mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-19 09:36:59 +03:00
Fix: measure gradient body grounds instead of assuming white (low-contrast false positives) (#557)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
2ab054d1f4
commit
5f7b001cbe
@@ -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 };
|
||||
|
||||
+90
-30
@@ -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 };
|
||||
|
||||
@@ -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 <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
|
||||
|
||||
@@ -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 <a> and <button> with their own background get contrast checks', async () => {
|
||||
// SAFE_TAGS skips <a> and <button> by default to avoid noise on inline links
|
||||
// (text links inside paragraphs). When these elements are styled as buttons
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Fixture: gradient body ground, oklch stops (low-contrast)</title>
|
||||
<style>
|
||||
/* The impeccable.style false-positive class: the page ground is a gradient
|
||||
set via `background:` shorthand, so the body's computed backgroundColor
|
||||
is transparent and the ground exists only as a backgroundImage whose
|
||||
stops serialize in oklch. The detector must measure text against those
|
||||
stops (composited under any translucent layers), never against an
|
||||
assumed white body. */
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 48px;
|
||||
background: linear-gradient(180deg, oklch(7% 0.006 95), oklch(4% 0.004 95));
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
body > * { margin: 0 0 32px; }
|
||||
|
||||
/* ── Should flag ─────────────────────────────────────────────────── */
|
||||
.flag-muted-direct {
|
||||
width: 560px;
|
||||
color: oklch(30% 0 0);
|
||||
font-size: 16px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.flag-muted-nested-wrapper { width: 560px; }
|
||||
.flag-muted-nested {
|
||||
color: oklch(32% 0 0);
|
||||
font-size: 16px;
|
||||
line-height: 1.7;
|
||||
margin: 0;
|
||||
}
|
||||
.frosted-light {
|
||||
width: 480px;
|
||||
padding: 24px;
|
||||
background: rgba(250, 249, 246, 0.88);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.flag-light-on-frosted {
|
||||
color: oklch(88% 0 0);
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Should pass ─────────────────────────────────────────────────── */
|
||||
.pass-hero-title {
|
||||
width: 640px;
|
||||
color: oklch(91% 0 0);
|
||||
font-size: 48px;
|
||||
}
|
||||
.pass-hero-body {
|
||||
width: 560px;
|
||||
color: oklch(88% 0 0);
|
||||
font-size: 16px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.pass-on-raised {
|
||||
width: 420px;
|
||||
padding: 24px;
|
||||
background: oklch(11% 0.006 95);
|
||||
color: oklch(88% 0 0);
|
||||
font-size: 15px;
|
||||
}
|
||||
.pass-dark-on-frosted {
|
||||
color: oklch(15% 0.01 95);
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.pass-hex-gradient {
|
||||
width: 520px;
|
||||
padding: 24px;
|
||||
background-image: linear-gradient(180deg, #14120f, #0a0908);
|
||||
color: oklch(90% 0 0);
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1 class="pass-hero-title">Light display text on the lacquer gradient</h1>
|
||||
<p class="pass-hero-body">This paragraph sits directly on the body's dark gradient ground. Its near-white ink clears WCAG AA against both gradient stops by a wide margin, so a detector that measures the real ground must not flag it. A detector that assumes a white body flags it at about 1.3:1.</p>
|
||||
<div class="pass-on-raised">Light text on a raised solid oklch surface between the text and the body gradient still resolves through the normal opaque-ancestor walk.</div>
|
||||
<div class="frosted-light">
|
||||
<p class="pass-dark-on-frosted">Dark ink on a translucent light wash over the dark gradient. The wash composites to a light surface, so this passes; measuring raw gradient stops would wrongly flag it.</p>
|
||||
<p class="flag-light-on-frosted">Light ink on the same translucent light wash genuinely fails once the wash is composited over the stops; raw stops would wrongly pass it.</p>
|
||||
</div>
|
||||
<p class="flag-muted-direct">This muted dark-gray ink genuinely fails contrast against both stops of the dark gradient ground, which proves the gradient stops are being measured instead of the checks silently skipping.</p>
|
||||
<div class="flag-muted-nested-wrapper">
|
||||
<p class="flag-muted-nested">Muted ink reached through a transparent wrapper still resolves to the body gradient and still fails against its stops.</p>
|
||||
</div>
|
||||
<div class="pass-hex-gradient">A non-body gradient with legacy hex stops keeps working through the original rgb/hex parser, and light text on it passes.</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user