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 8d70e1987..8893bb323 100644 --- a/cli/engine/detect-antipatterns-browser.js +++ b/cli/engine/detect-antipatterns-browser.js @@ -3990,7 +3990,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); @@ -7285,7 +7285,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; } @@ -7347,7 +7347,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); @@ -7644,7 +7644,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' }; } @@ -7774,7 +7774,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 031501513..ee65af7af 100644 --- a/cli/engine/rules/checks.mjs +++ b/cli/engine/rules/checks.mjs @@ -2748,7 +2748,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..e77d93231 100644 --- a/tests/detect-antipatterns-browser.test.mjs +++ b/tests/detect-antipatterns-browser.test.mjs @@ -221,6 +221,19 @@ 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 flags the should-flag column only', async () => { + const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/oklch-neon-text.html`, { visualContrast: false }); + 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