From 1b7da15b56a78bdab6084e8a7a2cac7338adb2f8 Mon Sep 17 00:00:00 2001 From: Abdul Wahab Date: Sat, 22 Aug 2026 05:25:50 +0500 Subject: [PATCH 1/2] Fix: parse oklch in visual-contrast and neon-text (#592) Bare parseRgb() dropped Tailwind v4 computed colors, so contrast sampling skipped and neon-text never fired. AI-assisted (Cursor agent). Co-authored-by: Cursor --- cli/engine/browser/injected/index.mjs | 8 ++--- cli/engine/detect-antipatterns-browser.js | 10 +++---- cli/engine/rules/checks.mjs | 2 +- tests/detect-antipatterns-browser.test.mjs | 8 +++++ .../antipatterns/oklch-neon-text.html | 29 +++++++++++++++++++ .../antipatterns/visual-contrast.html | 3 +- 6 files changed, 49 insertions(+), 11 deletions(-) create mode 100644 tests/fixtures/antipatterns/oklch-neon-text.html diff --git a/cli/engine/browser/injected/index.mjs b/cli/engine/browser/injected/index.mjs index 4cf648906..6eb971450 100644 --- a/cli/engine/browser/injected/index.mjs +++ b/cli/engine/browser/injected/index.mjs @@ -626,7 +626,7 @@ if (IS_BROWSER) { if (currentStyle.filter && currentStyle.filter !== 'none') reasons.add('filter'); if (currentStyle.backdropFilter && currentStyle.backdropFilter !== 'none') reasons.add('backdrop filter'); - const solidBg = parseRgb(currentStyle.backgroundColor); + const solidBg = parseRgb(currentStyle.backgroundColor) || parseAnyColor(currentStyle.backgroundColor); if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break; current = current.parentElement; } @@ -688,7 +688,7 @@ if (IS_BROWSER) { // starve the url()-backed texts this mode exists to sample. if (options.imageOnly && !reasons.includes('image background')) continue; - const textColor = parseRgb(style.color); + const textColor = parseRgb(style.color) || parseAnyColor(style.color); const fontSize = parseFloat(style.fontSize) || 16; const fontWeight = parseInt(style.fontWeight) || 400; const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700); @@ -985,7 +985,7 @@ if (IS_BROWSER) { return sample; } } - const bg = parseRgb(style.backgroundColor); + const bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor); if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' }; return { status: 'unresolved', reason: 'no readable background' }; } @@ -1115,7 +1115,7 @@ if (IS_BROWSER) { } const style = getComputedStyle(el); - const textColor = parseRgb(style.color) || candidate.textColor; + const textColor = parseRgb(style.color) || parseAnyColor(style.color) || candidate.textColor; if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' }; const rect = getDirectTextRect(el) || el.getBoundingClientRect(); diff --git a/cli/engine/detect-antipatterns-browser.js b/cli/engine/detect-antipatterns-browser.js index 8d5bce3f6..5d7ea6f46 100644 --- a/cli/engine/detect-antipatterns-browser.js +++ b/cli/engine/detect-antipatterns-browser.js @@ -3986,7 +3986,7 @@ function checkElementAIPaletteDOM(el) { } // Check for neon text (vivid cyan/purple color on dark background) - const textColor = parseRgb(style.color); + const textColor = parseRgb(style.color) || parseAnyColor(style.color); if (textColor && hasChroma(textColor, 80)) { const hue = getHue(textColor); const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310); @@ -7281,7 +7281,7 @@ if (IS_BROWSER) { if (currentStyle.filter && currentStyle.filter !== 'none') reasons.add('filter'); if (currentStyle.backdropFilter && currentStyle.backdropFilter !== 'none') reasons.add('backdrop filter'); - const solidBg = parseRgb(currentStyle.backgroundColor); + const solidBg = parseRgb(currentStyle.backgroundColor) || parseAnyColor(currentStyle.backgroundColor); if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break; current = current.parentElement; } @@ -7343,7 +7343,7 @@ if (IS_BROWSER) { // starve the url()-backed texts this mode exists to sample. if (options.imageOnly && !reasons.includes('image background')) continue; - const textColor = parseRgb(style.color); + const textColor = parseRgb(style.color) || parseAnyColor(style.color); const fontSize = parseFloat(style.fontSize) || 16; const fontWeight = parseInt(style.fontWeight) || 400; const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700); @@ -7640,7 +7640,7 @@ if (IS_BROWSER) { return sample; } } - const bg = parseRgb(style.backgroundColor); + const bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor); if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' }; return { status: 'unresolved', reason: 'no readable background' }; } @@ -7770,7 +7770,7 @@ if (IS_BROWSER) { } const style = getComputedStyle(el); - const textColor = parseRgb(style.color) || candidate.textColor; + const textColor = parseRgb(style.color) || parseAnyColor(style.color) || candidate.textColor; if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' }; const rect = getDirectTextRect(el) || el.getBoundingClientRect(); diff --git a/cli/engine/rules/checks.mjs b/cli/engine/rules/checks.mjs index 8a5654625..bb075e90c 100644 --- a/cli/engine/rules/checks.mjs +++ b/cli/engine/rules/checks.mjs @@ -2752,7 +2752,7 @@ function checkElementAIPaletteDOM(el) { } // Check for neon text (vivid cyan/purple color on dark background) - const textColor = parseRgb(style.color); + const textColor = parseRgb(style.color) || parseAnyColor(style.color); if (textColor && hasChroma(textColor, 80)) { const hue = getHue(textColor); const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310); diff --git a/tests/detect-antipatterns-browser.test.mjs b/tests/detect-antipatterns-browser.test.mjs index b8ab7667d..7900fa527 100644 --- a/tests/detect-antipatterns-browser.test.mjs +++ b/tests/detect-antipatterns-browser.test.mjs @@ -221,6 +221,14 @@ describe('detectUrl — browser-only fixtures', () => { assert.equal(contrast.length, 3, `expected exactly the 3 flag-column cases, got ${contrast.length}:\n${snippets}`); }); + it('ai-color-palette: oklch neon text on a dark ground is flagged', async () => { + const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/oklch-neon-text.html`, { visualContrast: false }); + assert.ok( + f.some(r => r.antipattern === 'ai-color-palette' && /Cyan neon text on dark background/i.test(r.snippet || '')), + `expected cyan neon-text finding from oklch color, got: ${JSON.stringify(f.map(r => r.snippet))}`, + ); + }); + it('shadowed form.id: a
with does not crash the scan (issue #407)', async () => { // HTMLFormElement named-property shadowing makes form.id / form.className // return the child input element, whose .startsWith throws. Every Shopify diff --git a/tests/fixtures/antipatterns/oklch-neon-text.html b/tests/fixtures/antipatterns/oklch-neon-text.html new file mode 100644 index 000000000..648504044 --- /dev/null +++ b/tests/fixtures/antipatterns/oklch-neon-text.html @@ -0,0 +1,29 @@ + + + + + + OKLCH Neon Text Fixture + + + +

Cyan neon token

+ + diff --git a/tests/fixtures/antipatterns/visual-contrast.html b/tests/fixtures/antipatterns/visual-contrast.html index fc640ca7c..147292ab3 100644 --- a/tests/fixtures/antipatterns/visual-contrast.html +++ b/tests/fixtures/antipatterns/visual-contrast.html @@ -9,6 +9,7 @@ --paper: #f7f3ee; --ink: #171717; --muted: #566174; + --flag-white: oklch(1 0 0); } body { @@ -110,7 +111,7 @@

Should flag after pixel sampling

-

White text on light image should be sampled by pixel contrast.

+

White text on light image should be sampled by pixel contrast.

From 8347d77f54ff77b5a32f146a4f0fa4ee37d3a8f2 Mon Sep 17 00:00:00 2001 From: Abdul Wahab Date: Sat, 22 Aug 2026 05:33:17 +0500 Subject: [PATCH 2/2] Test: give the oklch neon fixture flag and pass columns (#592) The neon-text path is browser-only, so the matrix lives in the Puppeteer suite rather than the static fixture runner. AI-assisted (Cursor agent). Co-authored-by: Cursor --- tests/detect-antipatterns-browser.test.mjs | 13 +++-- .../antipatterns/oklch-neon-text.html | 54 +++++++++++++++++-- 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/tests/detect-antipatterns-browser.test.mjs b/tests/detect-antipatterns-browser.test.mjs index 7900fa527..e77d93231 100644 --- a/tests/detect-antipatterns-browser.test.mjs +++ b/tests/detect-antipatterns-browser.test.mjs @@ -221,12 +221,17 @@ describe('detectUrl — browser-only fixtures', () => { assert.equal(contrast.length, 3, `expected exactly the 3 flag-column cases, got ${contrast.length}:\n${snippets}`); }); - it('ai-color-palette: oklch neon text on a dark ground is flagged', async () => { + it('ai-color-palette: oklch neon text flags the should-flag column only', async () => { const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/oklch-neon-text.html`, { visualContrast: false }); - assert.ok( - f.some(r => r.antipattern === 'ai-color-palette' && /Cyan neon text on dark background/i.test(r.snippet || '')), - `expected cyan neon-text finding from oklch color, got: ${JSON.stringify(f.map(r => r.snippet))}`, + const neon = f.filter(r => + r.antipattern === 'ai-color-palette' && /neon text on dark background/i.test(r.snippet || '') ); + assert.equal( + neon.length, + 1, + `expected exactly 1 oklch neon-text finding, got ${neon.length}: ${JSON.stringify(f.map(r => r.snippet))}`, + ); + assert.match(neon[0].snippet || '', /Cyan neon text on dark background/i); }); it('shadowed form.id: a with does not crash the scan (issue #407)', async () => { diff --git a/tests/fixtures/antipatterns/oklch-neon-text.html b/tests/fixtures/antipatterns/oklch-neon-text.html index 648504044..4e757fc72 100644 --- a/tests/fixtures/antipatterns/oklch-neon-text.html +++ b/tests/fixtures/antipatterns/oklch-neon-text.html @@ -7,23 +7,71 @@ -

Cyan neon token

+
+
+

Should flag

+

Cyan neon token

+
+
+

Should pass

+

Achromatic oklch on dark should pass

+

Muted cyan oklch on dark should pass

+
+

Cyan oklch on light ground should pass

+
+
+