Compare commits

..
Author SHA1 Message Date
Abdul WahabandCursor 7426af446e Fix: give the color-mix hex fixture explicit pixel size
jsdom does no layout; Greptile asked for width/height on .mix-hex-brand so the static fixture stays deterministic. Prepared with AI assistance.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 06:54:26 +05:00
Abdul WahabandCursor 5444031942 Fix: skip hex nested in color-mix when measuring gradient contrast (#578)
parseGradientColors treated #000 inside color-mix() as a stop, so low-contrast scored text against phantom black. Prepared with AI assistance.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 06:47:29 +05:00
10 changed files with 94 additions and 103 deletions
+4 -4
View File
@@ -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) || parseAnyColor(currentStyle.backgroundColor);
const solidBg = parseRgb(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) || parseAnyColor(style.color);
const textColor = parseRgb(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) || parseAnyColor(style.backgroundColor);
const bg = parseRgb(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) || parseAnyColor(style.color) || candidate.textColor;
const textColor = parseRgb(style.color) || candidate.textColor;
if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' };
const rect = getDirectTextRect(el) || el.getBoundingClientRect();
+13 -5
View File
@@ -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 });
@@ -3986,7 +3994,7 @@ function checkElementAIPaletteDOM(el) {
}
// Check for neon text (vivid cyan/purple color on dark background)
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
const textColor = parseRgb(style.color);
if (textColor && hasChroma(textColor, 80)) {
const hue = getHue(textColor);
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
@@ -7281,7 +7289,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) || parseAnyColor(currentStyle.backgroundColor);
const solidBg = parseRgb(currentStyle.backgroundColor);
if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break;
current = current.parentElement;
}
@@ -7343,7 +7351,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) || parseAnyColor(style.color);
const textColor = parseRgb(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 +7648,7 @@ if (IS_BROWSER) {
return sample;
}
}
const bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
const bg = parseRgb(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 +7778,7 @@ if (IS_BROWSER) {
}
const style = getComputedStyle(el);
const textColor = parseRgb(style.color) || parseAnyColor(style.color) || candidate.textColor;
const textColor = parseRgb(style.color) || candidate.textColor;
if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' };
const rect = getDirectTextRect(el) || el.getBoundingClientRect();
+1 -1
View File
@@ -2752,7 +2752,7 @@ function checkElementAIPaletteDOM(el) {
}
// Check for neon text (vivid cyan/purple color on dark background)
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
const textColor = parseRgb(style.color);
if (textColor && hasChroma(textColor, 80)) {
const hue = getHue(textColor);
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
+8
View File
@@ -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 });
@@ -221,19 +221,6 @@ 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 <form> with <input name="id"> 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
@@ -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().
+26
View File
@@ -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',
+16 -1
View File
@@ -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)); width: 400px; height: 120px; 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 @@
<p>Purple-to-indigo gradient</p>
</div>
<h3>color-mix nested hex must not report phantom black</h3>
<!-- Dark ink on the mixed green is a real fail against #17372d. The
leaked-#000 extractor used to report it as on #000000 instead. -->
<div class="mix-hex-brand" data-test="mix-hex-brand-dark">
<p style="color: #3d2418; font-size: 16px;">Dark ink on a mixed green stop must not report on #000000</p>
</div>
<h3>currentcolor surface via var() token</h3>
<!-- background-color: currentcolor with color: var(--fixture-bone).
The surface is knowable (bone #e8e2d6), so the faint text on it is
@@ -248,6 +258,11 @@
</div>
</div>
<h3>color-mix nested hex is not a surface</h3>
<div class="mix-hex-brand" data-test="mix-hex-brand">
<p style="color: #f7f3ea; font-size: 16px;">WhatsApp-style light text on a mixed dark green gradient stays readable</p>
</div>
<h3>currentcolor surface with good contrast</h3>
<div class="currentcolor-surface" data-test="currentcolor-good">
<p class="currentcolor-good-text">Dark ink text on a bone currentcolor surface</p>
-77
View File
@@ -1,77 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OKLCH Neon Text Fixture</title>
<style>
:root {
--neon: oklch(0.85 0.2 195);
--muted: oklch(0.85 0.04 195);
--paper: oklch(0.9 0 0);
--ground: #050505;
--light: #f5f5f5;
}
body {
margin: 0;
padding: 32px;
background: var(--ground);
font-family: system-ui, sans-serif;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 24px;
max-width: 980px;
margin: 0 auto;
}
.column {
display: grid;
gap: 14px;
}
.column > h2 {
margin: 0 0 2px;
color: var(--paper);
font-size: 13px;
font-weight: 700;
letter-spacing: 0.08em;
line-height: 1.4;
text-transform: uppercase;
}
p {
margin: 0;
font-size: 18px;
}
.neon-cyan { color: var(--neon); }
.muted-cyan { color: var(--muted); }
.oklch-paper { color: var(--paper); }
.light-shell {
background: var(--light);
padding: 12px;
}
</style>
</head>
<body>
<main class="grid">
<section class="column" data-col="flag">
<h2>Should flag</h2>
<p class="neon-cyan">Cyan neon token</p>
</section>
<section class="column" data-col="pass">
<h2>Should pass</h2>
<p class="oklch-paper">Achromatic oklch on dark should pass</p>
<p class="muted-cyan">Muted cyan oklch on dark should pass</p>
<div class="light-shell">
<p class="neon-cyan">Cyan oklch on light ground should pass</p>
</div>
</section>
</main>
</body>
</html>
+1 -2
View File
@@ -9,7 +9,6 @@
--paper: #f7f3ee;
--ink: #171717;
--muted: #566174;
--flag-white: oklch(1 0 0);
}
body {
@@ -111,7 +110,7 @@
<h2>Should flag after pixel sampling</h2>
<article class="image-card light-image">
<p style="color: var(--flag-white);">White text on light image should be sampled by pixel contrast.</p>
<p style="color: rgb(255, 255, 255);">White text on light image should be sampled by pixel contrast.</p>
</article>
<article class="image-card dark-image">