Compare commits

...
Author SHA1 Message Date
Paul BakausandClaude 294199d542 fix: abstain on translucent gradients over images, drop phantom color-mix stops
Two follow-up review findings on the merge with main.

A gradient leading a url() layer was treated as a resolvable surface even
when its stops are translucent, so the glow and AI-palette hunts averaged
wash stops (a 20% black wash reads as pure black) while the real surface
blends with image pixels the engine cannot read. resolveBackgroundInfo now
marks gradient-over-image unresolved unless every readable stop of the
leading gradient is opaque, in which case the gradient provably covers the
image and remains the scorable surface.

parseGradientColorsModern predated this branch's parseGradientColors
rewrite: its second regex pass re-extracted color tokens nested inside
color-mix() stops that the shared parser already captures whole via
balanced-paren tokens, appending ingredient colors that are never painted.
The worst-case stop ratio then invented low-contrast findings against a
color nobody sees. The helper is removed; all callers use the shared
parser, which covers the modern syntaxes it existed for.

Fixture coverage pins both: the translucent-wash-over-image glow abstains
in both engines, an opaque gradient over an image still flags in the
browser, and the color-mix wash case stays clean in the static engine.
Each new assertion was verified to fail against the previous engine.

Addresses Greptile and Cursor Bugbot review findings on PR #541.

AI-assisted-by: Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 14:51:01 -04:00
Paul BakausandClaude da286a938d Merge origin/main into fix/browser-bg-resolution-dark-themes
Reconciles this branch's resolveBackgroundInfo contract (unresolved-surface
abstention, url-on-top handling, currentcolor substitution, glow adapter fix)
with main's #557 gradient-ground work (readCascadeBackgroundColor,
parseGradientColorsModern, resolveGradientStops rewrite) and #559's scoped
ignores and invisible-at-rest skips. Generated browser bundle rebuilt with
bun run build:browser.

Merge conflict resolution performed with AI assistance (Claude Code).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 13:56:55 -04:00
Paul BakausandClaude 1fec78d3d2 fix: keep zero-offset glow findings when the surface is unreadable
The browser glow adapter abstained from the whole element when
resolveBackgroundInfo reported an unreadable surface, which also dropped
zero-offset chromatic halo findings that do not depend on the background
at all. It now skips only the gradient hunt past the unreadable layer and
scores the halo tell against a null surface, matching what the static
loop already did. Fixture cases pin both sides: the halo over a url()
image ancestor flags in both engines, and an offset chromatic shadow on
the same unknown surface stays abstained.

Also hardens the currentcolor background substitution with the
parseColorResolved fallback used by the text-color path, and adds fixture
coverage proving tokenized currentcolor surfaces already resolve through
the static cascade (flag when knowable, abstain when the token is
undefined).

Addresses Cursor Bugbot review findings on PR #541.

AI-assisted-by: Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 13:50:49 -04:00
Paul BakausandClaude 6df96fbced fix: address PR review bot findings on background resolution
- Treat a url() image layer stacked above a gradient as an occluding,
  unreadable surface: resolveBackgroundInfo now returns unresolved so the
  gradient-stop fallback never measures stops the image hides
  (greptile-apps finding, reproduced in Chrome).
- Route the glow and AI-palette DOM adapters through resolveBackgroundInfo
  so an unresolved surface makes them abstain instead of hunting gradient
  ancestors past an unreadable layer (Cursor Bugbot finding).
- Resolve background-color keywords jsdom hands through verbatim:
  inherit now reads as no-paint (the ancestor walk IS its resolution) and
  currentcolor substitutes the element's own computed text color instead
  of forcing an abstention (Copilot finding).
- Regression coverage in the dark-theme fixture for all three, asserted in
  both the jsdom and real-Chrome suites; browser detector regenerated.

AI-assisted: prepared with Claude Code at the maintainer's direction.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-09 16:00:51 -07:00
Paul Bakaus 331af89eae Stop assuming white when a background cannot be read
Dark themes came back from a scan buried in low-contrast findings that
all claimed the light text sat on #ffffff. Two live runs against
impeccable.style produced 102 and 95 of them.

Two causes, both fixed here.

Parsing. Browsers keep the authored color space in getComputedStyle
output: oklch() stayed oklch, but color-mix results come back as
color(srgb 1.04 0.72 -0.21), wide-gamut authors get color(display-p3
...), and lch()/lab() survive verbatim. The parser read none of those, so
those surfaces registered as unset. parseGradientColors was worse: it
matched only rgba() and #hex, so a ground painted as
linear-gradient(oklch(...), oklch(...)) counted as a gradient with no
stops at all.

Guessing. When the ancestor walk ran out of readable color it returned
white, and on a body-level gradient it returned white without even
looking. Light copy on a lacquer-black page then measured 1.3:1 against a
canvas the visitor never sees.

resolveBackgroundInfo now separates three outcomes: a resolved surface, a
gradient the caller should fall back to stops for, and an unreadable
layer. The last one makes both color adapters skip their contrast checks
entirely. White survives in exactly one case, the one that earns it:
every layer up to the document root was genuinely transparent.

Color conversions moved to cli/engine/shared/color.mjs and gained lab,
lch, and color() for srgb, srgb-linear, and display-p3. Spaces outside
that set return null, which now routes to abstention rather than to a
color nobody painted. Every conversion is pinned against what Chrome
itself paints for the same string.

Rescanning impeccable.style: 102 low-contrast findings down to 30, none
of them on an invented white ground.

