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
This commit is contained in:
Paul Bakaus
2026-08-07 11:18:14 -07:00
parent aee6ce9352
commit 331af89eae
7 changed files with 1262 additions and 612 deletions
+493 -306
View File
@@ -741,11 +741,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)) {
@@ -782,6 +814,416 @@ 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).
function isNoPaintColorValue(value) {
const v = String(value || '').trim().toLowerCase();
if (!v) return true;
return v === 'transparent' || v === 'none' || v === 'initial' || v === 'unset' || v === 'revert' || v === 'revert-layer';
}
// --- cli/engine/shared/fonts.mjs ---
const GOOGLE_FONTS_URL_RE = /fonts\.googleapis\.com\/css2?\?[^"'\s)<>]*/gi;
@@ -2461,7 +2903,19 @@ function readOwnBackgroundColor(el, computedStyle) {
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
@@ -2509,30 +2963,30 @@ function resolveBackground(el, win, 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. 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).
if (hasGradientOrUrl) {
if (current.tagName === 'BODY' || current.tagName === 'HTML') {
return flatten({ r: 255, g: 255, b: 255, a: 1 });
}
return null;
}
// No solid bg-color at this level. A gradient or image at THIS level 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).
if (hasGradientOrUrl) return { color: null, unresolved: false };
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 };
}
function resolveBackground(el, win, customPropMap) {
return resolveBackgroundInfo(el, win, customPropMap).color;
}
// Walk parents looking for a gradient background and return its color stops.
@@ -2779,13 +3233,19 @@ function checkElementColorsDOM(el) {
const style = getComputedStyle(el);
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({
@@ -2797,8 +3257,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,
@@ -2948,283 +3408,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
@@ -4438,7 +4621,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.
@@ -4484,11 +4668,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;
}
}
@@ -4496,8 +4682,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,
+57 -304
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';
@@ -1670,7 +1677,19 @@ function readOwnBackgroundColor(el, computedStyle) {
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
@@ -1718,30 +1737,30 @@ function resolveBackground(el, win, 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. 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).
if (hasGradientOrUrl) {
if (current.tagName === 'BODY' || current.tagName === 'HTML') {
return flatten({ r: 255, g: 255, b: 255, a: 1 });
}
return null;
}
// No solid bg-color at this level. A gradient or image at THIS level 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).
if (hasGradientOrUrl) return { color: null, unresolved: false };
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 };
}
function resolveBackground(el, win, customPropMap) {
return resolveBackgroundInfo(el, win, customPropMap).color;
}
// Walk parents looking for a gradient background and return its color stops.
@@ -1988,13 +2007,19 @@ function checkElementColorsDOM(el) {
const style = getComputedStyle(el);
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({
@@ -2006,8 +2031,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,
@@ -2157,283 +2182,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
@@ -3647,7 +3395,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.
@@ -3693,11 +3442,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;
}
}
@@ -3705,8 +3456,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,
@@ -5497,6 +5249,7 @@ export {
checkHtmlPatterns,
readOwnBackgroundColor,
resolveBackground,
resolveBackgroundInfo,
resolveGradientStops,
parseRadiusToPx,
resolveBorderRadiusPx,
+458 -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,437 @@ 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).
function isNoPaintColorValue(value) {
const v = String(value || '').trim().toLowerCase();
if (!v) return true;
return v === 'transparent' || v === 'none' || v === 'initial' || 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,
};
@@ -948,4 +948,48 @@ 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'],
];
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('; ')}`,
);
}
});
});
});
@@ -1366,3 +1366,51 @@ 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
];
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('; ')}`,
);
});
});
+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',
+104
View File
@@ -0,0 +1,104 @@
<!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; }
.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>
<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>
</body>
</html>