From ac0416b655f251c96a93a7aa7886ab04721dd4fd Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Tue, 11 Aug 2026 14:59:07 -0400 Subject: [PATCH] Stop assuming white when a background cannot be read (#541) * 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 * 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 * 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 * 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 --------- Co-authored-by: Claude --- cli/engine/detect-antipatterns-browser.js | 916 +++++++++++------- cli/engine/rules/checks.mjs | 470 +++------ cli/engine/shared/color.mjs | 468 ++++++++- tests/detect-antipatterns-browser.test.mjs | 85 ++ tests/detect-antipatterns-fixtures.test.mjs | 135 ++- tests/detect-antipatterns.test.js | 58 ++ tests/fixtures/antipatterns/color.html | 49 + .../antipatterns/dark-theme-modern-color.html | 131 +++ tests/fixtures/antipatterns/glow.html | 53 + 9 files changed, 1678 insertions(+), 687 deletions(-) create mode 100644 tests/fixtures/antipatterns/dark-theme-modern-color.html diff --git a/cli/engine/detect-antipatterns-browser.js b/cli/engine/detect-antipatterns-browser.js index ce5e66f0e..8d5bce3f6 100644 --- a/cli/engine/detect-antipatterns-browser.js +++ b/cli/engine/detect-antipatterns-browser.js @@ -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,424 @@ 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( 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, N%, transparent)` — where the +// result is simply 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( 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'; +} + // --- cli/engine/shared/fonts.mjs --- const GOOGLE_FONTS_URL_RE = /fonts\.googleapis\.com\/css2?\?[^"'\s)<>]*/gi; @@ -2522,7 +2972,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); @@ -2546,7 +2996,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 @@ -2574,59 +3036,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. @@ -2652,7 +3136,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) { @@ -2660,7 +3147,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; } } @@ -2906,13 +3393,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({ @@ -2924,8 +3417,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, @@ -3075,283 +3568,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, N%, transparent)` — where the -// result is simply 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 @@ -3714,15 +3930,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 }; @@ -3766,10 +3991,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 || ''; @@ -4575,7 +4803,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. @@ -4621,11 +4850,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; } } @@ -4633,8 +4864,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, diff --git a/cli/engine/rules/checks.mjs b/cli/engine/rules/checks.mjs index 045065c66..8a5654625 100644 --- a/cli/engine/rules/checks.mjs +++ b/cli/engine/rules/checks.mjs @@ -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, N%, transparent)` — where the -// result is simply 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, diff --git a/cli/engine/shared/color.mjs b/cli/engine/shared/color.mjs index 3d9a126bf..d2524ce52 100644 --- a/cli/engine/shared/color.mjs +++ b/cli/engine/shared/color.mjs @@ -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( 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, N%, transparent)` — where the +// result is simply 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( 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, }; diff --git a/tests/detect-antipatterns-browser.test.mjs b/tests/detect-antipatterns-browser.test.mjs index 06c5c84ee..b8ab7667d 100644 --- a/tests/detect-antipatterns-browser.test.mjs +++ b/tests/detect-antipatterns-browser.test.mjs @@ -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('; ')}`, + ); + }); + }); }); diff --git a/tests/detect-antipatterns-fixtures.test.mjs b/tests/detect-antipatterns-fixtures.test.mjs index 9ead61ee3..fb9ee76f9 100644 --- a/tests/detect-antipatterns-fixtures.test.mjs +++ b/tests/detect-antipatterns-fixtures.test.mjs @@ -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('; ')}`, + ); + }); +}); diff --git a/tests/detect-antipatterns.test.js b/tests/detect-antipatterns.test.js index f5afc97b6..07dc1137a 100644 --- a/tests/detect-antipatterns.test.js +++ b/tests/detect-antipatterns.test.js @@ -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', diff --git a/tests/fixtures/antipatterns/color.html b/tests/fixtures/antipatterns/color.html index 1debc1216..58ad354f7 100644 --- a/tests/fixtures/antipatterns/color.html +++ b/tests/fixtures/antipatterns/color.html @@ -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; } @@ -111,6 +132,14 @@

Purple-to-indigo gradient

+ +

currentcolor surface via var() token

+ +
+

Faint warm gray on a bone currentcolor surface

+
+
+

Light text on an unknowable currentcolor surface

+
+

Emoji on light backgrounds

diff --git a/tests/fixtures/antipatterns/dark-theme-modern-color.html b/tests/fixtures/antipatterns/dark-theme-modern-color.html new file mode 100644 index 000000000..55b7f3536 --- /dev/null +++ b/tests/fixtures/antipatterns/dark-theme-modern-color.html @@ -0,0 +1,131 @@ + + + + +Dark theme, modern color syntax + + + + +

Should flag

+ +
+

Flag Muted On Oklch Ground: charcoal body copy sitting straight on the lacquer gradient.

+
+ +
+

Flag Dim On Srgb Panel: slate copy on a near-black srgb surface.

+
+ +
+

Flag Dim On Display P3 Panel: slate copy on a near-black display-p3 surface.

+
+ +
+

Flag Dim On Lch Panel: warm gray copy on a warm near-black lch surface.

+
+ +
+

Flag Pale On Light Panel: bone copy on a bone-white srgb surface.

+
+ +
+
+

Flag Pale On Inherited Light Panel: the ground is inherited from the light section, so the walk must resolve it rather than abstain.

+
+
+ +
+

Flag Pale On Currentcolor Panel: the ground is the panel's own bone text color, so the keyword must resolve rather than abstain.

+
+ +

Should pass

+ +
+

Pass Light Text On Oklch Gradient Ground: warm ivory copy on the lacquer gradient, roughly 15:1.

+
+ +
+

Pass Light Text On Srgb Panel: warm ivory copy on the near-black srgb surface.

+
+ +
+

Pass Light Text On Color Mix Panel: warm ivory copy on a surface a browser reports as color(srgb ...).

+
+ +
+

Pass Light Text On Display P3 Panel: warm ivory copy on the near-black display-p3 surface.

+
+ +
+

Pass Light Text On Lch Panel: warm ivory copy on the warm near-black lch surface.

+
+ +
+

Pass Dark Text On Light Panel: ink copy on the bone-white srgb surface.

+
+

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.

+
+
+ +
+

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.

+
+ + + diff --git a/tests/fixtures/antipatterns/glow.html b/tests/fixtures/antipatterns/glow.html index cd8fb2b81..9d1b0db83 100644 --- a/tests/fixtures/antipatterns/glow.html +++ b/tests/fixtures/antipatterns/glow.html @@ -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); } @@ -136,6 +157,22 @@

Chromatic halo on light page

box-shadow: 0 0 20px oklch(0.65 0.2 300 / 0.45)

+ +

Zero-offset halo on unreadable (image) surface

+
+
+

Halo over photo background

+

box-shadow: 0 0 26px rgba(217, 70, 239, 0.5)

+
+
+ +

Colored glow under an opaque gradient over an image (browser)

+
+
+

Opaque dark gradient covers the photo

+

box-shadow: 0 6px 22px rgba(249, 115, 22, 0.5)

+
+