Assisted-by: Claude Code
2026-08-07 11:18:14 -07:00
9 changed files with 1678 additions and 687 deletions
File diff suppressed because it is too large Load Diff
+130 -340
View File
@@ -11,14 +11,21 @@ import {
isBrandFontOnOwnDomain,
} from '../shared/constants.mjs';
import {
CSS_NAMED_COLORS,
colorToHex,
compositeColorOver,
contrastRatio,
getHue,
hasChroma,
isNeutralColor,
isNoPaintColorValue,
oklchToRgb,
parseAnyColor,
parseColorMix,
parseGradientColors,
parseRgb,
relativeLuminance,
splitTopLevelCommas,
} from '../shared/color.mjs';
import { extractGoogleFontFamilies } from '../shared/fonts.mjs';
@@ -1731,7 +1738,7 @@ function readOwnBackgroundColor(el, computedStyle) {
// 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
// resolveBackgroundInfo and resolveGradientStops so both walks read the same
// surfaces.
function readCascadeBackgroundColor(current, style, customPropMap) {
let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
@@ -1755,7 +1762,19 @@ function readCascadeBackgroundColor(current, style, customPropMap) {
return bg;
}
function resolveBackground(el, win, customPropMap) {
// Walk up for the surface the element's text is painted on.
//
// Returns { color, unresolved }:
// • color set — the effective surface, overlays composited in.
// • unresolved: true — a layer on the way up paints a color this parser
// cannot read, so the surface is unknown. Callers
// must SKIP their contrast checks. Guessing white
// here is what flooded dark themes with false
// "on #ffffff" findings: one abstention costs a
// single finding, one wrong guess costs a hundred.
// • both null/false — no solid color, but a gradient or image is in
// play; callers fall back to its color stops.
function resolveBackgroundInfo(el, win, customPropMap) {
let current = el;
// Translucent layers (0.1 < a < 1) found on the way down to an opaque
// base. A browser composites these over the base; the old behavior
@@ -1783,59 +1802,81 @@ 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.
const bg = readCascadeBackgroundColor(current, style, customPropMap);
let bg = readCascadeBackgroundColor(current, style, customPropMap);
// `background-color: currentcolor` paints with the element's own text
// color — real paint whose value we know. Real browsers resolve the
// keyword before getComputedStyle output; jsdom hands it through
// verbatim, and without this substitution the layer would read as
// unparseable and force a needless abstention.
if ((!bg || bg.a < 0.1) && /^currentcolor$/i.test(String(style.backgroundColor || '').trim())) {
// The static cascade resolves var() text tokens before checks run, so
// style.color is normally already an rgb string here; parseColorResolved
// is defense in depth for any future caller that passes a live
// customPropMap (it matches the text-color path in checkElementColors
// and reduces to parseAnyColor when the map is null or absent).
bg = parseRgb(style.color) || parseColorResolved(style.color, customPropMap);
}
if (bg && bg.a > 0.1) {
if (bg.a >= 0.99) return flatten(bg);
if (bg.a >= 0.99) return { color: flatten(bg), unresolved: false };
overlays.push(bg);
} else if (!bg && !isNoPaintColorValue(style.backgroundColor)) {
// This layer names a color we could not parse (a color space we do not
// model, an unresolved var(), a syntax newer than the parser). It may
// well be opaque, which would make every ancestor below it invisible —
// so the surface is unknown and the walk stops here rather than
// reporting an ancestor the visitor never sees.
return { color: null, unresolved: true };
}
// No solid bg-color at this level, but this level paints an image. CSS
// stacks background-image layers first-on-top, so which layer leads
// decides what the visitor sees:
// • gradient on top — the gradient is the surface. Hand the caller a
// null color so it falls back to the gradient's own stops (body
// grounds, gradient buttons, hero sections).
// • url() on top — the surface is an image whose pixels this engine
// cannot read, and it may fully cover every layer and ancestor
// beneath it. Same contract as an unparseable color: abstain, so
// the gradient-stop fallback never measures a gradient the image
// hides (the shipped miss: `url(photo), linear-gradient(...)`
// reported low-contrast against the invisible gradient's stops).
if (hasGradientOrUrl) {
const layers = splitTopLevelCommas(bgImage);
const topPaintLayer = layers.find(
(layer) => /gradient\s*\(/i.test(layer) || /url\s*\(/i.test(layer),
);
const gradientOnTop = !!topPaintLayer
&& /gradient\s*\(/i.test(topPaintLayer)
&& !/^\s*url\s*\(/i.test(topPaintLayer);
if (!gradientOnTop) return { color: null, unresolved: true };
// Gradient on top of a url() layer: the image shows through wherever
// the gradient is not fully opaque, so a translucent wash like
// `linear-gradient(rgba(0,0,0,.2), rgba(0,0,0,.2)), url(photo)` paints
// a blend with pixels this engine cannot read. Only a gradient whose
// every readable stop is opaque provably covers the image; otherwise
// the surface is unknown — abstain rather than hand callers gradient
// stops (or a stop average) the visitor never sees unmixed.
const urlBeneath = layers.some(
(layer) => layer !== topPaintLayer && /url\s*\(/i.test(layer),
);
if (urlBeneath) {
const topStops = parseGradientColors(topPaintLayer);
const provablyOpaque = topStops.length > 0 && topStops.every((s) => (s.a ?? 1) >= 0.99);
if (!provablyOpaque) return { color: null, unresolved: true };
}
return { color: null, unresolved: false };
}
// No solid bg-color at this level. If THIS level has a gradient/url
// with no underlying solid color we can read:
// • on body/html: assume white. Body-level gradients are almost
// always decorative texture (paper grain, noise) on top of a
// solid bg-color the page set via `background: var(--paper)`
// shorthand — which jsdom can't decompose into bg-color. The
// downstream gradient-stops fallback path produces catastrophic
// false positives in this case (gradient noise stops have
// accidental browns/blacks that look like card backgrounds).
// • 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).
// 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 });
// Every layer up to the document root was genuinely see-through, so the
// browser paints its default canvas. This is the ONLY case that earns the
// white assumption.
return { color: flatten({ r: 255, g: 255, b: 255, a: 1 }), unresolved: false };
}
// 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;
function resolveBackground(el, win, customPropMap) {
return resolveBackgroundInfo(el, win, customPropMap).color;
}
// Walk parents looking for a gradient background and return its color stops.
@@ -1861,7 +1902,10 @@ function resolveGradientStops(el, win, customPropMap) {
if (bgImage && bgImage !== 'none' && /url\s*\(/i.test(bgImage)) return null;
let stops = null;
if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
const parsed = parseGradientColorsModern(bgImage);
// parseGradientColors (shared) reads modern-space stops too — oklch,
// color-mix and friends via balanced-paren token capture — so browser
// computed values that keep the authored syntax stay measurable.
const parsed = parseGradientColors(bgImage);
if (parsed.length > 0) stops = parsed;
}
if (!stops && !DETECTOR_IS_BROWSER) {
@@ -1869,7 +1913,7 @@ function resolveGradientStops(el, win, customPropMap) {
const rawStyle = current.getAttribute?.('style') || '';
const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
if (bgMatch && /gradient/i.test(bgMatch[1])) {
const parsed = parseGradientColorsModern(bgMatch[1]);
const parsed = parseGradientColors(bgMatch[1]);
if (parsed.length > 0) stops = parsed;
}
}
@@ -2115,13 +2159,19 @@ function checkElementColorsDOM(el) {
if (style.visibility === 'hidden' || effectiveOpacityDOM(el) <= 0.02) return [];
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
let effectiveBg = resolveBackground(el);
const bgInfo = resolveBackgroundInfo(el);
let effectiveBg = bgInfo.color;
// An unreadable surface anywhere up the chain: skip the gradient-stop
// fallback too, so nothing downstream measures against a ground we never
// resolved.
let surfaceUnresolved = bgInfo.unresolved;
let ownBg = readOwnBackgroundColor(el, style);
if (!ownBg || (ownBg.a ?? 1) <= 0.5) {
const pseudoSurface = readPseudoSurfaceDOM(el, rect);
if (pseudoSurface) {
ownBg = pseudoSurface;
effectiveBg = pseudoSurface;
surfaceUnresolved = false;
}
}
return checkColors({
@@ -2133,8 +2183,8 @@ function checkElementColorsDOM(el) {
// an oklch token near its own oklch background).
textColor: parseRgb(style.color) || parseAnyColor(style.color),
bgColor: ownBg,
effectiveBg,
effectiveBgStops: effectiveBg ? null : resolveGradientStops(el),
effectiveBg: surfaceUnresolved ? null : effectiveBg,
effectiveBgStops: surfaceUnresolved || effectiveBg ? null : resolveGradientStops(el),
fontSize: parseFloat(style.fontSize) || 16,
fontWeight: parseInt(style.fontWeight) || 400,
hasDirectText,
@@ -2284,283 +2334,6 @@ function resolveVarRefs(raw, customPropMap, depth = 0) {
});
}
// OKLCH → sRGB conversion (Björn Ottosson's matrices). L in 0..1 (or %),
// C in 0..~0.4 typical, H in degrees. Returns clamped {r,g,b,a:1} in 0..255.
// Needed because jsdom doesn't compute oklch() values — getComputedStyle
// returns the literal "oklch(...)" string. Without this, the entire
// Tailwind v4 color palette (which is OKLCH-based) is invisible to the
// detector's contrast / color checks.
function oklchToRgb(L, C, H) {
const hRad = (H * Math.PI) / 180;
return oklabToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
}
function oklabToRgb(L, a, b) {
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
const rLin = 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc;
const gLin = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc;
const bLin = -0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc;
const enc = (x) => {
const c = Math.max(0, Math.min(1, x));
return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
};
return {
r: Math.round(enc(rLin) * 255),
g: Math.round(enc(gLin) * 255),
b: Math.round(enc(bLin) * 255),
a: 1,
};
}
function hslToRgb(h, s, l) {
h = ((h % 360) + 360) % 360;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m0 = l - c / 2;
const [r, g, b] =
h < 60 ? [c, x, 0] :
h < 120 ? [x, c, 0] :
h < 180 ? [0, c, x] :
h < 240 ? [0, x, c] :
h < 300 ? [x, 0, c] : [c, 0, x];
return {
r: Math.round((r + m0) * 255),
g: Math.round((g + m0) * 255),
b: Math.round((b + m0) * 255),
a: 1,
};
}
function hwbToRgb(h, w, bl) {
if (w + bl >= 1) {
const g = Math.round((w / (w + bl)) * 255);
return { r: g, g, b: g, a: 1 };
}
const base = hslToRgb(h, 1, 0.5);
const mix = (c) => Math.round(((c / 255) * (1 - w - bl) + w) * 255);
return { r: mix(base.r), g: mix(base.g), b: mix(base.b), a: 1 };
}
// Common CSS named colors — the handful that actually show up in generated
// UIs, not the full 148-name spec list. Includes the achromatic names so a
// named gray parses (and correctly reads as no-chroma) instead of being
// treated as an unknown color.
const CSS_NAMED_COLORS = {
black: { r: 0, g: 0, b: 0 },
white: { r: 255, g: 255, b: 255 },
gray: { r: 128, g: 128, b: 128 },
grey: { r: 128, g: 128, b: 128 },
silver: { r: 192, g: 192, b: 192 },
dimgray: { r: 105, g: 105, b: 105 },
darkgray: { r: 169, g: 169, b: 169 },
lightgray: { r: 211, g: 211, b: 211 },
gainsboro: { r: 220, g: 220, b: 220 },
whitesmoke: { r: 245, g: 245, b: 245 },
red: { r: 255, g: 0, b: 0 },
crimson: { r: 220, g: 20, b: 60 },
tomato: { r: 255, g: 99, b: 71 },
coral: { r: 255, g: 127, b: 80 },
salmon: { r: 250, g: 128, b: 114 },
orange: { r: 255, g: 165, b: 0 },
gold: { r: 255, g: 215, b: 0 },
yellow: { r: 255, g: 255, b: 0 },
olive: { r: 128, g: 128, b: 0 },
lime: { r: 0, g: 255, b: 0 },
green: { r: 0, g: 128, b: 0 },
teal: { r: 0, g: 128, b: 128 },
turquoise: { r: 64, g: 224, b: 208 },
cyan: { r: 0, g: 255, b: 255 },
aqua: { r: 0, g: 255, b: 255 },
skyblue: { r: 135, g: 206, b: 235 },
dodgerblue: { r: 30, g: 144, b: 255 },
blue: { r: 0, g: 0, b: 255 },
navy: { r: 0, g: 0, b: 128 },
indigo: { r: 75, g: 0, b: 130 },
rebeccapurple: { r: 102, g: 51, b: 153 },
purple: { r: 128, g: 0, b: 128 },
violet: { r: 238, g: 130, b: 238 },
orchid: { r: 218, g: 112, b: 214 },
magenta: { r: 255, g: 0, b: 255 },
fuchsia: { r: 255, g: 0, b: 255 },
hotpink: { r: 255, g: 105, b: 180 },
pink: { r: 255, g: 192, b: 203 },
maroon: { r: 128, g: 0, b: 0 },
};
// Split a string on top-level commas (ignoring commas nested in parens).
function splitTopLevelCommas(str) {
const parts = [];
let depth = 0, start = 0;
for (let i = 0; i < str.length; i++) {
const ch = str[i];
if (ch === '(') depth++;
else if (ch === ')') depth = Math.max(0, depth - 1);
else if (ch === ',' && depth === 0) {
parts.push(str.slice(start, i).trim());
start = i + 1;
}
}
const tail = str.slice(start).trim();
if (tail) parts.push(tail);
return parts;
}
// Evaluate a CSS color-mix() expression to {r,g,b,a}. Returns null when
// the expression can't be resolved (unresolved var(), unknown colors).
//
// Mixing is done with premultiplied alpha in sRGB regardless of the
// declared interpolation space. That is exact for the dominant generated-UI
// pattern — `color-mix(in oklab, <color> N%, transparent)` — where the
// result is simply <color> at alpha N% in ANY rectangular space, and a
// close-enough approximation for opaque-opaque mixes (the detector only
// consumes these values for contrast/chroma thresholds, not for display).
function parseColorMix(str) {
const m = String(str).trim().match(/^color-mix\(/i);
if (!m) return null;
// Balanced-paren capture of the arguments.
let depth = 0, end = -1;
const open = str.indexOf('(');
for (let i = open; i < str.length; i++) {
if (str[i] === '(') depth++;
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
}
if (end < 0) return null;
const args = splitTopLevelCommas(str.slice(open + 1, end));
if (args.length !== 3 || !/^in\s/i.test(args[0])) return null;
const parseComponent = (component) => {
// Percentage may lead or trail the color per spec.
let pct = null;
let colorStr = component;
const trail = component.match(/\s+([\d.]+)%$/);
const lead = component.match(/^([\d.]+)%\s+/);
if (trail) { pct = parseFloat(trail[1]); colorStr = component.slice(0, trail.index).trim(); }
else if (lead) { pct = parseFloat(lead[1]); colorStr = component.slice(lead[0].length).trim(); }
let color;
if (/^transparent$/i.test(colorStr)) color = { r: 0, g: 0, b: 0, a: 0 };
else color = parseAnyColor(colorStr);
if (!color) return null;
return { color, pct };
};
const c1 = parseComponent(args[1]);
const c2 = parseComponent(args[2]);
if (!c1 || !c2) return null;
let p1 = c1.pct, p2 = c2.pct;
if (p1 == null && p2 == null) { p1 = 50; p2 = 50; }
else if (p1 == null) p1 = 100 - p2;
else if (p2 == null) p2 = 100 - p1;
const sum = p1 + p2;
if (sum <= 0) return null;
// Per spec: weights normalize to sum; when sum < 100 the result alpha is
// additionally scaled by sum/100.
const w1 = p1 / sum, w2 = p2 / sum;
const alphaScale = sum < 100 ? sum / 100 : 1;
const a1 = c1.color.a ?? 1, a2 = c2.color.a ?? 1;
const a = (a1 * w1 + a2 * w2) * alphaScale;
if (a <= 0) return { r: 0, g: 0, b: 0, a: 0 };
const mix = (ch) => Math.round((c1.color[ch] * a1 * w1 + c2.color[ch] * a2 * w2) / (a1 * w1 + a2 * w2));
return { r: mix('r'), g: mix('g'), b: mix('b'), a: Math.min(1, a) };
}
// Composite a translucent color over an opaque(ish) base (simple
// source-over in sRGB). Returns an opaque {r,g,b,a:1}.
function compositeColorOver(top, base) {
const a = top.a ?? 1;
return {
r: Math.round(top.r * a + base.r * (1 - a)),
g: Math.round(top.g * a + base.g * (1 - a)),
b: Math.round(top.b * a + base.b * (1 - a)),
a: 1,
};
}
// Extended color parser: rgb/rgba/hex/oklch/oklab/hsl/hwb/color-mix/common
// named colors. Returns null on no match. Use this when the input might be
// any CSS color form; use plain parseRgb when you only expect computed rgb()
// values from real browsers.
function parseAnyColor(s) {
if (!s || typeof s !== 'string') return null;
const str = s.trim();
if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
if (/^color-mix\(/i.test(str)) return parseColorMix(str);
let m;
m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+))?\s*\)/);
if (m) return { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: m[4] !== undefined ? +m[4] : 1 };
m = str.match(/^#([0-9a-f]{3,8})$/i);
if (m) {
const h = m[1];
if (h.length === 3 || h.length === 4) {
return {
r: parseInt(h[0] + h[0], 16),
g: parseInt(h[1] + h[1], 16),
b: parseInt(h[2] + h[2], 16),
a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
};
}
if (h.length === 6 || h.length === 8) {
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
};
}
}
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
// Match L (with optional %), then C and H separated permissively.
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const Lnum = parseFloat(m[1]);
const L = m[2] === '%' ? Lnum / 100 : Lnum;
const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
if (m[5] !== undefined) {
const alpha = parseFloat(m[5]);
rgb.a = m[6] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// OKLAB — a/b are signed axes; percentages map 100% → 0.4.
m = str.match(/oklab\(\s*([\d.]+)(%?)\s+(-?[\d.]+)(%?)\s+(-?[\d.]+)(%?)(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const L = m[2] === '%' ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
const a = m[4] === '%' ? parseFloat(m[3]) * 0.004 : parseFloat(m[3]);
const b = m[6] === '%' ? parseFloat(m[5]) * 0.004 : parseFloat(m[5]);
const rgb = oklabToRgb(L, a, b);
if (m[7] !== undefined) {
const alpha = parseFloat(m[7]);
rgb.a = m[8] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// HSL/HSLA — comma or space syntax, optional deg on hue.
m = str.match(/hsla?\(\s*(-?[\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%\s*[,\s]\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hslToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// HWB — hue whiteness% blackness%.
m = str.match(/hwb\(\s*(-?[\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hwbToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
const named = CSS_NAMED_COLORS[str.toLowerCase()];
if (named) return { ...named, a: 1 };
return null;
}
// Resolve var() refs in a color string (via customPropMap), then parse.
// Returns null on any failure. Used in jsdom-mode paths where
@@ -2923,15 +2696,24 @@ function checkElementGlowDOM(el) {
if (!boxShadow && !textShadow) return [];
// Use parent's background — glow radiates outward, so the surrounding context matters
// If resolveBackground returns null (gradient), try to infer from the gradient colors
let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el);
if (!parentBg) {
const parentBgInfo = resolveBackgroundInfo(el.parentElement || el);
// Unknown surface (an unreadable layer on the way up): skip only the
// gradient hunt below, which would walk PAST that layer and score the
// glow against a background the visitor never sees. checkGlow still runs
// with a null surface: the zero-offset chromatic halo tell holds on ANY
// background, and the static loop already passes the unresolved walk's
// null color straight through (detect-html.mjs uses resolveBackground).
let parentBg = parentBgInfo.color;
if (!parentBg && !parentBgInfo.unresolved) {
// 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.
// fallback in browser mode, and their stops usually serialize as oklch
// which the shared parseGradientColors reads via its color-function
// token capture.
let cur = el.parentElement;
while (cur && cur.nodeType === 1) {
const bgImage = getComputedStyle(cur).backgroundImage || '';
const gradColors = parseGradientColorsModern(bgImage);
const gradColors = parseGradientColors(bgImage);
if (gradColors.length > 0) {
// Average the gradient colors
const avg = { r: 0, g: 0, b: 0 };
@@ -2975,10 +2757,13 @@ function checkElementAIPaletteDOM(el) {
const hue = getHue(textColor);
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
if (isAIPalette) {
const parentBg = el.parentElement ? resolveBackground(el.parentElement) : null;
// Also check gradient parents
let effectiveBg = parentBg;
if (!effectiveBg) {
const parentBgInfo = el.parentElement
? resolveBackgroundInfo(el.parentElement)
: { color: null, unresolved: false };
// Unknown surface: leave effectiveBg null (no finding) rather than
// hunting gradient ancestors past a layer we could not read.
let effectiveBg = parentBgInfo.color;
if (!effectiveBg && !parentBgInfo.unresolved) {
let cur = el.parentElement;
while (cur && cur.nodeType === 1) {
const gi = getComputedStyle(cur).backgroundImage || '';
@@ -3784,7 +3569,8 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
const effectiveBg = resolveBackground(el, window, customPropMap);
const bgInfo = resolveBackgroundInfo(el, window, customPropMap);
const effectiveBg = bgInfo.color;
// jsdom returns literal "var(--X)" / "oklch(...)" for color, so plain
// parseRgb misses Tailwind-tokenized text colors. Resolve through the
// customPropMap first; fall back to parseRgb for vanilla rgb() pages.
@@ -3830,11 +3616,13 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
// element itself has no usable own background, that pseudo is the real
// surface for contrast purposes.
let finalEffectiveBg = effectiveBg;
let surfaceUnresolved = bgInfo.unresolved;
if ((!ownBg || (ownBg.a ?? 1) <= 0.5) && typeof window.getPseudoSurface === 'function') {
const pseudoSurface = window.getPseudoSurface(el);
if (pseudoSurface) {
ownBg = pseudoSurface;
finalEffectiveBg = pseudoSurface;
surfaceUnresolved = false;
}
}
@@ -3842,8 +3630,9 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
tag,
textColor,
bgColor: ownBg,
effectiveBg: finalEffectiveBg,
effectiveBgStops: finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
// Unknown surface: hand the checks nothing rather than a guess.
effectiveBg: surfaceUnresolved ? null : finalEffectiveBg,
effectiveBgStops: surfaceUnresolved || finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
fontSize: parseFloat(style.fontSize) || 16,
fontWeight: parseInt(style.fontWeight) || 400,
hasDirectText,
@@ -5663,6 +5452,7 @@ export {
checkHtmlPatterns,
readOwnBackgroundColor,
resolveBackground,
resolveBackgroundInfo,
resolveGradientStops,
parseRadiusToPx,
resolveBorderRadiusPx,
+466 -2
View File
@@ -71,11 +71,43 @@ function contrastRatio(c1, c2) {
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
// The CSS color functions worth pulling out of a longer declaration. The set
// is deliberately closed: `linear-gradient(` and `url(` also look like
// `name(` and must not be read as colors.
const COLOR_FUNCTION_NAMES = new Set([
'rgb', 'rgba', 'hsl', 'hsla', 'hwb', 'oklch', 'oklab', 'lch', 'lab', 'color', 'color-mix',
]);
// Pull every color-function token out of a value, with balanced-paren capture
// so nested forms (`color-mix(in oklab, oklch(...) 20%, transparent)`) survive
// whole. Returns the raw substrings in source order.
function extractColorFunctionTokens(value) {
const str = String(value || '');
const tokens = [];
const re = /([a-z][a-z-]*)\(/gi;
let m;
while ((m = re.exec(str)) !== null) {
if (!COLOR_FUNCTION_NAMES.has(m[1].toLowerCase())) continue;
let depth = 0, end = -1;
for (let i = m.index + m[0].length - 1; i < str.length; i++) {
if (str[i] === '(') depth++;
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
}
if (end < 0) break;
tokens.push(str.slice(m.index, end + 1));
re.lastIndex = end + 1;
}
return tokens;
}
function parseGradientColors(bgImage) {
if (!bgImage || !bgImage.includes('gradient')) return [];
const colors = [];
for (const m of bgImage.matchAll(/rgba?\([^)]+\)/g)) {
const c = parseRgb(m[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 c = parseAnyColor(token);
if (c) colors.push(c);
}
for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
@@ -112,13 +144,445 @@ function colorToHex(c) {
return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
}
// ─── Color-space conversions ────────────────────────────────────────────────
//
// Every function here lands on 8-bit sRGB, clamped to gamut. Chrome, Safari,
// and Firefox all keep the authored color space in getComputedStyle output
// (`oklch(0.84 0.19 80.46)`, `lch(20 5 60)`, `color(srgb 1.04 0.72 -0.21)`),
// so a detector that only reads rgb() is blind on any modern palette. The
// expected outputs are pinned in tests/detect-antipatterns.test.js against
// what Chrome itself paints for the same strings.
function clamp01(x) {
return Number.isFinite(x) ? Math.max(0, Math.min(1, x)) : 0;
}
// Linear-light sRGB channel to the encoded 0-255 value.
function encodeSrgbChannel(x) {
const c = clamp01(x);
return Math.round((c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055) * 255);
}
function decodeSrgbChannel(x) {
const c = Number.isFinite(x) ? x : 0;
const sign = c < 0 ? -1 : 1;
const abs = Math.abs(c);
return sign * (abs <= 0.04045 ? abs / 12.92 : Math.pow((abs + 0.055) / 1.055, 2.4));
}
function linearSrgbToColor(r, g, b, a = 1) {
return { r: encodeSrgbChannel(r), g: encodeSrgbChannel(g), b: encodeSrgbChannel(b), a };
}
// OKLab to sRGB (Björn Ottosson's matrices). L in 0..1, a/b are signed axes.
function oklabToRgb(L, a, b) {
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
return linearSrgbToColor(
4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc,
-1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc,
-0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc,
);
}
// OKLCH to sRGB. L in 0..1, C in 0..~0.4 typical, H in degrees. Chroma past
// the sRGB gamut clamps per channel rather than producing NaN.
function oklchToRgb(L, C, H) {
const hRad = (H * Math.PI) / 180;
return oklabToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
}
// CIE Lab to sRGB. CSS lab()/lch() use the D50 white point; the matrix below
// is the Bradford-adapted XYZ-D50 to linear-sRGB transform from CSS Color 4.
function labToRgb(L, a, b) {
const kappa = 24389 / 27, epsilon = 216 / 24389;
const fy = (L + 16) / 116, fx = fy + a / 500, fz = fy - b / 200;
const invert = (t) => (t * t * t > epsilon ? t * t * t : (116 * t - 16) / kappa);
const yr = L > kappa * epsilon ? Math.pow((L + 16) / 116, 3) : L / kappa;
const Xn = 0.3457 / 0.3585, Zn = (1 - 0.3457 - 0.3585) / 0.3585;
const x = invert(fx) * Xn, y = yr, z = invert(fz) * Zn;
return linearSrgbToColor(
3.1341359569958707 * x - 1.6173863321612538 * y - 0.4906619460083532 * z,
-0.9787955029120890 * x + 1.9162545672595240 * y + 0.0334427311613195 * z,
0.0719553798841168 * x - 0.2289768264158322 * y + 1.4053860583241250 * z,
);
}
function lchToRgb(L, C, H) {
const hRad = (H * Math.PI) / 180;
return labToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
}
// color(<space> c1 c2 c3) for the spaces that turn up in real stylesheets.
// `srgb` is what Chrome serializes most color-mix() results into, routinely
// with channels outside 0..1. Spaces we do not model return null so callers
// abstain instead of measuring against a color we invented.
function colorFunctionToRgb(space, c1, c2, c3) {
switch (space) {
case 'srgb':
return { r: Math.round(clamp01(c1) * 255), g: Math.round(clamp01(c2) * 255), b: Math.round(clamp01(c3) * 255), a: 1 };
case 'srgb-linear':
return linearSrgbToColor(c1, c2, c3);
case 'display-p3': {
const [R, G, B] = [decodeSrgbChannel(c1), decodeSrgbChannel(c2), decodeSrgbChannel(c3)];
return linearSrgbToColor(
1.2249401762805587 * R - 0.2249404646817506 * G + 0.0000002884022551 * B,
-0.0420569547096138 * R + 1.0420571661298634 * G - 0.0000002113202247 * B,
-0.0196375587040044 * R - 0.0786360772174755 * G + 1.0982736359214800 * B,
);
}
default:
return null;
}
}
function hslToRgb(h, s, l) {
h = ((h % 360) + 360) % 360;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m0 = l - c / 2;
const [r, g, b] =
h < 60 ? [c, x, 0] :
h < 120 ? [x, c, 0] :
h < 180 ? [0, c, x] :
h < 240 ? [0, x, c] :
h < 300 ? [x, 0, c] : [c, 0, x];
return {
r: Math.round((r + m0) * 255),
g: Math.round((g + m0) * 255),
b: Math.round((b + m0) * 255),
a: 1,
};
}
function hwbToRgb(h, w, bl) {
if (w + bl >= 1) {
const g = Math.round((w / (w + bl)) * 255);
return { r: g, g, b: g, a: 1 };
}
const base = hslToRgb(h, 1, 0.5);
const mix = (c) => Math.round(((c / 255) * (1 - w - bl) + w) * 255);
return { r: mix(base.r), g: mix(base.g), b: mix(base.b), a: 1 };
}
// Common CSS named colors — the handful that actually show up in generated
// UIs, not the full 148-name spec list. Includes the achromatic names so a
// named gray parses (and correctly reads as no-chroma) instead of being
// treated as an unknown color.
const CSS_NAMED_COLORS = {
black: { r: 0, g: 0, b: 0 },
white: { r: 255, g: 255, b: 255 },
gray: { r: 128, g: 128, b: 128 },
grey: { r: 128, g: 128, b: 128 },
silver: { r: 192, g: 192, b: 192 },
dimgray: { r: 105, g: 105, b: 105 },
darkgray: { r: 169, g: 169, b: 169 },
lightgray: { r: 211, g: 211, b: 211 },
gainsboro: { r: 220, g: 220, b: 220 },
whitesmoke: { r: 245, g: 245, b: 245 },
red: { r: 255, g: 0, b: 0 },
crimson: { r: 220, g: 20, b: 60 },
tomato: { r: 255, g: 99, b: 71 },
coral: { r: 255, g: 127, b: 80 },
salmon: { r: 250, g: 128, b: 114 },
orange: { r: 255, g: 165, b: 0 },
gold: { r: 255, g: 215, b: 0 },
yellow: { r: 255, g: 255, b: 0 },
olive: { r: 128, g: 128, b: 0 },
lime: { r: 0, g: 255, b: 0 },
green: { r: 0, g: 128, b: 0 },
teal: { r: 0, g: 128, b: 128 },
turquoise: { r: 64, g: 224, b: 208 },
cyan: { r: 0, g: 255, b: 255 },
aqua: { r: 0, g: 255, b: 255 },
skyblue: { r: 135, g: 206, b: 235 },
dodgerblue: { r: 30, g: 144, b: 255 },
blue: { r: 0, g: 0, b: 255 },
navy: { r: 0, g: 0, b: 128 },
indigo: { r: 75, g: 0, b: 130 },
rebeccapurple: { r: 102, g: 51, b: 153 },
purple: { r: 128, g: 0, b: 128 },
violet: { r: 238, g: 130, b: 238 },
orchid: { r: 218, g: 112, b: 214 },
magenta: { r: 255, g: 0, b: 255 },
fuchsia: { r: 255, g: 0, b: 255 },
hotpink: { r: 255, g: 105, b: 180 },
pink: { r: 255, g: 192, b: 203 },
maroon: { r: 128, g: 0, b: 0 },
};
// Split a string on top-level commas (ignoring commas nested in parens).
function splitTopLevelCommas(str) {
const parts = [];
let depth = 0, start = 0;
for (let i = 0; i < str.length; i++) {
const ch = str[i];
if (ch === '(') depth++;
else if (ch === ')') depth = Math.max(0, depth - 1);
else if (ch === ',' && depth === 0) {
parts.push(str.slice(start, i).trim());
start = i + 1;
}
}
const tail = str.slice(start).trim();
if (tail) parts.push(tail);
return parts;
}
// Evaluate a CSS color-mix() expression to {r,g,b,a}. Returns null when
// the expression can't be resolved (unresolved var(), unknown colors).
//
// Mixing is done with premultiplied alpha in sRGB regardless of the
// declared interpolation space. That is exact for the dominant generated-UI
// pattern — `color-mix(in oklab, <color> N%, transparent)` — where the
// result is simply <color> at alpha N% in ANY rectangular space, and a
// close-enough approximation for opaque-opaque mixes (the detector only
// consumes these values for contrast/chroma thresholds, not for display).
function parseColorMix(str) {
const m = String(str).trim().match(/^color-mix\(/i);
if (!m) return null;
// Balanced-paren capture of the arguments.
let depth = 0, end = -1;
const open = str.indexOf('(');
for (let i = open; i < str.length; i++) {
if (str[i] === '(') depth++;
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
}
if (end < 0) return null;
const args = splitTopLevelCommas(str.slice(open + 1, end));
if (args.length !== 3 || !/^in\s/i.test(args[0])) return null;
const parseComponent = (component) => {
// Percentage may lead or trail the color per spec.
let pct = null;
let colorStr = component;
const trail = component.match(/\s+([\d.]+)%$/);
const lead = component.match(/^([\d.]+)%\s+/);
if (trail) { pct = parseFloat(trail[1]); colorStr = component.slice(0, trail.index).trim(); }
else if (lead) { pct = parseFloat(lead[1]); colorStr = component.slice(lead[0].length).trim(); }
let color;
if (/^transparent$/i.test(colorStr)) color = { r: 0, g: 0, b: 0, a: 0 };
else color = parseAnyColor(colorStr);
if (!color) return null;
return { color, pct };
};
const c1 = parseComponent(args[1]);
const c2 = parseComponent(args[2]);
if (!c1 || !c2) return null;
let p1 = c1.pct, p2 = c2.pct;
if (p1 == null && p2 == null) { p1 = 50; p2 = 50; }
else if (p1 == null) p1 = 100 - p2;
else if (p2 == null) p2 = 100 - p1;
const sum = p1 + p2;
if (sum <= 0) return null;
// Per spec: weights normalize to sum; when sum < 100 the result alpha is
// additionally scaled by sum/100.
const w1 = p1 / sum, w2 = p2 / sum;
const alphaScale = sum < 100 ? sum / 100 : 1;
const a1 = c1.color.a ?? 1, a2 = c2.color.a ?? 1;
const a = (a1 * w1 + a2 * w2) * alphaScale;
if (a <= 0) return { r: 0, g: 0, b: 0, a: 0 };
const mix = (ch) => Math.round((c1.color[ch] * a1 * w1 + c2.color[ch] * a2 * w2) / (a1 * w1 + a2 * w2));
return { r: mix('r'), g: mix('g'), b: mix('b'), a: Math.min(1, a) };
}
// Composite a translucent color over an opaque(ish) base (simple
// source-over in sRGB). Returns an opaque {r,g,b,a:1}.
function compositeColorOver(top, base) {
const a = top.a ?? 1;
return {
r: Math.round(top.r * a + base.r * (1 - a)),
g: Math.round(top.g * a + base.g * (1 - a)),
b: Math.round(top.b * a + base.b * (1 - a)),
a: 1,
};
}
// A color() / lab() / lch() component: a bare number, a percentage against
// `scale`, or the `none` keyword (which resolves to zero for our purposes).
function parseColorComponent(token, scale = 1) {
if (token == null) return null;
const t = String(token).trim();
if (/^none$/i.test(t)) return 0;
const num = parseFloat(t);
if (!Number.isFinite(num)) return null;
return t.endsWith('%') ? (num / 100) * scale : num;
}
function parseAlphaToken(token) {
if (token == null) return 1;
const t = String(token).trim();
if (/^none$/i.test(t)) return 1;
const num = parseFloat(t);
if (!Number.isFinite(num)) return 1;
return t.endsWith('%') ? num / 100 : num;
}
// Extended color parser: rgb/rgba/hex/oklch/oklab/lch/lab/hsl/hwb/color()/
// color-mix/common named colors. Returns null on no match. Use this when the
// input might be any CSS color form; use plain parseRgb when you only expect
// computed rgb() values from real browsers.
function parseAnyColor(s) {
if (!s || typeof s !== 'string') return null;
const str = s.trim();
if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
if (/^color-mix\(/i.test(str)) return parseColorMix(str);
let m;
m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/);
if (m) {
const c = { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: 1 };
if (m[4] !== undefined) c.a = m[5] === '%' ? parseFloat(m[4]) / 100 : +m[4];
return c;
}
m = str.match(/^#([0-9a-f]{3,8})$/i);
if (m) {
const h = m[1];
if (h.length === 3 || h.length === 4) {
return {
r: parseInt(h[0] + h[0], 16),
g: parseInt(h[1] + h[1], 16),
b: parseInt(h[2] + h[2], 16),
a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
};
}
if (h.length === 6 || h.length === 8) {
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
};
}
}
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
// Match L (with optional %), then C and H separated permissively.
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const Lnum = parseFloat(m[1]);
const L = m[2] === '%' ? Lnum / 100 : Lnum;
const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
if (m[5] !== undefined) {
const alpha = parseFloat(m[5]);
rgb.a = m[6] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// OKLAB — a/b are signed axes; percentages map 100% → 0.4.
m = str.match(/oklab\(\s*([\d.]+)(%?)\s+(-?[\d.]+)(%?)\s+(-?[\d.]+)(%?)(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const L = m[2] === '%' ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
const a = m[4] === '%' ? parseFloat(m[3]) * 0.004 : parseFloat(m[3]);
const b = m[6] === '%' ? parseFloat(m[5]) * 0.004 : parseFloat(m[5]);
const rgb = oklabToRgb(L, a, b);
if (m[7] !== undefined) {
const alpha = parseFloat(m[7]);
rgb.a = m[8] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// LCH / LAB — CIE, D50 white point. Chrome serializes lch(20% 5 60) as
// `lch(20 5 60)`, so L arrives with or without its percent sign. In both
// spaces L runs 0..100 and 100% means 100.
m = str.match(/^lch\(\s*([\d.]+%?|none)\s+([\d.]+%?|none)\s+(-?[\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
if (m) {
const L = parseColorComponent(m[1], 100);
const C = parseColorComponent(m[2], 150);
const H = parseFloat(m[3]);
if (L == null || C == null || !Number.isFinite(H)) return null;
const rgb = lchToRgb(L, C, H);
rgb.a = parseAlphaToken(m[4]);
return rgb;
}
m = str.match(/^lab\(\s*([\d.]+%?|none)\s+(-?[\d.]+%?|none)\s+(-?[\d.]+%?|none)(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
if (m) {
const L = parseColorComponent(m[1], 100);
const a = parseColorComponent(m[2], 125);
const b = parseColorComponent(m[3], 125);
if (L == null || a == null || b == null) return null;
const rgb = labToRgb(L, a, b);
rgb.a = parseAlphaToken(m[4]);
return rgb;
}
// color(<space> c1 c2 c3 [/ alpha]) — what Chrome hands back for most
// color-mix() results and for any wide-gamut color an author wrote.
m = str.match(/^color\(\s*([a-z0-9-]+)\s+(-?[\d.eE+-]+%?|none)\s+(-?[\d.eE+-]+%?|none)\s+(-?[\d.eE+-]+%?|none)(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
if (m) {
const c1 = parseColorComponent(m[2]);
const c2 = parseColorComponent(m[3]);
const c3 = parseColorComponent(m[4]);
if (c1 == null || c2 == null || c3 == null) return null;
const rgb = colorFunctionToRgb(m[1].toLowerCase(), c1, c2, c3);
if (!rgb) return null;
rgb.a = parseAlphaToken(m[5]);
return rgb;
}
// HSL/HSLA — comma or space syntax, optional deg on hue.
m = str.match(/hsla?\(\s*(-?[\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%\s*[,\s]\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hslToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// HWB — hue whiteness% blackness%.
m = str.match(/hwb\(\s*(-?[\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hwbToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
const named = CSS_NAMED_COLORS[str.toLowerCase()];
if (named) return { ...named, a: 1 };
return null;
}
// True when a computed background-color string names no paint at all. Used to
// tell "this layer is see-through" (walk on to the ancestor) apart from "this
// layer has a color we could not read" (stop and abstain).
//
// `inherit` belongs here even though it is not literally see-through: it means
// "paint with the parent's background-color", and walking on to the parent IS
// that resolution. Real browsers resolve the keyword before getComputedStyle
// output; only jsdom's partial cascade hands it through verbatim, and treating
// it as unreadable would make the walk abstain on a surface it can know.
// (`currentcolor` is NOT here — it is real paint in the element's own text
// color; resolveBackgroundInfo substitutes the computed color for it.)
function isNoPaintColorValue(value) {
const v = String(value || '').trim().toLowerCase();
if (!v) return true;
return v === 'transparent' || v === 'none' || v === 'initial' || v === 'inherit' || v === 'unset' || v === 'revert' || v === 'revert-layer';
}
export {
isNeutralColor,
parseRgb,
relativeLuminance,
contrastRatio,
parseGradientColors,
extractColorFunctionTokens,
hasChroma,
getHue,
colorToHex,
oklabToRgb,
oklchToRgb,
labToRgb,
lchToRgb,
colorFunctionToRgb,
hslToRgb,
hwbToRgb,
CSS_NAMED_COLORS,
splitTopLevelCommas,
parseColorMix,
parseAnyColor,
compositeColorOver,
isNoPaintColorValue,
};
@@ -113,6 +113,35 @@ describe('detectUrl — browser-only fixtures', () => {
}
});
it('dark-glow: unreadable url() surface keeps zero-offset halos, abstains on offset chromatic shadows', async () => {
// Mirrors the static assertions in detect-antipatterns-fixtures.test.mjs.
// The browser adapter (checkElementGlowDOM) once returned [] for the
// whole element when the parent surface was unresolved, which also
// dropped zero-offset chromatic halos that need no background at all.
const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/glow.html`, { visualContrast: false });
const glow = f.filter(r => r.antipattern === 'dark-glow');
assert.ok(
glow.some(g => /Zero-offset box-shadow glow \(#d946ef\)/i.test(g.snippet || '')),
'expected zero-offset halo finding under unreadable image surface',
);
assert.equal(
glow.filter(g => /#10b981/i.test(g.snippet || '')).length, 0,
'offset chromatic shadow on unknown surface must not be scored',
);
// Gradient-over-image split: an opaque gradient provably covers the
// image, so the dark-background tell may score against its stops; a
// translucent wash blends with unknowable pixels (a white photo under a
// 20% black wash paints ~#cccccc, not black), so the walk abstains.
assert.ok(
glow.some(g => /Colored box-shadow glow \(#f97316\) on dark background/i.test(g.snippet || '')),
'expected colored-glow finding under a provably opaque gradient over an image',
);
assert.equal(
glow.filter(g => /#f43f5e/i.test(g.snippet || '')).length, 0,
'offset chromatic shadow under a translucent wash over an image must abstain',
);
});
it('image-backed text: the overlay default pass pixel-samples the image itself', async () => {
// Drives the OVERLAY entry (impeccableDetectAsync with default options),
// not detectUrl's Node-side full fallback — the image-only default mode
@@ -1028,4 +1057,60 @@ describe('detectUrl — browser-only fixtures', () => {
await detector.close();
}
});
// Only a real browser reproduces this one: Chrome keeps oklch(), lch(), and
// color(srgb ...) verbatim in getComputedStyle output, so a detector that
// cannot parse those reads every surface as unset, walks out of the page,
// and assumes the white canvas. On a dark theme that turns every light line
// into a false "on #ffffff" finding (two live scans of impeccable.style
// produced 95 and ~120 of them).
describe('dark themes written in modern color syntax', () => {
const FLAG_PAIRS = [
// Worst stop of the two-stop oklch ground.
['#35332d', '#050403'],
['#47474d', '#1a1c1f'],
['#59595c', '#121215'],
['#56514e', '#302b27'],
['#bfbdb8', '#faf7f2'],
// Chrome resolves inherit / currentcolor before getComputedStyle
// output, so these two must flag natively as well.
['#c7c4bf', '#faf7f2'],
['#bfbdb8', '#f0ede8'],
];
it('reads oklch / color() / lch grounds and never assumes white', async () => {
const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/dark-theme-modern-color.html`, {
visualContrast: false,
});
const lowContrast = f.filter(r => r.antipattern === 'low-contrast');
const snippets = lowContrast.map(r => r.snippet || '');
const onWhite = snippets.filter(s => /on #ffffff/i.test(s));
assert.equal(
onWhite.length, 0,
`no finding may claim a white ground on this page, got: ${onWhite.join('; ')}`,
);
const pale = snippets.filter(s => /#e7e4dd/i.test(s));
assert.equal(
pale.length, 0,
`ivory copy on dark grounds must not flag, got: ${pale.join('; ')}`,
);
for (const [text, bg] of FLAG_PAIRS) {
assert.ok(
snippets.some(s => s.includes(`text ${text}`) && s.includes(`on ${bg}`)),
`expected low-contrast for text ${text} on ${bg}, got: ${snippets.join('; ')}`,
);
}
// `url(...), linear-gradient(red, blue)` paints the image on top; no
// finding may measure against the occluded gradient's stops.
const hidden = f.filter(r => /#ff0000|#0000ff/i.test(r.snippet || ''));
assert.equal(
hidden.length, 0,
`no finding may reference the occluded gradient's stops, got: ${hidden.map(r => r.snippet).join('; ')}`,
);
});
});
});
+132 -3
View File
@@ -224,6 +224,54 @@ describe('detectHtml — static HTML/CSS fixtures', () => {
);
});
it('color: currentcolor surface resolves var() text color instead of abstaining', async () => {
// background-color: currentcolor paints with the element's own text
// color, which in jsdom can itself be a var() token. The surface is
// knowable through the custom-prop map, so the faint text on it is a
// real low-contrast finding — abstention here would hide it.
const f = await detectHtml(path.join(FIXTURES, 'color.html'));
assert.ok(
f.some(r =>
r.antipattern === 'low-contrast' &&
/#cfc9bd/i.test(r.snippet || '') &&
/#e8e2d6/i.test(r.snippet || '')
),
'expected low-contrast finding on the currentcolor var() surface',
);
// Good contrast on the same surface must not flag.
const goodFP = f.filter(r =>
(r.antipattern === 'low-contrast' || r.antipattern === 'gray-on-color') &&
/#3a352c/i.test(r.snippet || '')
);
assert.equal(goodFP.length, 0, `dark ink on bone must pass, got: ${goodFP.map(r => r.snippet).join('; ')}`);
// An undefined token keeps the surface unknowable: abstain, don't guess.
const unknownFP = f.filter(r =>
(r.antipattern === 'low-contrast' || r.antipattern === 'gray-on-color') &&
/#efe9dd/i.test(r.snippet || '')
);
assert.equal(
unknownFP.length, 0,
`unresolvable currentcolor surface must abstain, got: ${unknownFP.map(r => r.snippet).join('; ')}`,
);
});
it('color: a color-mix gradient stop never leaks its nested ingredient as a phantom surface', async () => {
// The stop paints as a 16% wash composited near-black over the dark
// wrap; the bright oklch(90% ...) nested inside the color-mix is an
// ingredient, never painted. Re-extracting nested tokens appended it as
// a phantom opaque stop, and the worst-case ratio then flagged the
// light text at ~1:1 against a color nobody sees.
const f = await detectHtml(path.join(FIXTURES, 'color.html'));
const phantom = f.filter(r =>
(r.antipattern === 'low-contrast' || r.antipattern === 'gray-on-color') &&
/#ded9cf/i.test(r.snippet || '')
);
assert.equal(
phantom.length, 0,
`light text on the mixed wash must not flag: ${phantom.map(r => r.snippet).join('; ')}`,
);
});
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().
@@ -1022,17 +1070,36 @@ describe('detectHtml — motion', () => {
describe('detectHtml — dark glow', () => {
// Calibrated static baseline — see motion test note above.
// 11 element-level findings (glow-blue, glow-purple, glow-cyan, glow-multi,
// 12 element-level findings (glow-blue, glow-purple, glow-cyan, glow-multi,
// inline pink, glow-oklch, glow-hex, glow-hsl, glow-var, glow-text,
// glow-light-oklch) + 1 page-level text-scan finding. Pass column adds none.
// glow-light-oklch, glow-photo-halo) + 1 page-level text-scan finding.
// Pass column adds none.
it('glow: flag column triggers dark-glow, pass column adds none', async () => {
const f = await detectHtml(path.join(FIXTURES, 'glow.html'));
const glow = f.filter(r => r.antipattern === 'dark-glow');
assert.equal(glow.length, 12);
assert.equal(glow.length, 13);
// Every finding is a glow tell, none reference the pass-column shadows
for (const g of glow) {
assert.match(g.snippet, /Zero-offset (box|text)-shadow glow|Colored (box|text)-shadow glow/);
}
// Zero-offset halo under an unreadable url() surface still fires: the
// halo tell does not depend on the background at all.
assert.ok(
glow.some(g => /Zero-offset box-shadow glow \(#d946ef\)/i.test(g.snippet)),
'expected zero-offset halo finding under unreadable image surface',
);
// Offset chromatic shadow under the same unreadable surface abstains:
// the dark-background tell needs a surface we can actually read.
assert.equal(
glow.filter(g => /#10b981/i.test(g.snippet)).length, 0,
'offset chromatic shadow on unknown surface must not be scored',
);
// Translucent gradient over a url() image blends with pixels the engine
// cannot read; the wash stops must never be scored as the surface.
assert.equal(
glow.filter(g => /#f43f5e/i.test(g.snippet)).length, 0,
'offset chromatic shadow under a translucent wash over an image must abstain',
);
});
});
@@ -1401,3 +1468,65 @@ describe('detectHtml — CSS patterns in prose (css-in-prose fixtures)', () => {
assert.ok(f.some(r => r.antipattern === 'ai-color-palette'), 'expected ai-color-palette');
});
});
describe('detectHtml — dark themes written in modern color syntax', () => {
// A dark page whose ground and surfaces are written in oklch, color(srgb),
// color(display-p3), and lch. Backgrounds the parser cannot read must make
// the contrast checks abstain; assuming the browser default of white turns
// every light-on-dark line into a false "on #ffffff" finding.
const FLAG_PAIRS = [
// The ground is a two-stop oklch gradient; the check reports the worst
// stop, which for charcoal copy is the lighter one.
['#35332d', '#050403'], // Flag Muted On Oklch Ground
['#47474d', '#1a1c1f'], // Flag Dim On Srgb Panel
['#59595c', '#121215'], // Flag Dim On Display P3 Panel
['#56514e', '#302b27'], // Flag Dim On Lch Panel
['#bfbdb8', '#faf7f2'], // Flag Pale On Light Panel
['#c7c4bf', '#faf7f2'], // Flag Pale On Inherited Light Panel
['#bfbdb8', '#f0ede8'], // Flag Pale On Currentcolor Panel
];
it('flags text that genuinely fails against a ground the parser can read', async () => {
const f = await detectHtml(path.join(FIXTURES, 'dark-theme-modern-color.html'));
const snippets = f.filter(r => r.antipattern === 'low-contrast').map(r => r.snippet || '');
for (const [text, bg] of FLAG_PAIRS) {
assert.ok(
snippets.some(s => s.includes(`text ${text}`) && s.includes(`on ${bg}`)),
`expected low-contrast for text ${text} on ${bg}, got: ${snippets.join('; ')}`,
);
}
});
it('never assumes white when the ground is unreadable', async () => {
const f = await detectHtml(path.join(FIXTURES, 'dark-theme-modern-color.html'));
const onWhite = f.filter(r => /on #ffffff/i.test(r.snippet || ''));
assert.equal(
onWhite.length, 0,
`no finding may claim a white ground on this page, got: ${onWhite.map(r => r.snippet).join('; ')}`,
);
});
it('light copy on readable dark surfaces stays quiet', async () => {
const f = await detectHtml(path.join(FIXTURES, 'dark-theme-modern-color.html'));
const pale = f.filter(r =>
(r.antipattern === 'low-contrast' || r.antipattern === 'gray-on-color') &&
/#e7e4dd/i.test(r.snippet || '')
);
assert.equal(
pale.length, 0,
`ivory copy on dark grounds must not flag, got: ${pale.map(r => r.snippet).join('; ')}`,
);
});
it('never measures a gradient hidden beneath an image layer', async () => {
// `url(...), linear-gradient(red, blue)` paints the image on top; the
// gradient is invisible. Falling back to its stops manufactured
// gray-on-color / low-contrast findings against colors nobody sees.
const f = await detectHtml(path.join(FIXTURES, 'dark-theme-modern-color.html'));
const hidden = f.filter(r => /#ff0000|#0000ff/i.test(r.snippet || ''));
assert.equal(
hidden.length, 0,
`no finding may reference the occluded gradient's stops, got: ${hidden.map(r => r.snippet).join('; ')}`,
);
});
});
+58
View File
@@ -30,6 +30,7 @@ import {
scanCssTextForRadialHalo,
scanHtmlForShapeAssembledIllustration,
} from '../cli/engine/rules/checks.mjs';
import { parseGradientColors } from '../cli/engine/shared/color.mjs';
const FIXTURES = path.join(import.meta.dir, 'fixtures', 'antipatterns');
const SCRIPT = path.join(import.meta.dir, '..', 'cli', 'engine', 'detect-antipatterns.mjs');
@@ -1477,6 +1478,63 @@ describe('hover contrast + color-mix', () => {
expect(c.a).toBeCloseTo(0.16, 2);
});
// Every expected value below is what Chrome itself paints for that string
// (read back from a 1x1 canvas), so the parser is pinned to the browser it
// has to agree with rather than to my arithmetic.
describe('parseAnyColor — the color syntaxes a browser reports verbatim', () => {
const cases = [
['oklch(0.84 0.19 80.46)', [255, 186, 0]],
['oklch(84% 0.19 80.46)', [255, 186, 0]],
['oklch(1 0 0)', [255, 255, 255]],
['oklch(0 0 0)', [0, 0, 0]],
// Chroma far outside the sRGB gamut must clamp, never produce NaN.
['oklch(0.62 0.4 30)', [255, 0, 0]],
['color(srgb 0.1 0.11 0.12)', [26, 28, 31]],
// Chrome's serialization of a color-mix in srgb routinely lands outside
// 0..1 on one or more channels.
['color(srgb 1.04084 0.728032 -0.213551)', [255, 186, 0]],
['color(srgb-linear 0.5 0.5 0.5)', [188, 188, 188]],
['color(display-p3 0.9 0.8 0.2)', [235, 203, 0]],
['color(display-p3 1 0 0)', [255, 0, 0]],
['lch(20 5 60)', [54, 47, 42]],
['lab(50 40 -30)', [165, 91, 171]],
['lab(100 0 0)', [255, 255, 255]],
['lab(0 0 0)', [0, 0, 0]],
];
for (const [input, [r, g, b]] of cases) {
test(`${input} -> rgb(${r}, ${g}, ${b})`, () => {
const c = parseAnyColor(input);
expect(c).not.toBeNull();
expect(Math.abs(c.r - r)).toBeLessThanOrEqual(1);
expect(Math.abs(c.g - g)).toBeLessThanOrEqual(1);
expect(Math.abs(c.b - b)).toBeLessThanOrEqual(1);
expect(c.a).toBe(1);
});
}
test('carries the alpha channel through color() and lch()', () => {
expect(parseAnyColor('color(srgb 0.1 0.11 0.12 / 0.4)').a).toBeCloseTo(0.4, 3);
expect(parseAnyColor('lch(20 5 60 / 25%)').a).toBeCloseTo(0.25, 3);
});
test('returns null for color spaces it does not model, so callers abstain', () => {
expect(parseAnyColor('color(rec2020 0.5 0.2 0.1)')).toBeNull();
expect(parseAnyColor('color(--custom-profile 0.2 0.3 0.4)')).toBeNull();
});
});
test('parseGradientColors reads stops written in modern color syntax', () => {
const stops = parseGradientColors('linear-gradient(oklch(0.07 0.006 95), oklch(0.11 0.008 95))');
expect(stops).toHaveLength(2);
expect(stops[0].r).toBeLessThan(10);
expect(stops[1].r).toBeLessThan(20);
});
test('parseGradientColors ignores the interpolation-space hint', () => {
const stops = parseGradientColors('linear-gradient(in oklab, rgb(0, 0, 0), rgb(255, 255, 255))');
expect(stops).toHaveLength(2);
});
test('checkHoverContrast flags a failing hover pair on a styled control', () => {
const f = checkHoverContrast({
tag: 'a',
+49
View File
@@ -36,6 +36,27 @@
.ox-glow { background: linear-gradient(160deg, rgba(52,192,168,0.09) 0%, #141419 65%); padding: 20px; }
.ox-glow p { color: #e8e6e3; font-size: 18px; }
.ox-glow .muted { color: #8e8c89; font-size: 16px; }
/* Gradient stop written as color-mix with a bright nested ingredient.
The stop paints as a 16% wash of the light oklch color (composited
over the dark wrap it is near-black); the nested oklch(90% ...) is an
INGREDIENT, never painted. A parser that re-extracts nested tokens
appends it as a phantom opaque light stop and the worst-case ratio
then flags the light text at ~1:1 against a color nobody sees. */
.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; }
/* 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; }
.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; }
/* Same shape but the token does not exist: the surface truly cannot be
read, so the walk must abstain rather than guess. */
.currentcolor-unknown { background-color: currentcolor; color: var(--fixture-undefined-token); padding: 14px 16px; border-radius: 10px; }
.currentcolor-unknown p { color: #efe9dd; font-size: 14px; }
</style>
</head>
<body>
@@ -111,6 +132,14 @@
<div class="bg-gradient-to-r from-purple-500 to-indigo-500 text-white p-4 rounded card" style="background: linear-gradient(to right, rgb(168, 85, 247), rgb(99, 102, 241)); color: white;">
<p>Purple-to-indigo gradient</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
a real low-contrast finding, not an abstention. -->
<div class="currentcolor-surface" data-test="currentcolor-low">
<p class="currentcolor-low-text">Faint warm gray on a bone currentcolor surface</p>
</div>
</div>
<!-- ════════════════════════════════════════════════════════════
@@ -212,6 +241,26 @@
</div>
</div>
<h3>color-mix gradient stop: nested ingredient is not a surface</h3>
<div class="mix-dark-wrap">
<div class="mix-glow" data-test="mix-glow">
<p>Light copy on a faint mixed wash over a dark ground stays readable</p>
</div>
</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>
</div>
<h3>currentcolor surface with unresolvable token (must abstain)</h3>
<!-- The var() token is undefined, so the surface genuinely cannot be
read. The walk must abstain instead of guessing a background for
the light text inside. -->
<div class="currentcolor-unknown" data-test="currentcolor-unknown">
<p>Light text on an unknowable currentcolor surface</p>
</div>
<h3>Emoji on light backgrounds</h3>
<!-- Emojis render as multicolor glyphs regardless of CSS color, so the
CSS color is irrelevant for contrast. These should NOT be flagged. -->
+131
View File
@@ -0,0 +1,131 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Dark theme, modern color syntax</title>
<style>
/* A lacquer-black ground painted as a gradient in oklch, the shape that
floods dark themes with "on #ffffff" low-contrast false positives when
the detector cannot read the ground and assumes white. */
body {
margin: 0;
padding: 24px;
background: linear-gradient(oklch(0.07 0.006 95), oklch(0.11 0.008 95));
font-family: Georgia, serif;
font-size: 15px;
line-height: 1.6;
}
section {
padding: 24px;
margin-bottom: 24px;
width: 640px;
}
p { margin: 0 0 12px; font-size: 15px; }
/* Readable grounds, each written in a color syntax a real browser keeps
verbatim in getComputedStyle output. */
.panel-srgb { background-color: color(srgb 0.1 0.11 0.12); }
.panel-mix { background-color: color-mix(in srgb, white 6%, #0b0b0d); }
.panel-p3 { background-color: color(display-p3 0.07 0.07 0.08); }
.panel-lch { background-color: lch(18 4 60); }
.panel-light { background-color: color(srgb 0.98 0.97 0.95); }
/* A color space the detector does not model. Nested inside the light
panel so a detector that walks PAST the unreadable layer would measure
light text against the light section and invent a finding. */
.panel-unreadable { background-color: color(rec2020 0.05 0.05 0.05); padding: 20px; }
/* An image layer stacked ABOVE a loud gradient. The image is the visible
surface and its pixels are unreadable; a detector that falls back to
gradient stops here measures a layer the visitor never sees. */
.panel-image-over-gradient { background-image: url("opaque-panel.png"), linear-gradient(#ff0000, #0000ff); }
/* Keywords a real browser resolves before getComputedStyle output but a
partial cascade hands through verbatim: inherit takes the parent's
ground, currentcolor paints with the element's own text color. Both are
knowable surfaces; abstaining on them would hide real findings. */
.panel-inherit { background-color: inherit; padding: 20px; }
.panel-current { background-color: currentcolor; color: color(srgb 0.94 0.93 0.91); }
.text-pale-2 { color: color(srgb 0.78 0.77 0.75); }
.text-light { color: oklch(0.92 0.01 90); }
.text-dark { color: color(srgb 0.1 0.11 0.12); }
.text-muted-ground { color: oklch(0.32 0.01 90); }
.text-dim-srgb { color: color(srgb 0.28 0.28 0.3); }
.text-dim-p3 { color: color(display-p3 0.35 0.35 0.36); }
.text-dim-lch { color: lch(35 3 60); }
.text-pale-light { color: color(srgb 0.75 0.74 0.72); }
</style>
</head>
<body>
<h1 class="text-light">Should flag</h1>
<section>
<p class="text-muted-ground">Flag Muted On Oklch Ground: charcoal body copy sitting straight on the lacquer gradient.</p>
</section>
<section class="panel-srgb">
<p class="text-dim-srgb">Flag Dim On Srgb Panel: slate copy on a near-black srgb surface.</p>
</section>
<section class="panel-p3">
<p class="text-dim-p3">Flag Dim On Display P3 Panel: slate copy on a near-black display-p3 surface.</p>
</section>
<section class="panel-lch">
<p class="text-dim-lch">Flag Dim On Lch Panel: warm gray copy on a warm near-black lch surface.</p>
</section>
<section class="panel-light">
<p class="text-pale-light">Flag Pale On Light Panel: bone copy on a bone-white srgb surface.</p>
</section>
<section class="panel-light">
<div class="panel-inherit">
<p class="text-pale-2">Flag Pale On Inherited Light Panel: the ground is inherited from the light section, so the walk must resolve it rather than abstain.</p>
</div>
</section>
<section class="panel-current">
<p class="text-pale-light">Flag Pale On Currentcolor Panel: the ground is the panel's own bone text color, so the keyword must resolve rather than abstain.</p>
</section>
<h1 class="text-light">Should pass</h1>
<section>
<p class="text-light">Pass Light Text On Oklch Gradient Ground: warm ivory copy on the lacquer gradient, roughly 15:1.</p>
</section>
<section class="panel-srgb">
<p class="text-light">Pass Light Text On Srgb Panel: warm ivory copy on the near-black srgb surface.</p>
</section>
<section class="panel-mix">
<p class="text-light">Pass Light Text On Color Mix Panel: warm ivory copy on a surface a browser reports as color(srgb ...).</p>
</section>
<section class="panel-p3">
<p class="text-light">Pass Light Text On Display P3 Panel: warm ivory copy on the near-black display-p3 surface.</p>
</section>
<section class="panel-lch">
<p class="text-light">Pass Light Text On Lch Panel: warm ivory copy on the warm near-black lch surface.</p>
</section>
<section class="panel-light">
<p class="text-dark">Pass Dark Text On Light Panel: ink copy on the bone-white srgb surface.</p>
<div class="panel-unreadable">
<p class="text-light">Pass Unreadable Panel Inside Light Section: the ground is a color space the detector cannot read, so it must abstain instead of measuring against the light section or against white.</p>
</div>
</section>
<section class="panel-image-over-gradient">
<p class="text-muted-ground">Pass Image Over Gradient: the visible ground is an image whose pixels the engine cannot read, so it must abstain instead of measuring the loud gradient hidden beneath it.</p>
</section>
</body>
</html>
+53
View File
@@ -42,6 +42,18 @@
.glow-text h4 { text-shadow: 0 0 12px #22d3ee; }
/* Zero-offset chromatic halo is slop on light backgrounds too */
.glow-light-oklch { box-shadow: 0 0 20px oklch(0.65 0.2 300 / 0.45); }
/* Unreadable surface: url() image ancestor. The zero-offset halo tell
holds on ANY background, so an unknown surface must not suppress it.
Both element loops must agree here (the browser adapter once returned
[] for the whole element on an unresolved surface). */
.photo-context { background-image: url('/fixtures/antipatterns/missing-photo.png'); padding: 16px; border-radius: 12px; }
.glow-photo-halo { box-shadow: 0 0 26px rgba(217, 70, 239, 0.5); }
/* Opaque gradient atop a url() layer: every stop is opaque, so the
gradient provably covers the image — the surface IS the dark
gradient, and the dark-background glow tell may score against it
(browser hunt; the static loop has no gradient hunt). */
.photo-opaque-grad { background: linear-gradient(#111827, #0b1220), url('/fixtures/antipatterns/missing-photo.png'); padding: 16px; border-radius: 12px; }
.glow-photo-opaque { box-shadow: 0 6px 22px rgba(249, 115, 22, 0.5); }
/* ── PASS: same dark/light backgrounds, but neutral or no glow ── */
.light-colored-shadow { box-shadow: 0 2px 4px rgba(59, 130, 246, 0.15); }
@@ -67,6 +79,15 @@
.light-offset-colored { box-shadow: 0 8px 20px rgba(59, 130, 246, 0.25); }
/* Achromatic zero-offset shadow: soft ambient elevation, stays legal */
.light-soft-neutral { box-shadow: 0 0 24px rgba(0, 0, 0, 0.15); }
/* Offset chromatic shadow under the same unreadable url() surface:
the dark-background glow tell needs a surface we can read — abstain. */
.photo-offset-colored { box-shadow: 0 6px 20px rgba(16, 185, 129, 0.35); }
/* Translucent gradient atop a url() layer: the image shows through the
20% wash, so the real surface is a blend with unknowable pixels (a
white photo composites to ~#cccccc, not black). Averaging the wash
stops as if they were the surface scored this "dark" — abstain. */
.photo-translucent-grad { background: linear-gradient(rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.2)), url('/fixtures/antipatterns/missing-photo.png'); padding: 16px; border-radius: 12px; }
.photo-translucent-offset { box-shadow: 0 6px 22px rgba(244, 63, 94, 0.45); }
/* Offset neutral text-shadow on dark card: legibility aid, not a glow */
.dark-text-offset h4 { text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6); }
</style>
@@ -136,6 +157,22 @@
<h4>Chromatic halo on light page</h4>
<p>box-shadow: 0 0 20px oklch(0.65 0.2 300 / 0.45)</p>
</div>
<h3>Zero-offset halo on unreadable (image) surface</h3>
<div class="photo-context">
<div class="card card-light glow-photo-halo">
<h4>Halo over photo background</h4>
<p>box-shadow: 0 0 26px rgba(217, 70, 239, 0.5)</p>
</div>
</div>
<h3>Colored glow under an opaque gradient over an image (browser)</h3>
<div class="photo-opaque-grad">
<div class="card card-dark glow-photo-opaque">
<h4>Opaque dark gradient covers the photo</h4>
<p>box-shadow: 0 6px 22px rgba(249, 115, 22, 0.5)</p>
</div>
</div>
</div>
<!-- ════════════════════════════════════════════════════════════
@@ -202,6 +239,22 @@
<p>Achromatic zero-offset shadow is ambient elevation.</p>
</div>
<h3>Offset chromatic shadow on unreadable (image) surface</h3>
<div class="photo-context">
<div class="card card-light photo-offset-colored">
<h4>Offset colored shadow over photo background</h4>
<p>Surface unknown, offset shadow — abstain, not flag.</p>
</div>
</div>
<h3>Offset chromatic shadow under a translucent gradient over an image</h3>
<div class="photo-translucent-grad">
<div class="card card-light photo-translucent-offset">
<h4>The wash blends with unknowable image pixels</h4>
<p>Surface unknown — abstain, never score the wash stops as dark.</p>
</div>
</div>
<h3>Neutral text-shadow on dark card</h3>
<div class="dark-context">
<div class="card card-dark dark-text-offset">