mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 06:06:37 +03:00
* 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:
@@ -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]}` });
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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]}` });
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user