diff --git a/.agents/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.agents/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index ce5e66f0e..8d5bce3f6 100644 --- a/.agents/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.agents/skills/impeccable/scripts/detector/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/.agents/skills/impeccable/scripts/detector/rules/checks.mjs b/.agents/skills/impeccable/scripts/detector/rules/checks.mjs index 045065c66..8a5654625 100644 --- a/.agents/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.agents/skills/impeccable/scripts/detector/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/.agents/skills/impeccable/scripts/detector/shared/color.mjs b/.agents/skills/impeccable/scripts/detector/shared/color.mjs index 3d9a126bf..d2524ce52 100644 --- a/.agents/skills/impeccable/scripts/detector/shared/color.mjs +++ b/.agents/skills/impeccable/scripts/detector/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/.claude/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.claude/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index ce5e66f0e..8d5bce3f6 100644 --- a/.claude/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.claude/skills/impeccable/scripts/detector/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/.claude/skills/impeccable/scripts/detector/rules/checks.mjs b/.claude/skills/impeccable/scripts/detector/rules/checks.mjs index 045065c66..8a5654625 100644 --- a/.claude/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.claude/skills/impeccable/scripts/detector/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/.claude/skills/impeccable/scripts/detector/shared/color.mjs b/.claude/skills/impeccable/scripts/detector/shared/color.mjs index 3d9a126bf..d2524ce52 100644 --- a/.claude/skills/impeccable/scripts/detector/shared/color.mjs +++ b/.claude/skills/impeccable/scripts/detector/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/.cursor/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.cursor/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index ce5e66f0e..8d5bce3f6 100644 --- a/.cursor/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.cursor/skills/impeccable/scripts/detector/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/.cursor/skills/impeccable/scripts/detector/rules/checks.mjs b/.cursor/skills/impeccable/scripts/detector/rules/checks.mjs index 045065c66..8a5654625 100644 --- a/.cursor/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.cursor/skills/impeccable/scripts/detector/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/.cursor/skills/impeccable/scripts/detector/shared/color.mjs b/.cursor/skills/impeccable/scripts/detector/shared/color.mjs index 3d9a126bf..d2524ce52 100644 --- a/.cursor/skills/impeccable/scripts/detector/shared/color.mjs +++ b/.cursor/skills/impeccable/scripts/detector/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/.gemini/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.gemini/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index ce5e66f0e..8d5bce3f6 100644 --- a/.gemini/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.gemini/skills/impeccable/scripts/detector/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/.gemini/skills/impeccable/scripts/detector/rules/checks.mjs b/.gemini/skills/impeccable/scripts/detector/rules/checks.mjs index 045065c66..8a5654625 100644 --- a/.gemini/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.gemini/skills/impeccable/scripts/detector/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/.gemini/skills/impeccable/scripts/detector/shared/color.mjs b/.gemini/skills/impeccable/scripts/detector/shared/color.mjs index 3d9a126bf..d2524ce52 100644 --- a/.gemini/skills/impeccable/scripts/detector/shared/color.mjs +++ b/.gemini/skills/impeccable/scripts/detector/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/.github/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.github/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index ce5e66f0e..8d5bce3f6 100644 --- a/.github/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.github/skills/impeccable/scripts/detector/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/.github/skills/impeccable/scripts/detector/rules/checks.mjs b/.github/skills/impeccable/scripts/detector/rules/checks.mjs index 045065c66..8a5654625 100644 --- a/.github/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.github/skills/impeccable/scripts/detector/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/.github/skills/impeccable/scripts/detector/shared/color.mjs b/.github/skills/impeccable/scripts/detector/shared/color.mjs index 3d9a126bf..d2524ce52 100644 --- a/.github/skills/impeccable/scripts/detector/shared/color.mjs +++ b/.github/skills/impeccable/scripts/detector/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/.grok/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.grok/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index ce5e66f0e..8d5bce3f6 100644 --- a/.grok/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.grok/skills/impeccable/scripts/detector/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/.grok/skills/impeccable/scripts/detector/rules/checks.mjs b/.grok/skills/impeccable/scripts/detector/rules/checks.mjs index 045065c66..8a5654625 100644 --- a/.grok/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.grok/skills/impeccable/scripts/detector/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/.grok/skills/impeccable/scripts/detector/shared/color.mjs b/.grok/skills/impeccable/scripts/detector/shared/color.mjs index 3d9a126bf..d2524ce52 100644 --- a/.grok/skills/impeccable/scripts/detector/shared/color.mjs +++ b/.grok/skills/impeccable/scripts/detector/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/.kiro/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.kiro/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index ce5e66f0e..8d5bce3f6 100644 --- a/.kiro/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.kiro/skills/impeccable/scripts/detector/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/.kiro/skills/impeccable/scripts/detector/rules/checks.mjs b/.kiro/skills/impeccable/scripts/detector/rules/checks.mjs index 045065c66..8a5654625 100644 --- a/.kiro/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.kiro/skills/impeccable/scripts/detector/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/.kiro/skills/impeccable/scripts/detector/shared/color.mjs b/.kiro/skills/impeccable/scripts/detector/shared/color.mjs index 3d9a126bf..d2524ce52 100644 --- a/.kiro/skills/impeccable/scripts/detector/shared/color.mjs +++ b/.kiro/skills/impeccable/scripts/detector/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/.opencode/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.opencode/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index ce5e66f0e..8d5bce3f6 100644 --- a/.opencode/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.opencode/skills/impeccable/scripts/detector/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/.opencode/skills/impeccable/scripts/detector/rules/checks.mjs b/.opencode/skills/impeccable/scripts/detector/rules/checks.mjs index 045065c66..8a5654625 100644 --- a/.opencode/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.opencode/skills/impeccable/scripts/detector/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/.opencode/skills/impeccable/scripts/detector/shared/color.mjs b/.opencode/skills/impeccable/scripts/detector/shared/color.mjs index 3d9a126bf..d2524ce52 100644 --- a/.opencode/skills/impeccable/scripts/detector/shared/color.mjs +++ b/.opencode/skills/impeccable/scripts/detector/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/.pi/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.pi/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index ce5e66f0e..8d5bce3f6 100644 --- a/.pi/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.pi/skills/impeccable/scripts/detector/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/.pi/skills/impeccable/scripts/detector/rules/checks.mjs b/.pi/skills/impeccable/scripts/detector/rules/checks.mjs index 045065c66..8a5654625 100644 --- a/.pi/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.pi/skills/impeccable/scripts/detector/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/.pi/skills/impeccable/scripts/detector/shared/color.mjs b/.pi/skills/impeccable/scripts/detector/shared/color.mjs index 3d9a126bf..d2524ce52 100644 --- a/.pi/skills/impeccable/scripts/detector/shared/color.mjs +++ b/.pi/skills/impeccable/scripts/detector/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/.qoder/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.qoder/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index ce5e66f0e..8d5bce3f6 100644 --- a/.qoder/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.qoder/skills/impeccable/scripts/detector/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/.qoder/skills/impeccable/scripts/detector/rules/checks.mjs b/.qoder/skills/impeccable/scripts/detector/rules/checks.mjs index 045065c66..8a5654625 100644 --- a/.qoder/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.qoder/skills/impeccable/scripts/detector/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/.qoder/skills/impeccable/scripts/detector/shared/color.mjs b/.qoder/skills/impeccable/scripts/detector/shared/color.mjs index 3d9a126bf..d2524ce52 100644 --- a/.qoder/skills/impeccable/scripts/detector/shared/color.mjs +++ b/.qoder/skills/impeccable/scripts/detector/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/.rovodev/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.rovodev/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index ce5e66f0e..8d5bce3f6 100644 --- a/.rovodev/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.rovodev/skills/impeccable/scripts/detector/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/.rovodev/skills/impeccable/scripts/detector/rules/checks.mjs b/.rovodev/skills/impeccable/scripts/detector/rules/checks.mjs index 045065c66..8a5654625 100644 --- a/.rovodev/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.rovodev/skills/impeccable/scripts/detector/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/.rovodev/skills/impeccable/scripts/detector/shared/color.mjs b/.rovodev/skills/impeccable/scripts/detector/shared/color.mjs index 3d9a126bf..d2524ce52 100644 --- a/.rovodev/skills/impeccable/scripts/detector/shared/color.mjs +++ b/.rovodev/skills/impeccable/scripts/detector/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/.trae-cn/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.trae-cn/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index ce5e66f0e..8d5bce3f6 100644 --- a/.trae-cn/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.trae-cn/skills/impeccable/scripts/detector/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/.trae-cn/skills/impeccable/scripts/detector/rules/checks.mjs b/.trae-cn/skills/impeccable/scripts/detector/rules/checks.mjs index 045065c66..8a5654625 100644 --- a/.trae-cn/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.trae-cn/skills/impeccable/scripts/detector/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/.trae-cn/skills/impeccable/scripts/detector/shared/color.mjs b/.trae-cn/skills/impeccable/scripts/detector/shared/color.mjs index 3d9a126bf..d2524ce52 100644 --- a/.trae-cn/skills/impeccable/scripts/detector/shared/color.mjs +++ b/.trae-cn/skills/impeccable/scripts/detector/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/.trae/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.trae/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index ce5e66f0e..8d5bce3f6 100644 --- a/.trae/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.trae/skills/impeccable/scripts/detector/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/.trae/skills/impeccable/scripts/detector/rules/checks.mjs b/.trae/skills/impeccable/scripts/detector/rules/checks.mjs index 045065c66..8a5654625 100644 --- a/.trae/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.trae/skills/impeccable/scripts/detector/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/.trae/skills/impeccable/scripts/detector/shared/color.mjs b/.trae/skills/impeccable/scripts/detector/shared/color.mjs index 3d9a126bf..d2524ce52 100644 --- a/.trae/skills/impeccable/scripts/detector/shared/color.mjs +++ b/.trae/skills/impeccable/scripts/detector/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/.vibe/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.vibe/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index ce5e66f0e..8d5bce3f6 100644 --- a/.vibe/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.vibe/skills/impeccable/scripts/detector/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/.vibe/skills/impeccable/scripts/detector/rules/checks.mjs b/.vibe/skills/impeccable/scripts/detector/rules/checks.mjs index 045065c66..8a5654625 100644 --- a/.vibe/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.vibe/skills/impeccable/scripts/detector/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/.vibe/skills/impeccable/scripts/detector/shared/color.mjs b/.vibe/skills/impeccable/scripts/detector/shared/color.mjs index 3d9a126bf..d2524ce52 100644 --- a/.vibe/skills/impeccable/scripts/detector/shared/color.mjs +++ b/.vibe/skills/impeccable/scripts/detector/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/plugin/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/plugin/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index ce5e66f0e..8d5bce3f6 100644 --- a/plugin/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/plugin/skills/impeccable/scripts/detector/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/plugin/skills/impeccable/scripts/detector/rules/checks.mjs b/plugin/skills/impeccable/scripts/detector/rules/checks.mjs index 045065c66..8a5654625 100644 --- a/plugin/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/plugin/skills/impeccable/scripts/detector/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/plugin/skills/impeccable/scripts/detector/shared/color.mjs b/plugin/skills/impeccable/scripts/detector/shared/color.mjs index 3d9a126bf..d2524ce52 100644 --- a/plugin/skills/impeccable/scripts/detector/shared/color.mjs +++ b/plugin/skills/impeccable/scripts/detector/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, };