From 5f7b001cbe28f23e51d6c88a84eb91b58e3a9113 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Mon, 10 Aug 2026 12:35:55 -0700 Subject: [PATCH] Fix: measure gradient body grounds instead of assuming white (low-contrast false positives) (#557) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix: measure gradient body grounds instead of assuming white (browser mode) A page whose ground is set via background: linear-gradient(...) on body leaves backgroundColor transparent, and resolveBackground assumed white for any body/html-level gradient. In a real browser that assumption is wrong: the shorthand is always decomposed there, so reaching that branch means the ground truly is the gradient. On a dark oklch gradient ground (impeccable.style's lacquer) this turned every light-on-dark text into a ~1.3:1 "on #ffffff" low-contrast finding, ~120 false positives on one site. Browser mode now returns null so the caller measures against the actual gradient stops; the white assumption stays for jsdom, where the undecomposed-shorthand rationale still holds. Gradient stops also now parse modern color syntax: computed backgroundImage keeps oklch()/oklab()/hsl()/hwb() stops as authored, and parseGradientColors only read rgb()/hex, so a token-driven gradient ground was invisible even once the walk deferred to it. New parseGradientColorsModern routes those stops through parseAnyColor. Covered by a Puppeteer fixture (dark oklch body gradient): light text on the ground must not flag, muted dark-gray ink must, proving the stops are measured rather than the checks silently skipping. Prepared with AI assistance (Claude Code), on maintainer instruction. Co-Authored-By: Claude Fable 5 * Composite translucent layers over gradient stops; parse modern glow stops Review fixes from PR #557's automated reviews, applied with AI assistance (Claude Code): - Cursor Bugbot found the new browser-mode early return discarded the translucent ancestors resolveBackground had collected: text on a frosted wash over a body gradient was measured against raw stops. resolveGradientStops now collects translucent layers during its own walk (through readCascadeBackgroundColor, extracted so both walks read surfaces identically) and composites every stop under them. - Copilot flagged the other legacy parseGradientColors call sites. The glow-context fallback now uses parseGradientColorsModern, since body gradients reach it more often after this change. The AI-palette rule and the injected analytic sampler stay on the legacy parser deliberately: the former is a rule-behavior expansion deserving its own fixtures, the latter degrades to pixel sampling or a skip. - Greptile asked for standard fixture structure: the fixture now has labeled flag/pass cases (3 flag, 5 pass) including the frosted-wash pair that locks the overlay compositing in both directions and a legacy hex-stop gradient guarding the original parser path. The test scopes itself to the DOM path via visualContrast: false, the suite's established pattern; the screenshot sampler is a separate subsystem with its own coverage. Co-Authored-By: Claude Fable 5 * Pin gradient-ground flag cases to their snippet signatures Bugbot follow-up: a count-only assertion let an offsetting miss and false positive cancel, especially the frosted pair. Each flag case now asserts its full text-on-background signature, so the frosted case must measure against the composited wash and the count guard excludes any pass case flagging in its place. Applied with AI assistance (Claude Code). Co-Authored-By: Claude Fable 5 * Comments: the static path is the custom engine now, not jsdom jsdom left the dependency tree when the static-html engine (StaticElement + css-cascade.mjs) replaced it, and that engine does decompose the background shorthand, so the comments this PR added were dated in both name and rationale. Only comments touched by this PR are renamed; the ~40 legacy jsdom mentions elsewhere in checks.mjs are a separate sweep. Applied with AI assistance (Claude Code). Co-Authored-By: Claude Fable 5 * Static engine: measure body gradients too, dropping the white assumption Follow-up to the browser-mode fix: the white assumption for body/html gradients was a jsdom guard, and jsdom is gone. The static cascade decomposes the background shorthand (expandStaticDeclaration) and preserves var() colors for later resolution, so a missing solid under a body gradient is now as real in static mode as in a browser — and the static engine had the identical false-positive class (light text on a dark gradient ground flagged "on #ffffff") while missing the muted-ink true positives on the same page. The old catastrophic case cannot recur: opaque stops fully cover any hidden solid (they are the ground), alpha stops composite over the resolved base or the white canvas default, and unresolvable stops drop rather than guess. Static twin of the browser test added over the same fixture; the full suite, the url()-ancestor guard, and a source scan of impeccable.style (0 low-contrast findings) all stay clean. Applied with AI assistance (Claude Code). Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- cli/engine/detect-antipatterns-browser.js | 120 +++++++++++++----- cli/engine/rules/checks.mjs | 120 +++++++++++++----- tests/detect-antipatterns-browser.test.mjs | 25 ++++ tests/detect-antipatterns-fixtures.test.mjs | 17 +++ .../antipatterns/dark-gradient-ground.html | 97 ++++++++++++++ 5 files changed, 319 insertions(+), 60 deletions(-) create mode 100644 tests/fixtures/antipatterns/dark-gradient-ground.html diff --git a/cli/engine/detect-antipatterns-browser.js b/cli/engine/detect-antipatterns-browser.js index 400c9294b..a3422ff6c 100644 --- a/cli/engine/detect-antipatterns-browser.js +++ b/cli/engine/detect-antipatterns-browser.js @@ -2461,6 +2461,33 @@ function readOwnBackgroundColor(el, computedStyle) { return bg; } +// One element's background-color as the cascade walk sees it: computed style +// first (with the modern-color fallback), then, in static mode only, +// custom-prop resolution and the inline-shorthand peek. Shared by +// resolveBackground and resolveGradientStops so both walks read the same +// surfaces. +function readCascadeBackgroundColor(current, style, customPropMap) { + let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor); + if (!DETECTOR_IS_BROWSER && (!bg || bg.a < 0.1)) { + // The static engine can return literal "var(--X)" / "oklch(...)" strings. + // Resolve through customPropMap so Tailwind v4 color tokens become RGB. + if (customPropMap) { + bg = parseColorResolved(style.backgroundColor, customPropMap); + } + if (!bg || bg.a < 0.1) { + // Inline-style fallback for colors the static cascade did not surface + // on backgroundColor. + const rawStyle = current.getAttribute?.('style') || ''; + const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i); + const inlineBg = bgMatch ? bgMatch[1].trim() : ''; + if (inlineBg && !/gradient/i.test(inlineBg) && !/url\s*\(/i.test(inlineBg)) { + bg = parseColorResolved(inlineBg, customPropMap) || parseAnyColor(inlineBg); + } + } + } + return bg; +} + function resolveBackground(el, win, customPropMap) { let current = el; // Translucent layers (0.1 < a < 1) found on the way down to an opaque @@ -2489,24 +2516,7 @@ function resolveBackground(el, win, customPropMap) { // body backgrounds. // Real browsers serialize wide-gamut computed values as oklab()/oklch() // (e.g. any color-mix() result), which plain parseRgb misses. - let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor); - if (!DETECTOR_IS_BROWSER && (!bg || bg.a < 0.1)) { - // jsdom returns literal "var(--X)" / "oklch(...)" strings. Resolve - // through customPropMap so Tailwind v4 color tokens become RGB. - if (customPropMap) { - bg = parseColorResolved(style.backgroundColor, customPropMap); - } - if (!bg || bg.a < 0.1) { - // Inline-style fallback. jsdom doesn't decompose background - // shorthand, so colors set via inline style are otherwise invisible. - const rawStyle = current.getAttribute?.('style') || ''; - const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i); - const inlineBg = bgMatch ? bgMatch[1].trim() : ''; - if (inlineBg && !/gradient/i.test(inlineBg) && !/url\s*\(/i.test(inlineBg)) { - bg = parseColorResolved(inlineBg, customPropMap) || parseAnyColor(inlineBg); - } - } - } + const bg = readCascadeBackgroundColor(current, style, customPropMap); if (bg && bg.a > 0.1) { if (bg.a >= 0.99) return flatten(bg); @@ -2524,40 +2534,88 @@ function resolveBackground(el, win, customPropMap) { // • on other elements: bail to null and let the caller fall back // to gradient stops (gradient buttons / hero sections are real // bgs worth checking against). - if (hasGradientOrUrl) { - if (current.tagName === 'BODY' || current.tagName === 'HTML') { - return flatten({ r: 255, g: 255, b: 255, a: 1 }); - } - return null; - } + // A gradient or image with no solid color under it, at any level + // including body/html, means the visible ground is that layer itself. + // Return null so the caller measures against the actual gradient stops, + // or skips when nothing is parseable — skipping beats a wrong ratio. + // + // Body/html used to assume white here, a guard written for jsdom, which + // never decomposed the `background:` shorthand and so could not see the + // solid paper color a texture gradient usually sits on. It turned every + // light-on-dark page into a wall of low-contrast false positives (a dark + // oklch body gradient produced ~120 "on #ffffff" findings on one site). + // Both engines can see shorthand solids now — the browser natively, the + // static cascade via expandStaticDeclaration + var() resolution — so a + // missing solid is real, and the old failure case cannot recur: opaque + // stops fully cover any hidden solid (they ARE the ground), alpha stops + // composite over the resolved base or the white canvas default, and + // unresolvable stops drop rather than guess. + if (hasGradientOrUrl) return null; current = current.parentElement; } return flatten({ r: 255, g: 255, b: 255, a: 1 }); } +// parseGradientColors (shared) reads only the legacy serializations: rgb() +// and hex stops. Browsers keep modern-space stops in computed backgroundImage +// exactly as authored — `linear-gradient(oklch(7% 0.006 95), …)` stays oklch — +// which is what every token-driven page produces. Route those through +// parseAnyColor so a gradient ground is measurable rather than invisible. +function parseGradientColorsModern(bgImage) { + if (!bgImage || !/gradient/i.test(bgImage)) return []; + const colors = parseGradientColors(bgImage); + for (const m of bgImage.matchAll(/(?:oklch|oklab|hsla?|hwb)\(\s*[^()]*\)/gi)) { + const c = parseAnyColor(m[0]); + if (c) colors.push(c); + } + return colors; +} + // Walk parents looking for a gradient background and return its color stops. // Used as a fallback when resolveBackground() returns null because the // effective background is a gradient (no single solid color to compare against). +// Translucent solid layers found between the element and the gradient (frosted +// panels, glass washes) are composited over every stop, the same way +// resolveBackground flattens them over a solid base — raw stops alone would +// false-flag dark text on a light frosted wash over a dark gradient, and miss +// the inverse. function resolveGradientStops(el, win, customPropMap) { let current = el; + const overlays = []; while (current && current.nodeType === 1) { const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current); const bgImage = style.backgroundImage || ''; let stops = null; if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) { - const parsed = parseGradientColors(bgImage); + const parsed = parseGradientColorsModern(bgImage); if (parsed.length > 0) stops = parsed; } if (!stops && !DETECTOR_IS_BROWSER) { - // jsdom doesn't decompose `background:` shorthand — peek at the raw inline style + // Static mode: peek at the raw inline style for gradients the cascade did not surface const rawStyle = current.getAttribute?.('style') || ''; const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i); if (bgMatch && /gradient/i.test(bgMatch[1])) { - const parsed = parseGradientColors(bgMatch[1]); + const parsed = parseGradientColorsModern(bgMatch[1]); if (parsed.length > 0) stops = parsed; } } - if (stops) return compositeGradientStops(stops, current, win, customPropMap); + if (stops) { + const composited = compositeGradientStops(stops, current, win, customPropMap); + if (!composited || overlays.length === 0) return composited; + return composited.map(stop => { + let acc = stop; + for (let i = overlays.length - 1; i >= 0; i--) acc = compositeColorOver(overlays[i], acc); + return acc; + }); + } + const bg = readCascadeBackgroundColor(current, style, customPropMap); + if (bg && bg.a > 0.1) { + // An opaque surface above the gradient means the gradient never shows + // through here; resolveBackground would have returned it, so reaching + // this is defensive — bail rather than measure the wrong layer. + if (bg.a >= 0.99) return null; + overlays.push(bg); + } current = current.parentElement; } return null; @@ -3589,11 +3647,13 @@ function checkElementGlowDOM(el) { // If resolveBackground returns null (gradient), try to infer from the gradient colors let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el); if (!parentBg) { - // Gradient background — sample its colors to determine if it's dark + // Gradient background — sample its colors to determine if it's dark. + // Modern-syntax parsing matters here: body-level gradients now reach this + // fallback in browser mode, and their stops usually serialize as oklch. let cur = el.parentElement; while (cur && cur.nodeType === 1) { const bgImage = getComputedStyle(cur).backgroundImage || ''; - const gradColors = parseGradientColors(bgImage); + const gradColors = parseGradientColorsModern(bgImage); if (gradColors.length > 0) { // Average the gradient colors const avg = { r: 0, g: 0, b: 0 }; diff --git a/cli/engine/rules/checks.mjs b/cli/engine/rules/checks.mjs index aee24a947..57341b017 100644 --- a/cli/engine/rules/checks.mjs +++ b/cli/engine/rules/checks.mjs @@ -1670,6 +1670,33 @@ function readOwnBackgroundColor(el, computedStyle) { return bg; } +// One element's background-color as the cascade walk sees it: computed style +// first (with the modern-color fallback), then, in static mode only, +// custom-prop resolution and the inline-shorthand peek. Shared by +// resolveBackground and resolveGradientStops so both walks read the same +// surfaces. +function readCascadeBackgroundColor(current, style, customPropMap) { + let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor); + if (!DETECTOR_IS_BROWSER && (!bg || bg.a < 0.1)) { + // The static engine can return literal "var(--X)" / "oklch(...)" strings. + // Resolve through customPropMap so Tailwind v4 color tokens become RGB. + if (customPropMap) { + bg = parseColorResolved(style.backgroundColor, customPropMap); + } + if (!bg || bg.a < 0.1) { + // Inline-style fallback for colors the static cascade did not surface + // on backgroundColor. + const rawStyle = current.getAttribute?.('style') || ''; + const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i); + const inlineBg = bgMatch ? bgMatch[1].trim() : ''; + if (inlineBg && !/gradient/i.test(inlineBg) && !/url\s*\(/i.test(inlineBg)) { + bg = parseColorResolved(inlineBg, customPropMap) || parseAnyColor(inlineBg); + } + } + } + return bg; +} + function resolveBackground(el, win, customPropMap) { let current = el; // Translucent layers (0.1 < a < 1) found on the way down to an opaque @@ -1698,24 +1725,7 @@ function resolveBackground(el, win, customPropMap) { // body backgrounds. // Real browsers serialize wide-gamut computed values as oklab()/oklch() // (e.g. any color-mix() result), which plain parseRgb misses. - let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor); - if (!DETECTOR_IS_BROWSER && (!bg || bg.a < 0.1)) { - // jsdom returns literal "var(--X)" / "oklch(...)" strings. Resolve - // through customPropMap so Tailwind v4 color tokens become RGB. - if (customPropMap) { - bg = parseColorResolved(style.backgroundColor, customPropMap); - } - if (!bg || bg.a < 0.1) { - // Inline-style fallback. jsdom doesn't decompose background - // shorthand, so colors set via inline style are otherwise invisible. - const rawStyle = current.getAttribute?.('style') || ''; - const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i); - const inlineBg = bgMatch ? bgMatch[1].trim() : ''; - if (inlineBg && !/gradient/i.test(inlineBg) && !/url\s*\(/i.test(inlineBg)) { - bg = parseColorResolved(inlineBg, customPropMap) || parseAnyColor(inlineBg); - } - } - } + const bg = readCascadeBackgroundColor(current, style, customPropMap); if (bg && bg.a > 0.1) { if (bg.a >= 0.99) return flatten(bg); @@ -1733,40 +1743,88 @@ function resolveBackground(el, win, customPropMap) { // • on other elements: bail to null and let the caller fall back // to gradient stops (gradient buttons / hero sections are real // bgs worth checking against). - if (hasGradientOrUrl) { - if (current.tagName === 'BODY' || current.tagName === 'HTML') { - return flatten({ r: 255, g: 255, b: 255, a: 1 }); - } - return null; - } + // A gradient or image with no solid color under it, at any level + // including body/html, means the visible ground is that layer itself. + // Return null so the caller measures against the actual gradient stops, + // or skips when nothing is parseable — skipping beats a wrong ratio. + // + // Body/html used to assume white here, a guard written for jsdom, which + // never decomposed the `background:` shorthand and so could not see the + // solid paper color a texture gradient usually sits on. It turned every + // light-on-dark page into a wall of low-contrast false positives (a dark + // oklch body gradient produced ~120 "on #ffffff" findings on one site). + // Both engines can see shorthand solids now — the browser natively, the + // static cascade via expandStaticDeclaration + var() resolution — so a + // missing solid is real, and the old failure case cannot recur: opaque + // stops fully cover any hidden solid (they ARE the ground), alpha stops + // composite over the resolved base or the white canvas default, and + // unresolvable stops drop rather than guess. + if (hasGradientOrUrl) return null; current = current.parentElement; } return flatten({ r: 255, g: 255, b: 255, a: 1 }); } +// parseGradientColors (shared) reads only the legacy serializations: rgb() +// and hex stops. Browsers keep modern-space stops in computed backgroundImage +// exactly as authored — `linear-gradient(oklch(7% 0.006 95), …)` stays oklch — +// which is what every token-driven page produces. Route those through +// parseAnyColor so a gradient ground is measurable rather than invisible. +function parseGradientColorsModern(bgImage) { + if (!bgImage || !/gradient/i.test(bgImage)) return []; + const colors = parseGradientColors(bgImage); + for (const m of bgImage.matchAll(/(?:oklch|oklab|hsla?|hwb)\(\s*[^()]*\)/gi)) { + const c = parseAnyColor(m[0]); + if (c) colors.push(c); + } + return colors; +} + // Walk parents looking for a gradient background and return its color stops. // Used as a fallback when resolveBackground() returns null because the // effective background is a gradient (no single solid color to compare against). +// Translucent solid layers found between the element and the gradient (frosted +// panels, glass washes) are composited over every stop, the same way +// resolveBackground flattens them over a solid base — raw stops alone would +// false-flag dark text on a light frosted wash over a dark gradient, and miss +// the inverse. function resolveGradientStops(el, win, customPropMap) { let current = el; + const overlays = []; while (current && current.nodeType === 1) { const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current); const bgImage = style.backgroundImage || ''; let stops = null; if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) { - const parsed = parseGradientColors(bgImage); + const parsed = parseGradientColorsModern(bgImage); if (parsed.length > 0) stops = parsed; } if (!stops && !DETECTOR_IS_BROWSER) { - // jsdom doesn't decompose `background:` shorthand — peek at the raw inline style + // Static mode: peek at the raw inline style for gradients the cascade did not surface const rawStyle = current.getAttribute?.('style') || ''; const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i); if (bgMatch && /gradient/i.test(bgMatch[1])) { - const parsed = parseGradientColors(bgMatch[1]); + const parsed = parseGradientColorsModern(bgMatch[1]); if (parsed.length > 0) stops = parsed; } } - if (stops) return compositeGradientStops(stops, current, win, customPropMap); + if (stops) { + const composited = compositeGradientStops(stops, current, win, customPropMap); + if (!composited || overlays.length === 0) return composited; + return composited.map(stop => { + let acc = stop; + for (let i = overlays.length - 1; i >= 0; i--) acc = compositeColorOver(overlays[i], acc); + return acc; + }); + } + const bg = readCascadeBackgroundColor(current, style, customPropMap); + if (bg && bg.a > 0.1) { + // An opaque surface above the gradient means the gradient never shows + // through here; resolveBackground would have returned it, so reaching + // this is defensive — bail rather than measure the wrong layer. + if (bg.a >= 0.99) return null; + overlays.push(bg); + } current = current.parentElement; } return null; @@ -2798,11 +2856,13 @@ function checkElementGlowDOM(el) { // If resolveBackground returns null (gradient), try to infer from the gradient colors let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el); if (!parentBg) { - // Gradient background — sample its colors to determine if it's dark + // Gradient background — sample its colors to determine if it's dark. + // Modern-syntax parsing matters here: body-level gradients now reach this + // fallback in browser mode, and their stops usually serialize as oklch. let cur = el.parentElement; while (cur && cur.nodeType === 1) { const bgImage = getComputedStyle(cur).backgroundImage || ''; - const gradColors = parseGradientColors(bgImage); + const gradColors = parseGradientColorsModern(bgImage); if (gradColors.length > 0) { // Average the gradient colors const avg = { r: 0, g: 0, b: 0 }; diff --git a/tests/detect-antipatterns-browser.test.mjs b/tests/detect-antipatterns-browser.test.mjs index c0ef5a24f..40f370231 100644 --- a/tests/detect-antipatterns-browser.test.mjs +++ b/tests/detect-antipatterns-browser.test.mjs @@ -112,6 +112,31 @@ describe('detectUrl — browser-only fixtures', () => { } }); + it('low-contrast: a gradient body ground with oklch stops is measured, never assumed white', async () => { + // The impeccable.style FP class: `background: linear-gradient(oklch(7%…), + // oklch(4%…))` on body leaves backgroundColor transparent, and the old + // resolveBackground assumed white for any body-level gradient — turning + // every light-on-dark text on the page into a ~1.3:1 finding (~120 of + // them on one site). In a real browser, reaching that branch means the + // ground truly is the gradient, so its stops are the surface to measure. + // visualContrast: false scopes this to the DOM resolution path under test; + // the screenshot sampler is a separate subsystem with its own coverage. + const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/dark-gradient-ground.html`, { visualContrast: false }); + const contrast = f.filter(r => r.antipattern === 'low-contrast'); + const snippets = contrast.map(r => r.snippet || '').join('\n'); + assert.doesNotMatch(snippets, /on #ffffff/, `light-on-dark text was measured against an assumed white body:\n${snippets}`); + // Each FLAG case is pinned to its full text-on-background signature (the + // hexes are the engine's own deterministic oklch conversions), so an + // offsetting miss and false positive cannot cancel out — in particular + // the frosted pair: flag-light-on-frosted must be measured against the + // COMPOSITED wash (#dcdbd8), never a raw dark stop, while count === 3 + // proves no pass-column case (like pass-dark-on-frosted) flags instead. + assert.match(snippets, /text #2e2e2e on #010101/, `flag-muted-direct missing against the darker stop:\n${snippets}`); + assert.match(snippets, /text #333333 on #010101/, `flag-muted-nested missing against the darker stop:\n${snippets}`); + assert.match(snippets, /text #d7d7d7 on #dcdbd8/, `flag-light-on-frosted missing against the composited wash:\n${snippets}`); + assert.equal(contrast.length, 3, `expected exactly the 3 flag-column cases, got ${contrast.length}:\n${snippets}`); + }); + 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/detect-antipatterns-fixtures.test.mjs b/tests/detect-antipatterns-fixtures.test.mjs index 6492b1976..0292d8166 100644 --- a/tests/detect-antipatterns-fixtures.test.mjs +++ b/tests/detect-antipatterns-fixtures.test.mjs @@ -239,6 +239,23 @@ describe('detectHtml — static HTML/CSS fixtures', () => { ); }); + it('dark-gradient-ground: a gradient body ground is measured against its stops, never assumed white', async () => { + // Static-engine twin of the browser test: the page ground is a dark oklch + // gradient set via `background:` shorthand on body (backgroundColor stays + // transparent). The old walk assumed white for any body-level gradient, + // flagging every light text at ~1.3:1 "on #ffffff" and missing the muted + // dark-gray true positives entirely. Stops must be measured instead, and + // the frosted translucent wash must composite over them. + const f = await detectHtml(path.join(FIXTURES, 'dark-gradient-ground.html')); + const contrast = f.filter(r => r.antipattern === 'low-contrast'); + const snippets = contrast.map(r => r.snippet || '').join('\n'); + assert.doesNotMatch(snippets, /on #ffffff/, `light-on-dark text was measured against an assumed white body:\n${snippets}`); + assert.match(snippets, /text #2e2e2e on /, `flag-muted-direct missing:\n${snippets}`); + assert.match(snippets, /text #333333 on /, `flag-muted-nested missing:\n${snippets}`); + assert.match(snippets, /text #d7d7d7 on #d/, `flag-light-on-frosted missing against the composited wash:\n${snippets}`); + assert.equal(contrast.length, 3, `expected exactly the 3 flag-column cases, got ${contrast.length}:\n${snippets}`); + }); + it('color: styled and