Fix: stop gray-on-color false positives on Tailwind opacity and JSX (#633) (#707)

* Fix: stop gray-on-color false positives on Tailwind opacity and JSX (#633)

Do not treat bg-*/10 tints as solid fills, and pair gray text with chromatic backgrounds only inside the same tag and ternary arm.

AI assistance: prepared with Cursor Grok under maintainer direction.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix: keep nested ternary arms and post-ternary classes exclusive (#633)

Recurse exclusive class scopes so nested else-arms do not pair, and treat classes after a finished ternary as shared across both arms.

AI assistance: prepared with Cursor Grok under maintainer direction.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix: ignore nullish coalescing when scoping gray-on-color (#633)

The second ? in ?? was treated as a ternary delimiter, so exclusive
arms stayed in one scope. Prepared with Cursor Grok under maintainer
direction.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Abdul Wahab
2026-09-03 08:53:46 -07:00
committed by GitHub
co-authored by Cursor
parent 5a7e2837d2
commit 32b270f4e8
4 changed files with 249 additions and 4 deletions
+1 -1
View File
@@ -1490,7 +1490,7 @@ function checkColors(opts) {
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
if (grayMatch && colorBgMatch) {
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
}
+148 -2
View File
@@ -498,6 +498,147 @@ function isNeutralBorderColor(str) {
return isNeutralAuthoredColor(m[1]);
}
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
function scanJs(text, start, onChar) {
let stringQuote = '';
let inTemplate = false;
let paren = 0;
let brace = 0;
const interpBrace = [];
for (let i = start; i < text.length; i++) {
const char = text[i];
const prev = text[i - 1];
const next = text[i + 1];
if (stringQuote) {
if (char === '\\') { i++; continue; }
if (char === stringQuote) stringQuote = '';
continue;
}
if (inTemplate && interpBrace.length === 0) {
if (char === '\\') { i++; continue; }
if (char === '$' && next === '{') {
brace++;
interpBrace.push(brace);
i++;
continue;
}
if (char === '`') { inTemplate = false; continue; }
continue;
}
if (char === "'" || char === '"') { stringQuote = char; continue; }
if (char === '`') { inTemplate = true; continue; }
if (char === '(') { paren++; continue; }
if (char === ')') { paren--; continue; }
if (char === '{') { brace++; continue; }
if (char === '}') {
brace--;
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
continue;
}
if (onChar(char, i, prev, next, { paren, brace })) return;
}
}
function containingMarkupTag(line, index) {
let i = 0;
while (i < line.length) {
const tagStart = line.indexOf('<', i);
if (tagStart === -1) break;
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
i = tagStart + 1;
continue;
}
let tagEnd = -1;
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
if (char === '>' && depth.brace === 0) {
tagEnd = j;
return true;
}
return false;
});
if (tagEnd === -1) break;
if (index >= tagStart && index <= tagEnd) {
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
}
i = tagEnd + 1;
}
return { text: line, start: 0 };
}
function findTernarySplit(text) {
let qPos = -1;
let qParen = 0;
let qBrace = 0;
let nested = 0;
let colonPos = -1;
let split = null;
const isQuestion = (char, prev, next) =>
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
scanJs(text, 0, (char, i, prev, next, depth) => {
if (colonPos === -1) {
if (qPos === -1 && isQuestion(char, prev, next)) {
qPos = i;
qParen = depth.paren;
qBrace = depth.brace;
return false;
}
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
nested++;
return false;
}
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
if (nested) nested--;
else colonPos = i;
}
return false;
}
if (char === ',' && sameDepth(depth)) {
split = {
common: text.slice(0, qPos),
consequent: text.slice(qPos + 1, colonPos),
alternate: text.slice(colonPos + 1, i),
suffix: text.slice(i),
};
return true;
}
return false;
});
if (!split && qPos !== -1 && colonPos !== -1) {
split = {
common: text.slice(0, qPos),
consequent: text.slice(qPos + 1, colonPos),
alternate: text.slice(colonPos + 1),
suffix: '',
};
}
return split;
}
function exclusiveClassScopes(text) {
const split = findTernarySplit(text);
if (!split) return [text];
return [
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
];
}
function grayOnColorScopes(line, index) {
return exclusiveClassScopes(containingMarkupTag(line, index).text);
}
function grayOnColorPairs(line, grayClass, index) {
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
}
const REGEX_MATCHERS = [
// --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
@@ -545,8 +686,13 @@ const REGEX_MATCHERS = [
fmt: () => 'bg-clip-text + bg-gradient' },
// --- Tailwind gray on colored bg ---
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)),
fmt: (m, line) => {
const bg = grayOnColorPairs(line, m[0], m.index)
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
.find(Boolean);
return `${m[0]} on ${bg?.[0] || '?'}`;
} },
// --- Tailwind AI palette ---
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
+1 -1
View File
@@ -217,7 +217,7 @@ function checkColors(opts) {
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
if (grayMatch && colorBgMatch) {
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
}
+99
View File
@@ -223,6 +223,73 @@ describe('detectText — Tailwind side-tab', () => {
});
});
describe('detectText — gray-on-color (issue #633)', () => {
const grayOnColor = (src, file = 'Repro.jsx') =>
detectText(src, file).filter(r => r.antipattern === 'gray-on-color');
test('opacity hover — no finding', () => {
expect(grayOnColor('<button className="text-slate-300 hover:bg-red-500/10 hover:text-red-400">Log out</button>')).toHaveLength(0);
});
test('opacity rest — no finding', () => {
expect(grayOnColor('<button className="text-slate-300 bg-red-500/10">Log out</button>')).toHaveLength(0);
});
test('solid still flags', () => {
const f = grayOnColor('<button className="text-slate-300 bg-red-500">Log out</button>');
expect(f).toHaveLength(1);
expect(f[0].snippet).toContain('text-slate-300 on bg-red-500');
});
test('ternary arms — no finding', () => {
expect(grayOnColor(
'<button className={`px-4 py-2 text-sm rounded-lg transition-colors ${mode === "a" ? "bg-amber-600 text-white" : "bg-white/5 text-slate-400 hover:bg-white/10"}`}>Mode</button>',
)).toHaveLength(0);
});
test('ternary with comparison > does not close the tag early', () => {
expect(grayOnColor(
'<button className={mode > 0 ? "bg-amber-600 text-white" : "text-slate-400"}>Mode</button>',
)).toHaveLength(0);
});
test('siblings on one line — no finding', () => {
expect(grayOnColor(
'<div className="flex items-center gap-1.5"><div className="w-3 h-3 rounded bg-amber-500" /><span className="text-slate-400">Vital few</span></div>',
)).toHaveLength(0);
});
test('cn() simultaneous args still flag', () => {
const f = grayOnColor('<button className={cn("text-slate-400", "bg-blue-600")}>Go</button>');
expect(f).toHaveLength(1);
expect(f[0].snippet).toContain('text-slate-400 on bg-blue-600');
});
test('cn() + ternary with gray in common prefix still flags', () => {
const f = grayOnColor('<button className={cn("text-slate-400", mode === "a" ? "bg-amber-600" : "bg-white")}>Go</button>');
expect(f).toHaveLength(1);
expect(f[0].snippet).toContain('text-slate-400 on bg-amber-600');
});
test('nested exclusive ternary arms — no finding', () => {
expect(grayOnColor(
'<div className={a ? "bg-red-500" : b ? "text-slate-400" : "bg-blue-600"} />',
)).toHaveLength(0);
});
test('shared classes after a ternary still flag', () => {
const f = grayOnColor('<div className={cn(a ? "bg-red-500" : "bg-blue-600", "text-slate-400")} />');
expect(f).toHaveLength(1);
expect(f[0].snippet).toContain('text-slate-400 on bg-red-500');
});
test('nullish coalescing before a ternary — no finding', () => {
expect(grayOnColor(
'<div className={value ?? fallback ? "bg-red-500" : "text-slate-400"} />',
)).toHaveLength(0);
});
});
describe('detectText — broken images in source comments', () => {
test('ignores img tags mentioned in JavaScript comments', () => {
const source = [
@@ -1809,6 +1876,38 @@ describe('checkColors — oklch computed colors', () => {
});
});
describe('checkColors — Tailwind classList gray-on-color (issue #633)', () => {
const grayOnColor = (classList) =>
checkColors({
tag: 'div',
textColor: null,
bgColor: null,
effectiveBg: null,
effectiveBgStops: null,
fontSize: 14,
fontWeight: 400,
hasDirectText: true,
isEmojiOnly: false,
bgClip: '',
bgImage: '',
classList,
}).filter(r => r.id === 'gray-on-color');
test('opacity hover — no finding', () => {
expect(grayOnColor('text-slate-300 hover:bg-red-500/10')).toHaveLength(0);
});
test('opacity rest — no finding', () => {
expect(grayOnColor('text-slate-300 bg-red-500/10')).toHaveLength(0);
});
test('solid still flags', () => {
const f = grayOnColor('text-slate-300 bg-red-500');
expect(f).toHaveLength(1);
expect(f[0].snippet).toBe('text-slate-300 on bg-red-500');
});
});
// ---------------------------------------------------------------------------
// Numbered section labels — pure helpers
// ---------------------------------------------------------------------------