diff --git a/cli/engine/detect-antipatterns-browser.js b/cli/engine/detect-antipatterns-browser.js index 400c9294b..d42a177d0 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,416 @@ function colorToHex(c) { return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join(''); } +// ─── Color-space conversions ──────────────────────────────────────────────── +// +// Every function here lands on 8-bit sRGB, clamped to gamut. Chrome, Safari, +// and Firefox all keep the authored color space in getComputedStyle output +// (`oklch(0.84 0.19 80.46)`, `lch(20 5 60)`, `color(srgb 1.04 0.72 -0.21)`), +// so a detector that only reads rgb() is blind on any modern palette. The +// expected outputs are pinned in tests/detect-antipatterns.test.js against +// what Chrome itself paints for the same strings. + +function clamp01(x) { + return Number.isFinite(x) ? Math.max(0, Math.min(1, x)) : 0; +} + +// Linear-light sRGB channel to the encoded 0-255 value. +function encodeSrgbChannel(x) { + const c = clamp01(x); + return Math.round((c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055) * 255); +} + +function decodeSrgbChannel(x) { + const c = Number.isFinite(x) ? x : 0; + const sign = c < 0 ? -1 : 1; + const abs = Math.abs(c); + return sign * (abs <= 0.04045 ? abs / 12.92 : Math.pow((abs + 0.055) / 1.055, 2.4)); +} + +function linearSrgbToColor(r, g, b, a = 1) { + return { r: encodeSrgbChannel(r), g: encodeSrgbChannel(g), b: encodeSrgbChannel(b), a }; +} + +// OKLab to sRGB (Björn Ottosson's matrices). L in 0..1, a/b are signed axes. +function oklabToRgb(L, a, b) { + const l_ = L + 0.3963377774 * a + 0.2158037573 * b; + const m_ = L - 0.1055613458 * a - 0.0638541728 * b; + const s_ = L - 0.0894841775 * a - 1.2914855480 * b; + const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_; + return linearSrgbToColor( + 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc, + -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc, + -0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc, + ); +} + +// OKLCH to sRGB. L in 0..1, C in 0..~0.4 typical, H in degrees. Chroma past +// the sRGB gamut clamps per channel rather than producing NaN. +function oklchToRgb(L, C, H) { + const hRad = (H * Math.PI) / 180; + return oklabToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad)); +} + +// CIE Lab to sRGB. CSS lab()/lch() use the D50 white point; the matrix below +// is the Bradford-adapted XYZ-D50 to linear-sRGB transform from CSS Color 4. +function labToRgb(L, a, b) { + const kappa = 24389 / 27, epsilon = 216 / 24389; + const fy = (L + 16) / 116, fx = fy + a / 500, fz = fy - b / 200; + const invert = (t) => (t * t * t > epsilon ? t * t * t : (116 * t - 16) / kappa); + const yr = L > kappa * epsilon ? Math.pow((L + 16) / 116, 3) : L / kappa; + const Xn = 0.3457 / 0.3585, Zn = (1 - 0.3457 - 0.3585) / 0.3585; + const x = invert(fx) * Xn, y = yr, z = invert(fz) * Zn; + return linearSrgbToColor( + 3.1341359569958707 * x - 1.6173863321612538 * y - 0.4906619460083532 * z, + -0.9787955029120890 * x + 1.9162545672595240 * y + 0.0334427311613195 * z, + 0.0719553798841168 * x - 0.2289768264158322 * y + 1.4053860583241250 * z, + ); +} + +function lchToRgb(L, C, H) { + const hRad = (H * Math.PI) / 180; + return labToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad)); +} + +// color( 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). +function isNoPaintColorValue(value) { + const v = String(value || '').trim().toLowerCase(); + if (!v) return true; + return v === 'transparent' || v === 'none' || v === 'initial' || v === 'unset' || v === 'revert' || v === 'revert-layer'; +} + // --- cli/engine/shared/fonts.mjs --- const GOOGLE_FONTS_URL_RE = /fonts\.googleapis\.com\/css2?\?[^"'\s)<>]*/gi; @@ -2461,7 +2903,19 @@ function readOwnBackgroundColor(el, computedStyle) { return bg; } -function resolveBackground(el, win, customPropMap) { +// Walk up for the surface the element's text is painted on. +// +// Returns { color, unresolved }: +// • color set — the effective surface, overlays composited in. +// • unresolved: true — a layer on the way up paints a color this parser +// cannot read, so the surface is unknown. Callers +// must SKIP their contrast checks. Guessing white +// here is what flooded dark themes with false +// "on #ffffff" findings: one abstention costs a +// single finding, one wrong guess costs a hundred. +// • both null/false — no solid color, but a gradient or image is in +// play; callers fall back to its color stops. +function resolveBackgroundInfo(el, win, customPropMap) { let current = el; // Translucent layers (0.1 < a < 1) found on the way down to an opaque // base. A browser composites these over the base; the old behavior @@ -2509,30 +2963,30 @@ function resolveBackground(el, win, customPropMap) { } if (bg && bg.a > 0.1) { - if (bg.a >= 0.99) return flatten(bg); + if (bg.a >= 0.99) return { color: flatten(bg), unresolved: false }; overlays.push(bg); + } else if (!bg && !isNoPaintColorValue(style.backgroundColor)) { + // This layer names a color we could not parse (a color space we do not + // model, an unresolved var(), a syntax newer than the parser). It may + // well be opaque, which would make every ancestor below it invisible — + // so the surface is unknown and the walk stops here rather than + // reporting an ancestor the visitor never sees. + return { color: null, unresolved: true }; } - // No solid bg-color at this level. If THIS level has a gradient/url - // with no underlying solid color we can read: - // • on body/html: assume white. Body-level gradients are almost - // always decorative texture (paper grain, noise) on top of a - // solid bg-color the page set via `background: var(--paper)` - // shorthand — which jsdom can't decompose into bg-color. The - // downstream gradient-stops fallback path produces catastrophic - // false positives in this case (gradient noise stops have - // accidental browns/blacks that look like card backgrounds). - // • on other elements: bail to null and let the caller fall back - // to gradient stops (gradient buttons / hero sections are real - // bgs worth checking against). - if (hasGradientOrUrl) { - if (current.tagName === 'BODY' || current.tagName === 'HTML') { - return flatten({ r: 255, g: 255, b: 255, a: 1 }); - } - return null; - } + // No solid bg-color at this level. A gradient or image at THIS level is + // the surface: hand the caller a null color so it falls back to the + // gradient's own stops (body grounds, gradient buttons, hero sections). + if (hasGradientOrUrl) return { color: null, unresolved: false }; current = current.parentElement; } - return flatten({ r: 255, g: 255, b: 255, a: 1 }); + // Every layer up to the document root was genuinely see-through, so the + // browser paints its default canvas. This is the ONLY case that earns the + // white assumption. + return { color: flatten({ r: 255, g: 255, b: 255, a: 1 }), unresolved: false }; +} + +function resolveBackground(el, win, customPropMap) { + return resolveBackgroundInfo(el, win, customPropMap).color; } // Walk parents looking for a gradient background and return its color stops. @@ -2779,13 +3233,19 @@ function checkElementColorsDOM(el) { const style = getComputedStyle(el); const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join(''); const hasDirectText = directText.trim().length > 0; - let effectiveBg = resolveBackground(el); + const bgInfo = resolveBackgroundInfo(el); + let effectiveBg = bgInfo.color; + // An unreadable surface anywhere up the chain: skip the gradient-stop + // fallback too, so nothing downstream measures against a ground we never + // resolved. + let surfaceUnresolved = bgInfo.unresolved; let ownBg = readOwnBackgroundColor(el, style); if (!ownBg || (ownBg.a ?? 1) <= 0.5) { const pseudoSurface = readPseudoSurfaceDOM(el, rect); if (pseudoSurface) { ownBg = pseudoSurface; effectiveBg = pseudoSurface; + surfaceUnresolved = false; } } return checkColors({ @@ -2797,8 +3257,8 @@ function checkElementColorsDOM(el) { // an oklch token near its own oklch background). textColor: parseRgb(style.color) || parseAnyColor(style.color), bgColor: ownBg, - effectiveBg, - effectiveBgStops: effectiveBg ? null : resolveGradientStops(el), + effectiveBg: surfaceUnresolved ? null : effectiveBg, + effectiveBgStops: surfaceUnresolved || effectiveBg ? null : resolveGradientStops(el), fontSize: parseFloat(style.fontSize) || 16, fontWeight: parseInt(style.fontWeight) || 400, hasDirectText, @@ -2948,283 +3408,6 @@ function resolveVarRefs(raw, customPropMap, depth = 0) { }); } -// OKLCH → sRGB conversion (Björn Ottosson's matrices). L in 0..1 (or %), -// C in 0..~0.4 typical, H in degrees. Returns clamped {r,g,b,a:1} in 0..255. -// Needed because jsdom doesn't compute oklch() values — getComputedStyle -// returns the literal "oklch(...)" string. Without this, the entire -// Tailwind v4 color palette (which is OKLCH-based) is invisible to the -// detector's contrast / color checks. -function oklchToRgb(L, C, H) { - const hRad = (H * Math.PI) / 180; - return oklabToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad)); -} - -function oklabToRgb(L, a, b) { - const l_ = L + 0.3963377774 * a + 0.2158037573 * b; - const m_ = L - 0.1055613458 * a - 0.0638541728 * b; - const s_ = L - 0.0894841775 * a - 1.2914855480 * b; - const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_; - const rLin = 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc; - const gLin = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc; - const bLin = -0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc; - const enc = (x) => { - const c = Math.max(0, Math.min(1, x)); - return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055; - }; - return { - r: Math.round(enc(rLin) * 255), - g: Math.round(enc(gLin) * 255), - b: Math.round(enc(bLin) * 255), - a: 1, - }; -} - -function hslToRgb(h, s, l) { - h = ((h % 360) + 360) % 360; - const c = (1 - Math.abs(2 * l - 1)) * s; - const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); - const m0 = l - c / 2; - const [r, g, b] = - h < 60 ? [c, x, 0] : - h < 120 ? [x, c, 0] : - h < 180 ? [0, c, x] : - h < 240 ? [0, x, c] : - h < 300 ? [x, 0, c] : [c, 0, x]; - return { - r: Math.round((r + m0) * 255), - g: Math.round((g + m0) * 255), - b: Math.round((b + m0) * 255), - a: 1, - }; -} - -function hwbToRgb(h, w, bl) { - if (w + bl >= 1) { - const g = Math.round((w / (w + bl)) * 255); - return { r: g, g, b: g, a: 1 }; - } - const base = hslToRgb(h, 1, 0.5); - const mix = (c) => Math.round(((c / 255) * (1 - w - bl) + w) * 255); - return { r: mix(base.r), g: mix(base.g), b: mix(base.b), a: 1 }; -} - -// Common CSS named colors — the handful that actually show up in generated -// UIs, not the full 148-name spec list. Includes the achromatic names so a -// named gray parses (and correctly reads as no-chroma) instead of being -// treated as an unknown color. -const CSS_NAMED_COLORS = { - black: { r: 0, g: 0, b: 0 }, - white: { r: 255, g: 255, b: 255 }, - gray: { r: 128, g: 128, b: 128 }, - grey: { r: 128, g: 128, b: 128 }, - silver: { r: 192, g: 192, b: 192 }, - dimgray: { r: 105, g: 105, b: 105 }, - darkgray: { r: 169, g: 169, b: 169 }, - lightgray: { r: 211, g: 211, b: 211 }, - gainsboro: { r: 220, g: 220, b: 220 }, - whitesmoke: { r: 245, g: 245, b: 245 }, - red: { r: 255, g: 0, b: 0 }, - crimson: { r: 220, g: 20, b: 60 }, - tomato: { r: 255, g: 99, b: 71 }, - coral: { r: 255, g: 127, b: 80 }, - salmon: { r: 250, g: 128, b: 114 }, - orange: { r: 255, g: 165, b: 0 }, - gold: { r: 255, g: 215, b: 0 }, - yellow: { r: 255, g: 255, b: 0 }, - olive: { r: 128, g: 128, b: 0 }, - lime: { r: 0, g: 255, b: 0 }, - green: { r: 0, g: 128, b: 0 }, - teal: { r: 0, g: 128, b: 128 }, - turquoise: { r: 64, g: 224, b: 208 }, - cyan: { r: 0, g: 255, b: 255 }, - aqua: { r: 0, g: 255, b: 255 }, - skyblue: { r: 135, g: 206, b: 235 }, - dodgerblue: { r: 30, g: 144, b: 255 }, - blue: { r: 0, g: 0, b: 255 }, - navy: { r: 0, g: 0, b: 128 }, - indigo: { r: 75, g: 0, b: 130 }, - rebeccapurple: { r: 102, g: 51, b: 153 }, - purple: { r: 128, g: 0, b: 128 }, - violet: { r: 238, g: 130, b: 238 }, - orchid: { r: 218, g: 112, b: 214 }, - magenta: { r: 255, g: 0, b: 255 }, - fuchsia: { r: 255, g: 0, b: 255 }, - hotpink: { r: 255, g: 105, b: 180 }, - pink: { r: 255, g: 192, b: 203 }, - maroon: { r: 128, g: 0, b: 0 }, -}; - -// Split a string on top-level commas (ignoring commas nested in parens). -function splitTopLevelCommas(str) { - const parts = []; - let depth = 0, start = 0; - for (let i = 0; i < str.length; i++) { - const ch = str[i]; - if (ch === '(') depth++; - else if (ch === ')') depth = Math.max(0, depth - 1); - else if (ch === ',' && depth === 0) { - parts.push(str.slice(start, i).trim()); - start = i + 1; - } - } - const tail = str.slice(start).trim(); - if (tail) parts.push(tail); - return parts; -} - -// Evaluate a CSS color-mix() expression to {r,g,b,a}. Returns null when -// the expression can't be resolved (unresolved var(), unknown colors). -// -// Mixing is done with premultiplied alpha in sRGB regardless of the -// declared interpolation space. That is exact for the dominant generated-UI -// pattern — `color-mix(in oklab, 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 @@ -4438,7 +4621,8 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join(''); const hasDirectText = directText.trim().length > 0; - const effectiveBg = resolveBackground(el, window, customPropMap); + const bgInfo = resolveBackgroundInfo(el, window, customPropMap); + const effectiveBg = bgInfo.color; // jsdom returns literal "var(--X)" / "oklch(...)" for color, so plain // parseRgb misses Tailwind-tokenized text colors. Resolve through the // customPropMap first; fall back to parseRgb for vanilla rgb() pages. @@ -4484,11 +4668,13 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe // element itself has no usable own background, that pseudo is the real // surface for contrast purposes. let finalEffectiveBg = effectiveBg; + let surfaceUnresolved = bgInfo.unresolved; if ((!ownBg || (ownBg.a ?? 1) <= 0.5) && typeof window.getPseudoSurface === 'function') { const pseudoSurface = window.getPseudoSurface(el); if (pseudoSurface) { ownBg = pseudoSurface; finalEffectiveBg = pseudoSurface; + surfaceUnresolved = false; } } @@ -4496,8 +4682,9 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe tag, textColor, bgColor: ownBg, - effectiveBg: finalEffectiveBg, - effectiveBgStops: finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap), + // Unknown surface: hand the checks nothing rather than a guess. + effectiveBg: surfaceUnresolved ? null : finalEffectiveBg, + effectiveBgStops: surfaceUnresolved || finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap), fontSize: parseFloat(style.fontSize) || 16, fontWeight: parseInt(style.fontWeight) || 400, hasDirectText, diff --git a/cli/engine/rules/checks.mjs b/cli/engine/rules/checks.mjs index aee24a947..3c23f770a 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'; @@ -1670,7 +1677,19 @@ function readOwnBackgroundColor(el, computedStyle) { return bg; } -function resolveBackground(el, win, customPropMap) { +// Walk up for the surface the element's text is painted on. +// +// Returns { color, unresolved }: +// • color set — the effective surface, overlays composited in. +// • unresolved: true — a layer on the way up paints a color this parser +// cannot read, so the surface is unknown. Callers +// must SKIP their contrast checks. Guessing white +// here is what flooded dark themes with false +// "on #ffffff" findings: one abstention costs a +// single finding, one wrong guess costs a hundred. +// • both null/false — no solid color, but a gradient or image is in +// play; callers fall back to its color stops. +function resolveBackgroundInfo(el, win, customPropMap) { let current = el; // Translucent layers (0.1 < a < 1) found on the way down to an opaque // base. A browser composites these over the base; the old behavior @@ -1718,30 +1737,30 @@ function resolveBackground(el, win, customPropMap) { } if (bg && bg.a > 0.1) { - if (bg.a >= 0.99) return flatten(bg); + if (bg.a >= 0.99) return { color: flatten(bg), unresolved: false }; overlays.push(bg); + } else if (!bg && !isNoPaintColorValue(style.backgroundColor)) { + // This layer names a color we could not parse (a color space we do not + // model, an unresolved var(), a syntax newer than the parser). It may + // well be opaque, which would make every ancestor below it invisible — + // so the surface is unknown and the walk stops here rather than + // reporting an ancestor the visitor never sees. + return { color: null, unresolved: true }; } - // No solid bg-color at this level. If THIS level has a gradient/url - // with no underlying solid color we can read: - // • on body/html: assume white. Body-level gradients are almost - // always decorative texture (paper grain, noise) on top of a - // solid bg-color the page set via `background: var(--paper)` - // shorthand — which jsdom can't decompose into bg-color. The - // downstream gradient-stops fallback path produces catastrophic - // false positives in this case (gradient noise stops have - // accidental browns/blacks that look like card backgrounds). - // • on other elements: bail to null and let the caller fall back - // to gradient stops (gradient buttons / hero sections are real - // bgs worth checking against). - if (hasGradientOrUrl) { - if (current.tagName === 'BODY' || current.tagName === 'HTML') { - return flatten({ r: 255, g: 255, b: 255, a: 1 }); - } - return null; - } + // No solid bg-color at this level. A gradient or image at THIS level is + // the surface: hand the caller a null color so it falls back to the + // gradient's own stops (body grounds, gradient buttons, hero sections). + if (hasGradientOrUrl) return { color: null, unresolved: false }; current = current.parentElement; } - return flatten({ r: 255, g: 255, b: 255, a: 1 }); + // Every layer up to the document root was genuinely see-through, so the + // browser paints its default canvas. This is the ONLY case that earns the + // white assumption. + return { color: flatten({ r: 255, g: 255, b: 255, a: 1 }), unresolved: false }; +} + +function resolveBackground(el, win, customPropMap) { + return resolveBackgroundInfo(el, win, customPropMap).color; } // Walk parents looking for a gradient background and return its color stops. @@ -1988,13 +2007,19 @@ function checkElementColorsDOM(el) { const style = getComputedStyle(el); const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join(''); const hasDirectText = directText.trim().length > 0; - let effectiveBg = resolveBackground(el); + const bgInfo = resolveBackgroundInfo(el); + let effectiveBg = bgInfo.color; + // An unreadable surface anywhere up the chain: skip the gradient-stop + // fallback too, so nothing downstream measures against a ground we never + // resolved. + let surfaceUnresolved = bgInfo.unresolved; let ownBg = readOwnBackgroundColor(el, style); if (!ownBg || (ownBg.a ?? 1) <= 0.5) { const pseudoSurface = readPseudoSurfaceDOM(el, rect); if (pseudoSurface) { ownBg = pseudoSurface; effectiveBg = pseudoSurface; + surfaceUnresolved = false; } } return checkColors({ @@ -2006,8 +2031,8 @@ function checkElementColorsDOM(el) { // an oklch token near its own oklch background). textColor: parseRgb(style.color) || parseAnyColor(style.color), bgColor: ownBg, - effectiveBg, - effectiveBgStops: effectiveBg ? null : resolveGradientStops(el), + effectiveBg: surfaceUnresolved ? null : effectiveBg, + effectiveBgStops: surfaceUnresolved || effectiveBg ? null : resolveGradientStops(el), fontSize: parseFloat(style.fontSize) || 16, fontWeight: parseInt(style.fontWeight) || 400, hasDirectText, @@ -2157,283 +2182,6 @@ function resolveVarRefs(raw, customPropMap, depth = 0) { }); } -// OKLCH → sRGB conversion (Björn Ottosson's matrices). L in 0..1 (or %), -// C in 0..~0.4 typical, H in degrees. Returns clamped {r,g,b,a:1} in 0..255. -// Needed because jsdom doesn't compute oklch() values — getComputedStyle -// returns the literal "oklch(...)" string. Without this, the entire -// Tailwind v4 color palette (which is OKLCH-based) is invisible to the -// detector's contrast / color checks. -function oklchToRgb(L, C, H) { - const hRad = (H * Math.PI) / 180; - return oklabToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad)); -} - -function oklabToRgb(L, a, b) { - const l_ = L + 0.3963377774 * a + 0.2158037573 * b; - const m_ = L - 0.1055613458 * a - 0.0638541728 * b; - const s_ = L - 0.0894841775 * a - 1.2914855480 * b; - const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_; - const rLin = 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc; - const gLin = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc; - const bLin = -0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc; - const enc = (x) => { - const c = Math.max(0, Math.min(1, x)); - return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055; - }; - return { - r: Math.round(enc(rLin) * 255), - g: Math.round(enc(gLin) * 255), - b: Math.round(enc(bLin) * 255), - a: 1, - }; -} - -function hslToRgb(h, s, l) { - h = ((h % 360) + 360) % 360; - const c = (1 - Math.abs(2 * l - 1)) * s; - const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); - const m0 = l - c / 2; - const [r, g, b] = - h < 60 ? [c, x, 0] : - h < 120 ? [x, c, 0] : - h < 180 ? [0, c, x] : - h < 240 ? [0, x, c] : - h < 300 ? [x, 0, c] : [c, 0, x]; - return { - r: Math.round((r + m0) * 255), - g: Math.round((g + m0) * 255), - b: Math.round((b + m0) * 255), - a: 1, - }; -} - -function hwbToRgb(h, w, bl) { - if (w + bl >= 1) { - const g = Math.round((w / (w + bl)) * 255); - return { r: g, g, b: g, a: 1 }; - } - const base = hslToRgb(h, 1, 0.5); - const mix = (c) => Math.round(((c / 255) * (1 - w - bl) + w) * 255); - return { r: mix(base.r), g: mix(base.g), b: mix(base.b), a: 1 }; -} - -// Common CSS named colors — the handful that actually show up in generated -// UIs, not the full 148-name spec list. Includes the achromatic names so a -// named gray parses (and correctly reads as no-chroma) instead of being -// treated as an unknown color. -const CSS_NAMED_COLORS = { - black: { r: 0, g: 0, b: 0 }, - white: { r: 255, g: 255, b: 255 }, - gray: { r: 128, g: 128, b: 128 }, - grey: { r: 128, g: 128, b: 128 }, - silver: { r: 192, g: 192, b: 192 }, - dimgray: { r: 105, g: 105, b: 105 }, - darkgray: { r: 169, g: 169, b: 169 }, - lightgray: { r: 211, g: 211, b: 211 }, - gainsboro: { r: 220, g: 220, b: 220 }, - whitesmoke: { r: 245, g: 245, b: 245 }, - red: { r: 255, g: 0, b: 0 }, - crimson: { r: 220, g: 20, b: 60 }, - tomato: { r: 255, g: 99, b: 71 }, - coral: { r: 255, g: 127, b: 80 }, - salmon: { r: 250, g: 128, b: 114 }, - orange: { r: 255, g: 165, b: 0 }, - gold: { r: 255, g: 215, b: 0 }, - yellow: { r: 255, g: 255, b: 0 }, - olive: { r: 128, g: 128, b: 0 }, - lime: { r: 0, g: 255, b: 0 }, - green: { r: 0, g: 128, b: 0 }, - teal: { r: 0, g: 128, b: 128 }, - turquoise: { r: 64, g: 224, b: 208 }, - cyan: { r: 0, g: 255, b: 255 }, - aqua: { r: 0, g: 255, b: 255 }, - skyblue: { r: 135, g: 206, b: 235 }, - dodgerblue: { r: 30, g: 144, b: 255 }, - blue: { r: 0, g: 0, b: 255 }, - navy: { r: 0, g: 0, b: 128 }, - indigo: { r: 75, g: 0, b: 130 }, - rebeccapurple: { r: 102, g: 51, b: 153 }, - purple: { r: 128, g: 0, b: 128 }, - violet: { r: 238, g: 130, b: 238 }, - orchid: { r: 218, g: 112, b: 214 }, - magenta: { r: 255, g: 0, b: 255 }, - fuchsia: { r: 255, g: 0, b: 255 }, - hotpink: { r: 255, g: 105, b: 180 }, - pink: { r: 255, g: 192, b: 203 }, - maroon: { r: 128, g: 0, b: 0 }, -}; - -// Split a string on top-level commas (ignoring commas nested in parens). -function splitTopLevelCommas(str) { - const parts = []; - let depth = 0, start = 0; - for (let i = 0; i < str.length; i++) { - const ch = str[i]; - if (ch === '(') depth++; - else if (ch === ')') depth = Math.max(0, depth - 1); - else if (ch === ',' && depth === 0) { - parts.push(str.slice(start, i).trim()); - start = i + 1; - } - } - const tail = str.slice(start).trim(); - if (tail) parts.push(tail); - return parts; -} - -// Evaluate a CSS color-mix() expression to {r,g,b,a}. Returns null when -// the expression can't be resolved (unresolved var(), unknown colors). -// -// Mixing is done with premultiplied alpha in sRGB regardless of the -// declared interpolation space. That is exact for the dominant generated-UI -// pattern — `color-mix(in oklab, 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 @@ -3647,7 +3395,8 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join(''); const hasDirectText = directText.trim().length > 0; - const effectiveBg = resolveBackground(el, window, customPropMap); + const bgInfo = resolveBackgroundInfo(el, window, customPropMap); + const effectiveBg = bgInfo.color; // jsdom returns literal "var(--X)" / "oklch(...)" for color, so plain // parseRgb misses Tailwind-tokenized text colors. Resolve through the // customPropMap first; fall back to parseRgb for vanilla rgb() pages. @@ -3693,11 +3442,13 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe // element itself has no usable own background, that pseudo is the real // surface for contrast purposes. let finalEffectiveBg = effectiveBg; + let surfaceUnresolved = bgInfo.unresolved; if ((!ownBg || (ownBg.a ?? 1) <= 0.5) && typeof window.getPseudoSurface === 'function') { const pseudoSurface = window.getPseudoSurface(el); if (pseudoSurface) { ownBg = pseudoSurface; finalEffectiveBg = pseudoSurface; + surfaceUnresolved = false; } } @@ -3705,8 +3456,9 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe tag, textColor, bgColor: ownBg, - effectiveBg: finalEffectiveBg, - effectiveBgStops: finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap), + // Unknown surface: hand the checks nothing rather than a guess. + effectiveBg: surfaceUnresolved ? null : finalEffectiveBg, + effectiveBgStops: surfaceUnresolved || finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap), fontSize: parseFloat(style.fontSize) || 16, fontWeight: parseInt(style.fontWeight) || 400, hasDirectText, @@ -5497,6 +5249,7 @@ export { checkHtmlPatterns, readOwnBackgroundColor, resolveBackground, + resolveBackgroundInfo, resolveGradientStops, parseRadiusToPx, resolveBorderRadiusPx, diff --git a/cli/engine/shared/color.mjs b/cli/engine/shared/color.mjs index 3d9a126bf..278ef30f9 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,437 @@ function colorToHex(c) { return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join(''); } +// ─── Color-space conversions ──────────────────────────────────────────────── +// +// Every function here lands on 8-bit sRGB, clamped to gamut. Chrome, Safari, +// and Firefox all keep the authored color space in getComputedStyle output +// (`oklch(0.84 0.19 80.46)`, `lch(20 5 60)`, `color(srgb 1.04 0.72 -0.21)`), +// so a detector that only reads rgb() is blind on any modern palette. The +// expected outputs are pinned in tests/detect-antipatterns.test.js against +// what Chrome itself paints for the same strings. + +function clamp01(x) { + return Number.isFinite(x) ? Math.max(0, Math.min(1, x)) : 0; +} + +// Linear-light sRGB channel to the encoded 0-255 value. +function encodeSrgbChannel(x) { + const c = clamp01(x); + return Math.round((c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055) * 255); +} + +function decodeSrgbChannel(x) { + const c = Number.isFinite(x) ? x : 0; + const sign = c < 0 ? -1 : 1; + const abs = Math.abs(c); + return sign * (abs <= 0.04045 ? abs / 12.92 : Math.pow((abs + 0.055) / 1.055, 2.4)); +} + +function linearSrgbToColor(r, g, b, a = 1) { + return { r: encodeSrgbChannel(r), g: encodeSrgbChannel(g), b: encodeSrgbChannel(b), a }; +} + +// OKLab to sRGB (Björn Ottosson's matrices). L in 0..1, a/b are signed axes. +function oklabToRgb(L, a, b) { + const l_ = L + 0.3963377774 * a + 0.2158037573 * b; + const m_ = L - 0.1055613458 * a - 0.0638541728 * b; + const s_ = L - 0.0894841775 * a - 1.2914855480 * b; + const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_; + return linearSrgbToColor( + 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc, + -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc, + -0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc, + ); +} + +// OKLCH to sRGB. L in 0..1, C in 0..~0.4 typical, H in degrees. Chroma past +// the sRGB gamut clamps per channel rather than producing NaN. +function oklchToRgb(L, C, H) { + const hRad = (H * Math.PI) / 180; + return oklabToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad)); +} + +// CIE Lab to sRGB. CSS lab()/lch() use the D50 white point; the matrix below +// is the Bradford-adapted XYZ-D50 to linear-sRGB transform from CSS Color 4. +function labToRgb(L, a, b) { + const kappa = 24389 / 27, epsilon = 216 / 24389; + const fy = (L + 16) / 116, fx = fy + a / 500, fz = fy - b / 200; + const invert = (t) => (t * t * t > epsilon ? t * t * t : (116 * t - 16) / kappa); + const yr = L > kappa * epsilon ? Math.pow((L + 16) / 116, 3) : L / kappa; + const Xn = 0.3457 / 0.3585, Zn = (1 - 0.3457 - 0.3585) / 0.3585; + const x = invert(fx) * Xn, y = yr, z = invert(fz) * Zn; + return linearSrgbToColor( + 3.1341359569958707 * x - 1.6173863321612538 * y - 0.4906619460083532 * z, + -0.9787955029120890 * x + 1.9162545672595240 * y + 0.0334427311613195 * z, + 0.0719553798841168 * x - 0.2289768264158322 * y + 1.4053860583241250 * z, + ); +} + +function lchToRgb(L, C, H) { + const hRad = (H * Math.PI) / 180; + return labToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad)); +} + +// color( 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). +function isNoPaintColorValue(value) { + const v = String(value || '').trim().toLowerCase(); + if (!v) return true; + return v === 'transparent' || v === 'none' || v === 'initial' || v === 'unset' || v === 'revert' || v === 'revert-layer'; +} + export { isNeutralColor, parseRgb, relativeLuminance, contrastRatio, parseGradientColors, + extractColorFunctionTokens, hasChroma, getHue, colorToHex, + oklabToRgb, + oklchToRgb, + labToRgb, + lchToRgb, + colorFunctionToRgb, + hslToRgb, + hwbToRgb, + CSS_NAMED_COLORS, + splitTopLevelCommas, + parseColorMix, + parseAnyColor, + compositeColorOver, + isNoPaintColorValue, }; diff --git a/tests/detect-antipatterns-browser.test.mjs b/tests/detect-antipatterns-browser.test.mjs index c0ef5a24f..6d1aa0df5 100644 --- a/tests/detect-antipatterns-browser.test.mjs +++ b/tests/detect-antipatterns-browser.test.mjs @@ -948,4 +948,48 @@ describe('detectUrl — browser-only fixtures', () => { await detector.close(); } }); + + // Only a real browser reproduces this one: Chrome keeps oklch(), lch(), and + // color(srgb ...) verbatim in getComputedStyle output, so a detector that + // cannot parse those reads every surface as unset, walks out of the page, + // and assumes the white canvas. On a dark theme that turns every light line + // into a false "on #ffffff" finding (two live scans of impeccable.style + // produced 95 and ~120 of them). + describe('dark themes written in modern color syntax', () => { + const FLAG_PAIRS = [ + // Worst stop of the two-stop oklch ground. + ['#35332d', '#050403'], + ['#47474d', '#1a1c1f'], + ['#59595c', '#121215'], + ['#56514e', '#302b27'], + ['#bfbdb8', '#faf7f2'], + ]; + + it('reads oklch / color() / lch grounds and never assumes white', async () => { + const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/dark-theme-modern-color.html`, { + visualContrast: false, + }); + const lowContrast = f.filter(r => r.antipattern === 'low-contrast'); + const snippets = lowContrast.map(r => r.snippet || ''); + + const onWhite = snippets.filter(s => /on #ffffff/i.test(s)); + assert.equal( + onWhite.length, 0, + `no finding may claim a white ground on this page, got: ${onWhite.join('; ')}`, + ); + + const pale = snippets.filter(s => /#e7e4dd/i.test(s)); + assert.equal( + pale.length, 0, + `ivory copy on dark grounds must not flag, got: ${pale.join('; ')}`, + ); + + for (const [text, bg] of FLAG_PAIRS) { + assert.ok( + snippets.some(s => s.includes(`text ${text}`) && s.includes(`on ${bg}`)), + `expected low-contrast for text ${text} on ${bg}, got: ${snippets.join('; ')}`, + ); + } + }); + }); }); diff --git a/tests/detect-antipatterns-fixtures.test.mjs b/tests/detect-antipatterns-fixtures.test.mjs index 6492b1976..69f266121 100644 --- a/tests/detect-antipatterns-fixtures.test.mjs +++ b/tests/detect-antipatterns-fixtures.test.mjs @@ -1366,3 +1366,51 @@ describe('detectHtml — CSS patterns in prose (css-in-prose fixtures)', () => { assert.ok(f.some(r => r.antipattern === 'ai-color-palette'), 'expected ai-color-palette'); }); }); + +describe('detectHtml — dark themes written in modern color syntax', () => { + // A dark page whose ground and surfaces are written in oklch, color(srgb), + // color(display-p3), and lch. Backgrounds the parser cannot read must make + // the contrast checks abstain; assuming the browser default of white turns + // every light-on-dark line into a false "on #ffffff" finding. + const FLAG_PAIRS = [ + // The ground is a two-stop oklch gradient; the check reports the worst + // stop, which for charcoal copy is the lighter one. + ['#35332d', '#050403'], // Flag Muted On Oklch Ground + ['#47474d', '#1a1c1f'], // Flag Dim On Srgb Panel + ['#59595c', '#121215'], // Flag Dim On Display P3 Panel + ['#56514e', '#302b27'], // Flag Dim On Lch Panel + ['#bfbdb8', '#faf7f2'], // Flag Pale On Light Panel + ]; + + it('flags text that genuinely fails against a ground the parser can read', async () => { + const f = await detectHtml(path.join(FIXTURES, 'dark-theme-modern-color.html')); + const snippets = f.filter(r => r.antipattern === 'low-contrast').map(r => r.snippet || ''); + for (const [text, bg] of FLAG_PAIRS) { + assert.ok( + snippets.some(s => s.includes(`text ${text}`) && s.includes(`on ${bg}`)), + `expected low-contrast for text ${text} on ${bg}, got: ${snippets.join('; ')}`, + ); + } + }); + + it('never assumes white when the ground is unreadable', async () => { + const f = await detectHtml(path.join(FIXTURES, 'dark-theme-modern-color.html')); + const onWhite = f.filter(r => /on #ffffff/i.test(r.snippet || '')); + assert.equal( + onWhite.length, 0, + `no finding may claim a white ground on this page, got: ${onWhite.map(r => r.snippet).join('; ')}`, + ); + }); + + it('light copy on readable dark surfaces stays quiet', async () => { + const f = await detectHtml(path.join(FIXTURES, 'dark-theme-modern-color.html')); + const pale = f.filter(r => + (r.antipattern === 'low-contrast' || r.antipattern === 'gray-on-color') && + /#e7e4dd/i.test(r.snippet || '') + ); + assert.equal( + pale.length, 0, + `ivory copy on dark grounds must not flag, got: ${pale.map(r => r.snippet).join('; ')}`, + ); + }); +}); 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/dark-theme-modern-color.html b/tests/fixtures/antipatterns/dark-theme-modern-color.html new file mode 100644 index 000000000..301f8391a --- /dev/null +++ b/tests/fixtures/antipatterns/dark-theme-modern-color.html @@ -0,0 +1,104 @@ + + + + +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.

+
+ +

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.

+
+
+ + +