diff --git a/cli/engine/detect-antipatterns-browser.js b/cli/engine/detect-antipatterns-browser.js index 8d5bce3f6..e3e5dc21c 100644 --- a/cli/engine/detect-antipatterns-browser.js +++ b/cli/engine/detect-antipatterns-browser.js @@ -773,14 +773,22 @@ function extractColorFunctionTokens(value) { function parseGradientColors(bgImage) { if (!bgImage || !bgImage.includes('gradient')) return []; const colors = []; + const tokenSpans = []; + let from = 0; // Stops arrive in whatever syntax the author wrote and the browser kept. // A dark ground painted as `linear-gradient(oklch(...), oklch(...))` used // to read as a gradient with no stops at all. for (const token of extractColorFunctionTokens(bgImage)) { + const start = bgImage.indexOf(token, from); + if (start < 0) break; + tokenSpans.push({ start, end: start + token.length }); + from = start + token.length; const c = parseAnyColor(token); if (c) colors.push(c); } for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) { + // Nested hex inside color-mix is an ingredient, not a stop (issue #578). + if (tokenSpans.some(s => m.index >= s.start && m.index < s.end)) continue; const h = m[1]; if (h.length === 6) { colors.push({ r: parseInt(h.slice(0,2),16), g: parseInt(h.slice(2,4),16), b: parseInt(h.slice(4,6),16), a: 1 }); diff --git a/cli/engine/shared/color.mjs b/cli/engine/shared/color.mjs index d2524ce52..983e15330 100644 --- a/cli/engine/shared/color.mjs +++ b/cli/engine/shared/color.mjs @@ -103,14 +103,22 @@ function extractColorFunctionTokens(value) { function parseGradientColors(bgImage) { if (!bgImage || !bgImage.includes('gradient')) return []; const colors = []; + const tokenSpans = []; + let from = 0; // Stops arrive in whatever syntax the author wrote and the browser kept. // A dark ground painted as `linear-gradient(oklch(...), oklch(...))` used // to read as a gradient with no stops at all. for (const token of extractColorFunctionTokens(bgImage)) { + const start = bgImage.indexOf(token, from); + if (start < 0) break; + tokenSpans.push({ start, end: start + token.length }); + from = start + token.length; const c = parseAnyColor(token); if (c) colors.push(c); } for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) { + // Nested hex inside color-mix is an ingredient, not a stop (issue #578). + if (tokenSpans.some(s => m.index >= s.start && m.index < s.end)) continue; const h = m[1]; if (h.length === 6) { colors.push({ r: parseInt(h.slice(0,2),16), g: parseInt(h.slice(2,4),16), b: parseInt(h.slice(4,6),16), a: 1 }); diff --git a/tests/detect-antipatterns-fixtures.test.mjs b/tests/detect-antipatterns-fixtures.test.mjs index fb9ee76f9..b94e64efb 100644 --- a/tests/detect-antipatterns-fixtures.test.mjs +++ b/tests/detect-antipatterns-fixtures.test.mjs @@ -272,6 +272,31 @@ describe('detectHtml — static HTML/CSS fixtures', () => { ); }); + it('color: nested #000 inside color-mix must not become on #000000', async () => { + const f = await detectHtml(path.join(FIXTURES, 'color.html')); + const light = f.filter(r => + (r.antipattern === 'low-contrast' || r.antipattern === 'gray-on-color') && + /#f7f3ea/i.test(r.snippet || '') + ); + assert.equal( + light.length, 0, + `light text on the mixed green must not flag: ${light.map(r => r.snippet).join('; ')}`, + ); + const leaked = f.filter(r => /#3d2418 on #000000/i.test(r.snippet || '')); + assert.equal( + leaked.length, 0, + `nested #000 must not become on #000000: ${leaked.map(r => r.snippet).join('; ')}`, + ); + assert.ok( + f.some(r => + r.antipattern === 'low-contrast' && + /#3d2418/i.test(r.snippet || '') && + /#17372d|#295344/i.test(r.snippet || '') + ), + 'dark ink on the mixed stop should flag against the mix, not phantom black', + ); + }); + it('color: white text on background-image url() ancestor is not flagged as low-contrast', async () => { const f = await detectHtml(path.join(FIXTURES, 'color.html')); // The pass column has white text on a div with background-image: url(). diff --git a/tests/detect-antipatterns.test.js b/tests/detect-antipatterns.test.js index 2c7141365..8eda903f4 100644 --- a/tests/detect-antipatterns.test.js +++ b/tests/detect-antipatterns.test.js @@ -1599,6 +1599,32 @@ describe('hover contrast + color-mix', () => { expect(stops).toHaveLength(2); }); + test('parseGradientColors resolves color-mix stops without leaking nested hex', () => { + const stops = parseGradientColors('linear-gradient(135deg, color-mix(in srgb, #2d5a4a 92%, #000), color-mix(in srgb, #1a3d32 90%, #000))'); + expect(stops).toHaveLength(2); + expect(stops[0]).toEqual({ r: 41, g: 83, b: 68, a: 1 }); + expect(stops[1]).toEqual({ r: 23, g: 55, b: 45, a: 1 }); + }); + + test('parseGradientColors does not leak nested hex when color-mix has var()', () => { + const stops = parseGradientColors('linear-gradient(135deg, color-mix(in srgb, var(--brand) 92%, #000), color-mix(in srgb, var(--brand-deep) 90%, #000))'); + expect(stops).toEqual([]); + }); + + test('parseGradientColors still collects sibling bare hex stops beside color-mix', () => { + const stops = parseGradientColors('linear-gradient(color-mix(in srgb, #2d5a4a 92%, #000), #ffffff)'); + expect(stops).toHaveLength(2); + expect(stops[0]).toEqual({ r: 41, g: 83, b: 68, a: 1 }); + expect(stops[1]).toEqual({ r: 255, g: 255, b: 255, a: 1 }); + }); + + test('parseGradientColors still reads bare hex gradient stops', () => { + const stops = parseGradientColors('linear-gradient(#2d5a4a, #000)'); + expect(stops).toHaveLength(2); + expect(stops[0]).toEqual({ r: 45, g: 90, b: 74, a: 1 }); + expect(stops[1]).toEqual({ r: 0, g: 0, b: 0, a: 1 }); + }); + test('checkHoverContrast flags a failing hover pair on a styled control', () => { const f = checkHoverContrast({ tag: 'a', diff --git a/tests/fixtures/antipatterns/color.html b/tests/fixtures/antipatterns/color.html index 58ad354f7..1f77a7edb 100644 --- a/tests/fixtures/antipatterns/color.html +++ b/tests/fixtures/antipatterns/color.html @@ -45,11 +45,14 @@ .mix-dark-wrap { background: #0f0f11; padding: 16px; } .mix-glow { background: linear-gradient(160deg, color-mix(in oklab, oklch(90% 0.02 95) 16%, transparent) 0%, #141419 65%); padding: 20px; } .mix-glow p { color: #ded9cf; font-size: 16px; } + /* issue #578 — #000 inside color-mix is an ingredient; white-ish text on + the mixed dark green must not be scored against phantom black. */ + .mix-hex-brand { background: linear-gradient(135deg, color-mix(in srgb, var(--mix-hex-brand) 92%, #000), color-mix(in srgb, var(--mix-hex-brand-deep) 90%, #000)); padding: 20px; } /* currentcolor surface: background-color paints with the element's own text color, which is itself a var() token here. jsdom hands both through verbatim, so the walk must resolve the token via the custom-prop map instead of abstaining on a knowable surface. */ - :root { --fixture-bone: #e8e2d6; } + :root { --fixture-bone: #e8e2d6; --mix-hex-brand: #2d5a4a; --mix-hex-brand-deep: #1a3d32; } .currentcolor-surface { background-color: currentcolor; color: var(--fixture-bone); padding: 14px 16px; border-radius: 10px; margin-bottom: 10px; } .currentcolor-low-text { color: #cfc9bd; font-size: 14px; } .currentcolor-good-text { color: #3a352c; font-size: 14px; } @@ -133,6 +136,13 @@

Purple-to-indigo gradient

+

color-mix nested hex must not report phantom black

+ +
+

Dark ink on a mixed green stop must not report on #000000

+
+

currentcolor surface via var() token