Sync generated provider output

This commit is contained in:
github-actions[bot]
2026-08-11 18:59:38 +00:00
parent ac0416b655
commit ae388ac58f
45 changed files with 17550 additions and 10260 deletions
File diff suppressed because it is too large Load Diff
@@ -11,14 +11,21 @@ import {
isBrandFontOnOwnDomain,
} from '../shared/constants.mjs';
import {
CSS_NAMED_COLORS,
colorToHex,
compositeColorOver,
contrastRatio,
getHue,
hasChroma,
isNeutralColor,
isNoPaintColorValue,
oklchToRgb,
parseAnyColor,
parseColorMix,
parseGradientColors,
parseRgb,
relativeLuminance,
splitTopLevelCommas,
} from '../shared/color.mjs';
import { extractGoogleFontFamilies } from '../shared/fonts.mjs';
@@ -1731,7 +1738,7 @@ function readOwnBackgroundColor(el, computedStyle) {
// One element's background-color as the cascade walk sees it: computed style
// first (with the modern-color fallback), then, in static mode only,
// custom-prop resolution and the inline-shorthand peek. Shared by
// resolveBackground and resolveGradientStops so both walks read the same
// resolveBackgroundInfo and resolveGradientStops so both walks read the same
// surfaces.
function readCascadeBackgroundColor(current, style, customPropMap) {
let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
@@ -1755,7 +1762,19 @@ function readCascadeBackgroundColor(current, style, customPropMap) {
return bg;
}
function resolveBackground(el, win, customPropMap) {
// Walk up for the surface the element's text is painted on.
//
// Returns { color, unresolved }:
// • color set — the effective surface, overlays composited in.
// • unresolved: true — a layer on the way up paints a color this parser
// cannot read, so the surface is unknown. Callers
// must SKIP their contrast checks. Guessing white
// here is what flooded dark themes with false
// "on #ffffff" findings: one abstention costs a
// single finding, one wrong guess costs a hundred.
// • both null/false — no solid color, but a gradient or image is in
// play; callers fall back to its color stops.
function resolveBackgroundInfo(el, win, customPropMap) {
let current = el;
// Translucent layers (0.1 < a < 1) found on the way down to an opaque
// base. A browser composites these over the base; the old behavior
@@ -1783,59 +1802,81 @@ function resolveBackground(el, win, customPropMap) {
// body backgrounds.
// Real browsers serialize wide-gamut computed values as oklab()/oklch()
// (e.g. any color-mix() result), which plain parseRgb misses.
const bg = readCascadeBackgroundColor(current, style, customPropMap);
let bg = readCascadeBackgroundColor(current, style, customPropMap);
// `background-color: currentcolor` paints with the element's own text
// color — real paint whose value we know. Real browsers resolve the
// keyword before getComputedStyle output; jsdom hands it through
// verbatim, and without this substitution the layer would read as
// unparseable and force a needless abstention.
if ((!bg || bg.a < 0.1) && /^currentcolor$/i.test(String(style.backgroundColor || '').trim())) {
// The static cascade resolves var() text tokens before checks run, so
// style.color is normally already an rgb string here; parseColorResolved
// is defense in depth for any future caller that passes a live
// customPropMap (it matches the text-color path in checkElementColors
// and reduces to parseAnyColor when the map is null or absent).
bg = parseRgb(style.color) || parseColorResolved(style.color, customPropMap);
}
if (bg && bg.a > 0.1) {
if (bg.a >= 0.99) return flatten(bg);
if (bg.a >= 0.99) return { color: flatten(bg), unresolved: false };
overlays.push(bg);
} else if (!bg && !isNoPaintColorValue(style.backgroundColor)) {
// This layer names a color we could not parse (a color space we do not
// model, an unresolved var(), a syntax newer than the parser). It may
// well be opaque, which would make every ancestor below it invisible —
// so the surface is unknown and the walk stops here rather than
// reporting an ancestor the visitor never sees.
return { color: null, unresolved: true };
}
// No solid bg-color at this level, but this level paints an image. CSS
// stacks background-image layers first-on-top, so which layer leads
// decides what the visitor sees:
// • gradient on top — the gradient is the surface. Hand the caller a
// null color so it falls back to the gradient's own stops (body
// grounds, gradient buttons, hero sections).
// • url() on top — the surface is an image whose pixels this engine
// cannot read, and it may fully cover every layer and ancestor
// beneath it. Same contract as an unparseable color: abstain, so
// the gradient-stop fallback never measures a gradient the image
// hides (the shipped miss: `url(photo), linear-gradient(...)`
// reported low-contrast against the invisible gradient's stops).
if (hasGradientOrUrl) {
const layers = splitTopLevelCommas(bgImage);
const topPaintLayer = layers.find(
(layer) => /gradient\s*\(/i.test(layer) || /url\s*\(/i.test(layer),
);
const gradientOnTop = !!topPaintLayer
&& /gradient\s*\(/i.test(topPaintLayer)
&& !/^\s*url\s*\(/i.test(topPaintLayer);
if (!gradientOnTop) return { color: null, unresolved: true };
// Gradient on top of a url() layer: the image shows through wherever
// the gradient is not fully opaque, so a translucent wash like
// `linear-gradient(rgba(0,0,0,.2), rgba(0,0,0,.2)), url(photo)` paints
// a blend with pixels this engine cannot read. Only a gradient whose
// every readable stop is opaque provably covers the image; otherwise
// the surface is unknown — abstain rather than hand callers gradient
// stops (or a stop average) the visitor never sees unmixed.
const urlBeneath = layers.some(
(layer) => layer !== topPaintLayer && /url\s*\(/i.test(layer),
);
if (urlBeneath) {
const topStops = parseGradientColors(topPaintLayer);
const provablyOpaque = topStops.length > 0 && topStops.every((s) => (s.a ?? 1) >= 0.99);
if (!provablyOpaque) return { color: null, unresolved: true };
}
return { color: null, unresolved: false };
}
// No solid bg-color at this level. If THIS level has a gradient/url
// with no underlying solid color we can read:
// • on body/html: assume white. Body-level gradients are almost
// always decorative texture (paper grain, noise) on top of a
// solid bg-color the page set via `background: var(--paper)`
// shorthand — which jsdom can't decompose into bg-color. The
// downstream gradient-stops fallback path produces catastrophic
// false positives in this case (gradient noise stops have
// accidental browns/blacks that look like card backgrounds).
// • on other elements: bail to null and let the caller fall back
// to gradient stops (gradient buttons / hero sections are real
// bgs worth checking against).
// A gradient or image with no solid color under it, at any level
// including body/html, means the visible ground is that layer itself.
// Return null so the caller measures against the actual gradient stops,
// or skips when nothing is parseable — skipping beats a wrong ratio.
//
// Body/html used to assume white here, a guard written for jsdom, which
// never decomposed the `background:` shorthand and so could not see the
// solid paper color a texture gradient usually sits on. It turned every
// light-on-dark page into a wall of low-contrast false positives (a dark
// oklch body gradient produced ~120 "on #ffffff" findings on one site).
// Both engines can see shorthand solids now — the browser natively, the
// static cascade via expandStaticDeclaration + var() resolution — so a
// missing solid is real, and the old failure case cannot recur: opaque
// stops fully cover any hidden solid (they ARE the ground), alpha stops
// composite over the resolved base or the white canvas default, and
// unresolvable stops drop rather than guess.
if (hasGradientOrUrl) return null;
current = current.parentElement;
}
return flatten({ r: 255, g: 255, b: 255, a: 1 });
// Every layer up to the document root was genuinely see-through, so the
// browser paints its default canvas. This is the ONLY case that earns the
// white assumption.
return { color: flatten({ r: 255, g: 255, b: 255, a: 1 }), unresolved: false };
}
// parseGradientColors (shared) reads only the legacy serializations: rgb()
// and hex stops. Browsers keep modern-space stops in computed backgroundImage
// exactly as authored — `linear-gradient(oklch(7% 0.006 95), …)` stays oklch —
// which is what every token-driven page produces. Route those through
// parseAnyColor so a gradient ground is measurable rather than invisible.
function parseGradientColorsModern(bgImage) {
if (!bgImage || !/gradient/i.test(bgImage)) return [];
const colors = parseGradientColors(bgImage);
for (const m of bgImage.matchAll(/(?:oklch|oklab|hsla?|hwb)\(\s*[^()]*\)/gi)) {
const c = parseAnyColor(m[0]);
if (c) colors.push(c);
}
return colors;
function resolveBackground(el, win, customPropMap) {
return resolveBackgroundInfo(el, win, customPropMap).color;
}
// Walk parents looking for a gradient background and return its color stops.
@@ -1861,7 +1902,10 @@ function resolveGradientStops(el, win, customPropMap) {
if (bgImage && bgImage !== 'none' && /url\s*\(/i.test(bgImage)) return null;
let stops = null;
if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
const parsed = parseGradientColorsModern(bgImage);
// parseGradientColors (shared) reads modern-space stops too — oklch,
// color-mix and friends via balanced-paren token capture — so browser
// computed values that keep the authored syntax stay measurable.
const parsed = parseGradientColors(bgImage);
if (parsed.length > 0) stops = parsed;
}
if (!stops && !DETECTOR_IS_BROWSER) {
@@ -1869,7 +1913,7 @@ function resolveGradientStops(el, win, customPropMap) {
const rawStyle = current.getAttribute?.('style') || '';
const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
if (bgMatch && /gradient/i.test(bgMatch[1])) {
const parsed = parseGradientColorsModern(bgMatch[1]);
const parsed = parseGradientColors(bgMatch[1]);
if (parsed.length > 0) stops = parsed;
}
}
@@ -2115,13 +2159,19 @@ function checkElementColorsDOM(el) {
if (style.visibility === 'hidden' || effectiveOpacityDOM(el) <= 0.02) return [];
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
let effectiveBg = resolveBackground(el);
const bgInfo = resolveBackgroundInfo(el);
let effectiveBg = bgInfo.color;
// An unreadable surface anywhere up the chain: skip the gradient-stop
// fallback too, so nothing downstream measures against a ground we never
// resolved.
let surfaceUnresolved = bgInfo.unresolved;
let ownBg = readOwnBackgroundColor(el, style);
if (!ownBg || (ownBg.a ?? 1) <= 0.5) {
const pseudoSurface = readPseudoSurfaceDOM(el, rect);
if (pseudoSurface) {
ownBg = pseudoSurface;
effectiveBg = pseudoSurface;
surfaceUnresolved = false;
}
}
return checkColors({
@@ -2133,8 +2183,8 @@ function checkElementColorsDOM(el) {
// an oklch token near its own oklch background).
textColor: parseRgb(style.color) || parseAnyColor(style.color),
bgColor: ownBg,
effectiveBg,
effectiveBgStops: effectiveBg ? null : resolveGradientStops(el),
effectiveBg: surfaceUnresolved ? null : effectiveBg,
effectiveBgStops: surfaceUnresolved || effectiveBg ? null : resolveGradientStops(el),
fontSize: parseFloat(style.fontSize) || 16,
fontWeight: parseInt(style.fontWeight) || 400,
hasDirectText,
@@ -2284,283 +2334,6 @@ function resolveVarRefs(raw, customPropMap, depth = 0) {
});
}
// OKLCH → sRGB conversion (Björn Ottosson's matrices). L in 0..1 (or %),
// C in 0..~0.4 typical, H in degrees. Returns clamped {r,g,b,a:1} in 0..255.
// Needed because jsdom doesn't compute oklch() values — getComputedStyle
// returns the literal "oklch(...)" string. Without this, the entire
// Tailwind v4 color palette (which is OKLCH-based) is invisible to the
// detector's contrast / color checks.
function oklchToRgb(L, C, H) {
const hRad = (H * Math.PI) / 180;
return oklabToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
}
function oklabToRgb(L, a, b) {
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
const rLin = 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc;
const gLin = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc;
const bLin = -0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc;
const enc = (x) => {
const c = Math.max(0, Math.min(1, x));
return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
};
return {
r: Math.round(enc(rLin) * 255),
g: Math.round(enc(gLin) * 255),
b: Math.round(enc(bLin) * 255),
a: 1,
};
}
function hslToRgb(h, s, l) {
h = ((h % 360) + 360) % 360;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m0 = l - c / 2;
const [r, g, b] =
h < 60 ? [c, x, 0] :
h < 120 ? [x, c, 0] :
h < 180 ? [0, c, x] :
h < 240 ? [0, x, c] :
h < 300 ? [x, 0, c] : [c, 0, x];
return {
r: Math.round((r + m0) * 255),
g: Math.round((g + m0) * 255),
b: Math.round((b + m0) * 255),
a: 1,
};
}
function hwbToRgb(h, w, bl) {
if (w + bl >= 1) {
const g = Math.round((w / (w + bl)) * 255);
return { r: g, g, b: g, a: 1 };
}
const base = hslToRgb(h, 1, 0.5);
const mix = (c) => Math.round(((c / 255) * (1 - w - bl) + w) * 255);
return { r: mix(base.r), g: mix(base.g), b: mix(base.b), a: 1 };
}
// Common CSS named colors — the handful that actually show up in generated
// UIs, not the full 148-name spec list. Includes the achromatic names so a
// named gray parses (and correctly reads as no-chroma) instead of being
// treated as an unknown color.
const CSS_NAMED_COLORS = {
black: { r: 0, g: 0, b: 0 },
white: { r: 255, g: 255, b: 255 },
gray: { r: 128, g: 128, b: 128 },
grey: { r: 128, g: 128, b: 128 },
silver: { r: 192, g: 192, b: 192 },
dimgray: { r: 105, g: 105, b: 105 },
darkgray: { r: 169, g: 169, b: 169 },
lightgray: { r: 211, g: 211, b: 211 },
gainsboro: { r: 220, g: 220, b: 220 },
whitesmoke: { r: 245, g: 245, b: 245 },
red: { r: 255, g: 0, b: 0 },
crimson: { r: 220, g: 20, b: 60 },
tomato: { r: 255, g: 99, b: 71 },
coral: { r: 255, g: 127, b: 80 },
salmon: { r: 250, g: 128, b: 114 },
orange: { r: 255, g: 165, b: 0 },
gold: { r: 255, g: 215, b: 0 },
yellow: { r: 255, g: 255, b: 0 },
olive: { r: 128, g: 128, b: 0 },
lime: { r: 0, g: 255, b: 0 },
green: { r: 0, g: 128, b: 0 },
teal: { r: 0, g: 128, b: 128 },
turquoise: { r: 64, g: 224, b: 208 },
cyan: { r: 0, g: 255, b: 255 },
aqua: { r: 0, g: 255, b: 255 },
skyblue: { r: 135, g: 206, b: 235 },
dodgerblue: { r: 30, g: 144, b: 255 },
blue: { r: 0, g: 0, b: 255 },
navy: { r: 0, g: 0, b: 128 },
indigo: { r: 75, g: 0, b: 130 },
rebeccapurple: { r: 102, g: 51, b: 153 },
purple: { r: 128, g: 0, b: 128 },
violet: { r: 238, g: 130, b: 238 },
orchid: { r: 218, g: 112, b: 214 },
magenta: { r: 255, g: 0, b: 255 },
fuchsia: { r: 255, g: 0, b: 255 },
hotpink: { r: 255, g: 105, b: 180 },
pink: { r: 255, g: 192, b: 203 },
maroon: { r: 128, g: 0, b: 0 },
};
// Split a string on top-level commas (ignoring commas nested in parens).
function splitTopLevelCommas(str) {
const parts = [];
let depth = 0, start = 0;
for (let i = 0; i < str.length; i++) {
const ch = str[i];
if (ch === '(') depth++;
else if (ch === ')') depth = Math.max(0, depth - 1);
else if (ch === ',' && depth === 0) {
parts.push(str.slice(start, i).trim());
start = i + 1;
}
}
const tail = str.slice(start).trim();
if (tail) parts.push(tail);
return parts;
}
// Evaluate a CSS color-mix() expression to {r,g,b,a}. Returns null when
// the expression can't be resolved (unresolved var(), unknown colors).
//
// Mixing is done with premultiplied alpha in sRGB regardless of the
// declared interpolation space. That is exact for the dominant generated-UI
// pattern — `color-mix(in oklab, <color> N%, transparent)` — where the
// result is simply <color> at alpha N% in ANY rectangular space, and a
// close-enough approximation for opaque-opaque mixes (the detector only
// consumes these values for contrast/chroma thresholds, not for display).
function parseColorMix(str) {
const m = String(str).trim().match(/^color-mix\(/i);
if (!m) return null;
// Balanced-paren capture of the arguments.
let depth = 0, end = -1;
const open = str.indexOf('(');
for (let i = open; i < str.length; i++) {
if (str[i] === '(') depth++;
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
}
if (end < 0) return null;
const args = splitTopLevelCommas(str.slice(open + 1, end));
if (args.length !== 3 || !/^in\s/i.test(args[0])) return null;
const parseComponent = (component) => {
// Percentage may lead or trail the color per spec.
let pct = null;
let colorStr = component;
const trail = component.match(/\s+([\d.]+)%$/);
const lead = component.match(/^([\d.]+)%\s+/);
if (trail) { pct = parseFloat(trail[1]); colorStr = component.slice(0, trail.index).trim(); }
else if (lead) { pct = parseFloat(lead[1]); colorStr = component.slice(lead[0].length).trim(); }
let color;
if (/^transparent$/i.test(colorStr)) color = { r: 0, g: 0, b: 0, a: 0 };
else color = parseAnyColor(colorStr);
if (!color) return null;
return { color, pct };
};
const c1 = parseComponent(args[1]);
const c2 = parseComponent(args[2]);
if (!c1 || !c2) return null;
let p1 = c1.pct, p2 = c2.pct;
if (p1 == null && p2 == null) { p1 = 50; p2 = 50; }
else if (p1 == null) p1 = 100 - p2;
else if (p2 == null) p2 = 100 - p1;
const sum = p1 + p2;
if (sum <= 0) return null;
// Per spec: weights normalize to sum; when sum < 100 the result alpha is
// additionally scaled by sum/100.
const w1 = p1 / sum, w2 = p2 / sum;
const alphaScale = sum < 100 ? sum / 100 : 1;
const a1 = c1.color.a ?? 1, a2 = c2.color.a ?? 1;
const a = (a1 * w1 + a2 * w2) * alphaScale;
if (a <= 0) return { r: 0, g: 0, b: 0, a: 0 };
const mix = (ch) => Math.round((c1.color[ch] * a1 * w1 + c2.color[ch] * a2 * w2) / (a1 * w1 + a2 * w2));
return { r: mix('r'), g: mix('g'), b: mix('b'), a: Math.min(1, a) };
}
// Composite a translucent color over an opaque(ish) base (simple
// source-over in sRGB). Returns an opaque {r,g,b,a:1}.
function compositeColorOver(top, base) {
const a = top.a ?? 1;
return {
r: Math.round(top.r * a + base.r * (1 - a)),
g: Math.round(top.g * a + base.g * (1 - a)),
b: Math.round(top.b * a + base.b * (1 - a)),
a: 1,
};
}
// Extended color parser: rgb/rgba/hex/oklch/oklab/hsl/hwb/color-mix/common
// named colors. Returns null on no match. Use this when the input might be
// any CSS color form; use plain parseRgb when you only expect computed rgb()
// values from real browsers.
function parseAnyColor(s) {
if (!s || typeof s !== 'string') return null;
const str = s.trim();
if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
if (/^color-mix\(/i.test(str)) return parseColorMix(str);
let m;
m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+))?\s*\)/);
if (m) return { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: m[4] !== undefined ? +m[4] : 1 };
m = str.match(/^#([0-9a-f]{3,8})$/i);
if (m) {
const h = m[1];
if (h.length === 3 || h.length === 4) {
return {
r: parseInt(h[0] + h[0], 16),
g: parseInt(h[1] + h[1], 16),
b: parseInt(h[2] + h[2], 16),
a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
};
}
if (h.length === 6 || h.length === 8) {
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
};
}
}
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
// Match L (with optional %), then C and H separated permissively.
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const Lnum = parseFloat(m[1]);
const L = m[2] === '%' ? Lnum / 100 : Lnum;
const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
if (m[5] !== undefined) {
const alpha = parseFloat(m[5]);
rgb.a = m[6] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// OKLAB — a/b are signed axes; percentages map 100% → 0.4.
m = str.match(/oklab\(\s*([\d.]+)(%?)\s+(-?[\d.]+)(%?)\s+(-?[\d.]+)(%?)(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const L = m[2] === '%' ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
const a = m[4] === '%' ? parseFloat(m[3]) * 0.004 : parseFloat(m[3]);
const b = m[6] === '%' ? parseFloat(m[5]) * 0.004 : parseFloat(m[5]);
const rgb = oklabToRgb(L, a, b);
if (m[7] !== undefined) {
const alpha = parseFloat(m[7]);
rgb.a = m[8] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// HSL/HSLA — comma or space syntax, optional deg on hue.
m = str.match(/hsla?\(\s*(-?[\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%\s*[,\s]\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hslToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// HWB — hue whiteness% blackness%.
m = str.match(/hwb\(\s*(-?[\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hwbToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
const named = CSS_NAMED_COLORS[str.toLowerCase()];
if (named) return { ...named, a: 1 };
return null;
}
// Resolve var() refs in a color string (via customPropMap), then parse.
// Returns null on any failure. Used in jsdom-mode paths where
@@ -2923,15 +2696,24 @@ function checkElementGlowDOM(el) {
if (!boxShadow && !textShadow) return [];
// Use parent's background — glow radiates outward, so the surrounding context matters
// If resolveBackground returns null (gradient), try to infer from the gradient colors
let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el);
if (!parentBg) {
const parentBgInfo = resolveBackgroundInfo(el.parentElement || el);
// Unknown surface (an unreadable layer on the way up): skip only the
// gradient hunt below, which would walk PAST that layer and score the
// glow against a background the visitor never sees. checkGlow still runs
// with a null surface: the zero-offset chromatic halo tell holds on ANY
// background, and the static loop already passes the unresolved walk's
// null color straight through (detect-html.mjs uses resolveBackground).
let parentBg = parentBgInfo.color;
if (!parentBg && !parentBgInfo.unresolved) {
// Gradient background — sample its colors to determine if it's dark.
// Modern-syntax parsing matters here: body-level gradients now reach this
// fallback in browser mode, and their stops usually serialize as oklch.
// fallback in browser mode, and their stops usually serialize as oklch
// which the shared parseGradientColors reads via its color-function
// token capture.
let cur = el.parentElement;
while (cur && cur.nodeType === 1) {
const bgImage = getComputedStyle(cur).backgroundImage || '';
const gradColors = parseGradientColorsModern(bgImage);
const gradColors = parseGradientColors(bgImage);
if (gradColors.length > 0) {
// Average the gradient colors
const avg = { r: 0, g: 0, b: 0 };
@@ -2975,10 +2757,13 @@ function checkElementAIPaletteDOM(el) {
const hue = getHue(textColor);
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
if (isAIPalette) {
const parentBg = el.parentElement ? resolveBackground(el.parentElement) : null;
// Also check gradient parents
let effectiveBg = parentBg;
if (!effectiveBg) {
const parentBgInfo = el.parentElement
? resolveBackgroundInfo(el.parentElement)
: { color: null, unresolved: false };
// Unknown surface: leave effectiveBg null (no finding) rather than
// hunting gradient ancestors past a layer we could not read.
let effectiveBg = parentBgInfo.color;
if (!effectiveBg && !parentBgInfo.unresolved) {
let cur = el.parentElement;
while (cur && cur.nodeType === 1) {
const gi = getComputedStyle(cur).backgroundImage || '';
@@ -3784,7 +3569,8 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
const effectiveBg = resolveBackground(el, window, customPropMap);
const bgInfo = resolveBackgroundInfo(el, window, customPropMap);
const effectiveBg = bgInfo.color;
// jsdom returns literal "var(--X)" / "oklch(...)" for color, so plain
// parseRgb misses Tailwind-tokenized text colors. Resolve through the
// customPropMap first; fall back to parseRgb for vanilla rgb() pages.
@@ -3830,11 +3616,13 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
// element itself has no usable own background, that pseudo is the real
// surface for contrast purposes.
let finalEffectiveBg = effectiveBg;
let surfaceUnresolved = bgInfo.unresolved;
if ((!ownBg || (ownBg.a ?? 1) <= 0.5) && typeof window.getPseudoSurface === 'function') {
const pseudoSurface = window.getPseudoSurface(el);
if (pseudoSurface) {
ownBg = pseudoSurface;
finalEffectiveBg = pseudoSurface;
surfaceUnresolved = false;
}
}
@@ -3842,8 +3630,9 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
tag,
textColor,
bgColor: ownBg,
effectiveBg: finalEffectiveBg,
effectiveBgStops: finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
// Unknown surface: hand the checks nothing rather than a guess.
effectiveBg: surfaceUnresolved ? null : finalEffectiveBg,
effectiveBgStops: surfaceUnresolved || finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
fontSize: parseFloat(style.fontSize) || 16,
fontWeight: parseInt(style.fontWeight) || 400,
hasDirectText,
@@ -5663,6 +5452,7 @@ export {
checkHtmlPatterns,
readOwnBackgroundColor,
resolveBackground,
resolveBackgroundInfo,
resolveGradientStops,
parseRadiusToPx,
resolveBorderRadiusPx,
@@ -71,11 +71,43 @@ function contrastRatio(c1, c2) {
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
// The CSS color functions worth pulling out of a longer declaration. The set
// is deliberately closed: `linear-gradient(` and `url(` also look like
// `name(` and must not be read as colors.
const COLOR_FUNCTION_NAMES = new Set([
'rgb', 'rgba', 'hsl', 'hsla', 'hwb', 'oklch', 'oklab', 'lch', 'lab', 'color', 'color-mix',
]);
// Pull every color-function token out of a value, with balanced-paren capture
// so nested forms (`color-mix(in oklab, oklch(...) 20%, transparent)`) survive
// whole. Returns the raw substrings in source order.
function extractColorFunctionTokens(value) {
const str = String(value || '');
const tokens = [];
const re = /([a-z][a-z-]*)\(/gi;
let m;
while ((m = re.exec(str)) !== null) {
if (!COLOR_FUNCTION_NAMES.has(m[1].toLowerCase())) continue;
let depth = 0, end = -1;
for (let i = m.index + m[0].length - 1; i < str.length; i++) {
if (str[i] === '(') depth++;
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
}
if (end < 0) break;
tokens.push(str.slice(m.index, end + 1));
re.lastIndex = end + 1;
}
return tokens;
}
function parseGradientColors(bgImage) {
if (!bgImage || !bgImage.includes('gradient')) return [];
const colors = [];
for (const m of bgImage.matchAll(/rgba?\([^)]+\)/g)) {
const c = parseRgb(m[0]);
// Stops arrive in whatever syntax the author wrote and the browser kept.
// A dark ground painted as `linear-gradient(oklch(...), oklch(...))` used
// to read as a gradient with no stops at all.
for (const token of extractColorFunctionTokens(bgImage)) {
const c = parseAnyColor(token);
if (c) colors.push(c);
}
for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
@@ -112,13 +144,445 @@ function colorToHex(c) {
return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
}
// ─── Color-space conversions ────────────────────────────────────────────────
//
// Every function here lands on 8-bit sRGB, clamped to gamut. Chrome, Safari,
// and Firefox all keep the authored color space in getComputedStyle output
// (`oklch(0.84 0.19 80.46)`, `lch(20 5 60)`, `color(srgb 1.04 0.72 -0.21)`),
// so a detector that only reads rgb() is blind on any modern palette. The
// expected outputs are pinned in tests/detect-antipatterns.test.js against
// what Chrome itself paints for the same strings.
function clamp01(x) {
return Number.isFinite(x) ? Math.max(0, Math.min(1, x)) : 0;
}
// Linear-light sRGB channel to the encoded 0-255 value.
function encodeSrgbChannel(x) {
const c = clamp01(x);
return Math.round((c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055) * 255);
}
function decodeSrgbChannel(x) {
const c = Number.isFinite(x) ? x : 0;
const sign = c < 0 ? -1 : 1;
const abs = Math.abs(c);
return sign * (abs <= 0.04045 ? abs / 12.92 : Math.pow((abs + 0.055) / 1.055, 2.4));
}
function linearSrgbToColor(r, g, b, a = 1) {
return { r: encodeSrgbChannel(r), g: encodeSrgbChannel(g), b: encodeSrgbChannel(b), a };
}
// OKLab to sRGB (Björn Ottosson's matrices). L in 0..1, a/b are signed axes.
function oklabToRgb(L, a, b) {
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
return linearSrgbToColor(
4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc,
-1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc,
-0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc,
);
}
// OKLCH to sRGB. L in 0..1, C in 0..~0.4 typical, H in degrees. Chroma past
// the sRGB gamut clamps per channel rather than producing NaN.
function oklchToRgb(L, C, H) {
const hRad = (H * Math.PI) / 180;
return oklabToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
}
// CIE Lab to sRGB. CSS lab()/lch() use the D50 white point; the matrix below
// is the Bradford-adapted XYZ-D50 to linear-sRGB transform from CSS Color 4.
function labToRgb(L, a, b) {
const kappa = 24389 / 27, epsilon = 216 / 24389;
const fy = (L + 16) / 116, fx = fy + a / 500, fz = fy - b / 200;
const invert = (t) => (t * t * t > epsilon ? t * t * t : (116 * t - 16) / kappa);
const yr = L > kappa * epsilon ? Math.pow((L + 16) / 116, 3) : L / kappa;
const Xn = 0.3457 / 0.3585, Zn = (1 - 0.3457 - 0.3585) / 0.3585;
const x = invert(fx) * Xn, y = yr, z = invert(fz) * Zn;
return linearSrgbToColor(
3.1341359569958707 * x - 1.6173863321612538 * y - 0.4906619460083532 * z,
-0.9787955029120890 * x + 1.9162545672595240 * y + 0.0334427311613195 * z,
0.0719553798841168 * x - 0.2289768264158322 * y + 1.4053860583241250 * z,
);
}
function lchToRgb(L, C, H) {
const hRad = (H * Math.PI) / 180;
return labToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
}
// color(<space> c1 c2 c3) for the spaces that turn up in real stylesheets.
// `srgb` is what Chrome serializes most color-mix() results into, routinely
// with channels outside 0..1. Spaces we do not model return null so callers
// abstain instead of measuring against a color we invented.
function colorFunctionToRgb(space, c1, c2, c3) {
switch (space) {
case 'srgb':
return { r: Math.round(clamp01(c1) * 255), g: Math.round(clamp01(c2) * 255), b: Math.round(clamp01(c3) * 255), a: 1 };
case 'srgb-linear':
return linearSrgbToColor(c1, c2, c3);
case 'display-p3': {
const [R, G, B] = [decodeSrgbChannel(c1), decodeSrgbChannel(c2), decodeSrgbChannel(c3)];
return linearSrgbToColor(
1.2249401762805587 * R - 0.2249404646817506 * G + 0.0000002884022551 * B,
-0.0420569547096138 * R + 1.0420571661298634 * G - 0.0000002113202247 * B,
-0.0196375587040044 * R - 0.0786360772174755 * G + 1.0982736359214800 * B,
);
}
default:
return null;
}
}
function hslToRgb(h, s, l) {
h = ((h % 360) + 360) % 360;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m0 = l - c / 2;
const [r, g, b] =
h < 60 ? [c, x, 0] :
h < 120 ? [x, c, 0] :
h < 180 ? [0, c, x] :
h < 240 ? [0, x, c] :
h < 300 ? [x, 0, c] : [c, 0, x];
return {
r: Math.round((r + m0) * 255),
g: Math.round((g + m0) * 255),
b: Math.round((b + m0) * 255),
a: 1,
};
}
function hwbToRgb(h, w, bl) {
if (w + bl >= 1) {
const g = Math.round((w / (w + bl)) * 255);
return { r: g, g, b: g, a: 1 };
}
const base = hslToRgb(h, 1, 0.5);
const mix = (c) => Math.round(((c / 255) * (1 - w - bl) + w) * 255);
return { r: mix(base.r), g: mix(base.g), b: mix(base.b), a: 1 };
}
// Common CSS named colors — the handful that actually show up in generated
// UIs, not the full 148-name spec list. Includes the achromatic names so a
// named gray parses (and correctly reads as no-chroma) instead of being
// treated as an unknown color.
const CSS_NAMED_COLORS = {
black: { r: 0, g: 0, b: 0 },
white: { r: 255, g: 255, b: 255 },
gray: { r: 128, g: 128, b: 128 },
grey: { r: 128, g: 128, b: 128 },
silver: { r: 192, g: 192, b: 192 },
dimgray: { r: 105, g: 105, b: 105 },
darkgray: { r: 169, g: 169, b: 169 },
lightgray: { r: 211, g: 211, b: 211 },
gainsboro: { r: 220, g: 220, b: 220 },
whitesmoke: { r: 245, g: 245, b: 245 },
red: { r: 255, g: 0, b: 0 },
crimson: { r: 220, g: 20, b: 60 },
tomato: { r: 255, g: 99, b: 71 },
coral: { r: 255, g: 127, b: 80 },
salmon: { r: 250, g: 128, b: 114 },
orange: { r: 255, g: 165, b: 0 },
gold: { r: 255, g: 215, b: 0 },
yellow: { r: 255, g: 255, b: 0 },
olive: { r: 128, g: 128, b: 0 },
lime: { r: 0, g: 255, b: 0 },
green: { r: 0, g: 128, b: 0 },
teal: { r: 0, g: 128, b: 128 },
turquoise: { r: 64, g: 224, b: 208 },
cyan: { r: 0, g: 255, b: 255 },
aqua: { r: 0, g: 255, b: 255 },
skyblue: { r: 135, g: 206, b: 235 },
dodgerblue: { r: 30, g: 144, b: 255 },
blue: { r: 0, g: 0, b: 255 },
navy: { r: 0, g: 0, b: 128 },
indigo: { r: 75, g: 0, b: 130 },
rebeccapurple: { r: 102, g: 51, b: 153 },
purple: { r: 128, g: 0, b: 128 },
violet: { r: 238, g: 130, b: 238 },
orchid: { r: 218, g: 112, b: 214 },
magenta: { r: 255, g: 0, b: 255 },
fuchsia: { r: 255, g: 0, b: 255 },
hotpink: { r: 255, g: 105, b: 180 },
pink: { r: 255, g: 192, b: 203 },
maroon: { r: 128, g: 0, b: 0 },
};
// Split a string on top-level commas (ignoring commas nested in parens).
function splitTopLevelCommas(str) {
const parts = [];
let depth = 0, start = 0;
for (let i = 0; i < str.length; i++) {
const ch = str[i];
if (ch === '(') depth++;
else if (ch === ')') depth = Math.max(0, depth - 1);
else if (ch === ',' && depth === 0) {
parts.push(str.slice(start, i).trim());
start = i + 1;
}
}
const tail = str.slice(start).trim();
if (tail) parts.push(tail);
return parts;
}
// Evaluate a CSS color-mix() expression to {r,g,b,a}. Returns null when
// the expression can't be resolved (unresolved var(), unknown colors).
//
// Mixing is done with premultiplied alpha in sRGB regardless of the
// declared interpolation space. That is exact for the dominant generated-UI
// pattern — `color-mix(in oklab, <color> N%, transparent)` — where the
// result is simply <color> at alpha N% in ANY rectangular space, and a
// close-enough approximation for opaque-opaque mixes (the detector only
// consumes these values for contrast/chroma thresholds, not for display).
function parseColorMix(str) {
const m = String(str).trim().match(/^color-mix\(/i);
if (!m) return null;
// Balanced-paren capture of the arguments.
let depth = 0, end = -1;
const open = str.indexOf('(');
for (let i = open; i < str.length; i++) {
if (str[i] === '(') depth++;
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
}
if (end < 0) return null;
const args = splitTopLevelCommas(str.slice(open + 1, end));
if (args.length !== 3 || !/^in\s/i.test(args[0])) return null;
const parseComponent = (component) => {
// Percentage may lead or trail the color per spec.
let pct = null;
let colorStr = component;
const trail = component.match(/\s+([\d.]+)%$/);
const lead = component.match(/^([\d.]+)%\s+/);
if (trail) { pct = parseFloat(trail[1]); colorStr = component.slice(0, trail.index).trim(); }
else if (lead) { pct = parseFloat(lead[1]); colorStr = component.slice(lead[0].length).trim(); }
let color;
if (/^transparent$/i.test(colorStr)) color = { r: 0, g: 0, b: 0, a: 0 };
else color = parseAnyColor(colorStr);
if (!color) return null;
return { color, pct };
};
const c1 = parseComponent(args[1]);
const c2 = parseComponent(args[2]);
if (!c1 || !c2) return null;
let p1 = c1.pct, p2 = c2.pct;
if (p1 == null && p2 == null) { p1 = 50; p2 = 50; }
else if (p1 == null) p1 = 100 - p2;
else if (p2 == null) p2 = 100 - p1;
const sum = p1 + p2;
if (sum <= 0) return null;
// Per spec: weights normalize to sum; when sum < 100 the result alpha is
// additionally scaled by sum/100.
const w1 = p1 / sum, w2 = p2 / sum;
const alphaScale = sum < 100 ? sum / 100 : 1;
const a1 = c1.color.a ?? 1, a2 = c2.color.a ?? 1;
const a = (a1 * w1 + a2 * w2) * alphaScale;
if (a <= 0) return { r: 0, g: 0, b: 0, a: 0 };
const mix = (ch) => Math.round((c1.color[ch] * a1 * w1 + c2.color[ch] * a2 * w2) / (a1 * w1 + a2 * w2));
return { r: mix('r'), g: mix('g'), b: mix('b'), a: Math.min(1, a) };
}
// Composite a translucent color over an opaque(ish) base (simple
// source-over in sRGB). Returns an opaque {r,g,b,a:1}.
function compositeColorOver(top, base) {
const a = top.a ?? 1;
return {
r: Math.round(top.r * a + base.r * (1 - a)),
g: Math.round(top.g * a + base.g * (1 - a)),
b: Math.round(top.b * a + base.b * (1 - a)),
a: 1,
};
}
// A color() / lab() / lch() component: a bare number, a percentage against
// `scale`, or the `none` keyword (which resolves to zero for our purposes).
function parseColorComponent(token, scale = 1) {
if (token == null) return null;
const t = String(token).trim();
if (/^none$/i.test(t)) return 0;
const num = parseFloat(t);
if (!Number.isFinite(num)) return null;
return t.endsWith('%') ? (num / 100) * scale : num;
}
function parseAlphaToken(token) {
if (token == null) return 1;
const t = String(token).trim();
if (/^none$/i.test(t)) return 1;
const num = parseFloat(t);
if (!Number.isFinite(num)) return 1;
return t.endsWith('%') ? num / 100 : num;
}
// Extended color parser: rgb/rgba/hex/oklch/oklab/lch/lab/hsl/hwb/color()/
// color-mix/common named colors. Returns null on no match. Use this when the
// input might be any CSS color form; use plain parseRgb when you only expect
// computed rgb() values from real browsers.
function parseAnyColor(s) {
if (!s || typeof s !== 'string') return null;
const str = s.trim();
if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
if (/^color-mix\(/i.test(str)) return parseColorMix(str);
let m;
m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/);
if (m) {
const c = { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: 1 };
if (m[4] !== undefined) c.a = m[5] === '%' ? parseFloat(m[4]) / 100 : +m[4];
return c;
}
m = str.match(/^#([0-9a-f]{3,8})$/i);
if (m) {
const h = m[1];
if (h.length === 3 || h.length === 4) {
return {
r: parseInt(h[0] + h[0], 16),
g: parseInt(h[1] + h[1], 16),
b: parseInt(h[2] + h[2], 16),
a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
};
}
if (h.length === 6 || h.length === 8) {
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
};
}
}
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
// Match L (with optional %), then C and H separated permissively.
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const Lnum = parseFloat(m[1]);
const L = m[2] === '%' ? Lnum / 100 : Lnum;
const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
if (m[5] !== undefined) {
const alpha = parseFloat(m[5]);
rgb.a = m[6] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// OKLAB — a/b are signed axes; percentages map 100% → 0.4.
m = str.match(/oklab\(\s*([\d.]+)(%?)\s+(-?[\d.]+)(%?)\s+(-?[\d.]+)(%?)(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const L = m[2] === '%' ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
const a = m[4] === '%' ? parseFloat(m[3]) * 0.004 : parseFloat(m[3]);
const b = m[6] === '%' ? parseFloat(m[5]) * 0.004 : parseFloat(m[5]);
const rgb = oklabToRgb(L, a, b);
if (m[7] !== undefined) {
const alpha = parseFloat(m[7]);
rgb.a = m[8] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// LCH / LAB — CIE, D50 white point. Chrome serializes lch(20% 5 60) as
// `lch(20 5 60)`, so L arrives with or without its percent sign. In both
// spaces L runs 0..100 and 100% means 100.
m = str.match(/^lch\(\s*([\d.]+%?|none)\s+([\d.]+%?|none)\s+(-?[\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
if (m) {
const L = parseColorComponent(m[1], 100);
const C = parseColorComponent(m[2], 150);
const H = parseFloat(m[3]);
if (L == null || C == null || !Number.isFinite(H)) return null;
const rgb = lchToRgb(L, C, H);
rgb.a = parseAlphaToken(m[4]);
return rgb;
}
m = str.match(/^lab\(\s*([\d.]+%?|none)\s+(-?[\d.]+%?|none)\s+(-?[\d.]+%?|none)(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
if (m) {
const L = parseColorComponent(m[1], 100);
const a = parseColorComponent(m[2], 125);
const b = parseColorComponent(m[3], 125);
if (L == null || a == null || b == null) return null;
const rgb = labToRgb(L, a, b);
rgb.a = parseAlphaToken(m[4]);
return rgb;
}
// color(<space> c1 c2 c3 [/ alpha]) — what Chrome hands back for most
// color-mix() results and for any wide-gamut color an author wrote.
m = str.match(/^color\(\s*([a-z0-9-]+)\s+(-?[\d.eE+-]+%?|none)\s+(-?[\d.eE+-]+%?|none)\s+(-?[\d.eE+-]+%?|none)(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
if (m) {
const c1 = parseColorComponent(m[2]);
const c2 = parseColorComponent(m[3]);
const c3 = parseColorComponent(m[4]);
if (c1 == null || c2 == null || c3 == null) return null;
const rgb = colorFunctionToRgb(m[1].toLowerCase(), c1, c2, c3);
if (!rgb) return null;
rgb.a = parseAlphaToken(m[5]);
return rgb;
}
// HSL/HSLA — comma or space syntax, optional deg on hue.
m = str.match(/hsla?\(\s*(-?[\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%\s*[,\s]\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hslToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// HWB — hue whiteness% blackness%.
m = str.match(/hwb\(\s*(-?[\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hwbToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
const named = CSS_NAMED_COLORS[str.toLowerCase()];
if (named) return { ...named, a: 1 };
return null;
}
// True when a computed background-color string names no paint at all. Used to
// tell "this layer is see-through" (walk on to the ancestor) apart from "this
// layer has a color we could not read" (stop and abstain).
//
// `inherit` belongs here even though it is not literally see-through: it means
// "paint with the parent's background-color", and walking on to the parent IS
// that resolution. Real browsers resolve the keyword before getComputedStyle
// output; only jsdom's partial cascade hands it through verbatim, and treating
// it as unreadable would make the walk abstain on a surface it can know.
// (`currentcolor` is NOT here — it is real paint in the element's own text
// color; resolveBackgroundInfo substitutes the computed color for it.)
function isNoPaintColorValue(value) {
const v = String(value || '').trim().toLowerCase();
if (!v) return true;
return v === 'transparent' || v === 'none' || v === 'initial' || v === 'inherit' || v === 'unset' || v === 'revert' || v === 'revert-layer';
}
export {
isNeutralColor,
parseRgb,
relativeLuminance,
contrastRatio,
parseGradientColors,
extractColorFunctionTokens,
hasChroma,
getHue,
colorToHex,
oklabToRgb,
oklchToRgb,
labToRgb,
lchToRgb,
colorFunctionToRgb,
hslToRgb,
hwbToRgb,
CSS_NAMED_COLORS,
splitTopLevelCommas,
parseColorMix,
parseAnyColor,
compositeColorOver,
isNoPaintColorValue,
};
File diff suppressed because it is too large Load Diff
@@ -11,14 +11,21 @@ import {
isBrandFontOnOwnDomain,
} from '../shared/constants.mjs';
import {
CSS_NAMED_COLORS,
colorToHex,
compositeColorOver,
contrastRatio,
getHue,
hasChroma,
isNeutralColor,
isNoPaintColorValue,
oklchToRgb,
parseAnyColor,
parseColorMix,
parseGradientColors,
parseRgb,
relativeLuminance,
splitTopLevelCommas,
} from '../shared/color.mjs';
import { extractGoogleFontFamilies } from '../shared/fonts.mjs';
@@ -1731,7 +1738,7 @@ function readOwnBackgroundColor(el, computedStyle) {
// One element's background-color as the cascade walk sees it: computed style
// first (with the modern-color fallback), then, in static mode only,
// custom-prop resolution and the inline-shorthand peek. Shared by
// resolveBackground and resolveGradientStops so both walks read the same
// resolveBackgroundInfo and resolveGradientStops so both walks read the same
// surfaces.
function readCascadeBackgroundColor(current, style, customPropMap) {
let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
@@ -1755,7 +1762,19 @@ function readCascadeBackgroundColor(current, style, customPropMap) {
return bg;
}
function resolveBackground(el, win, customPropMap) {
// Walk up for the surface the element's text is painted on.
//
// Returns { color, unresolved }:
// • color set — the effective surface, overlays composited in.
// • unresolved: true — a layer on the way up paints a color this parser
// cannot read, so the surface is unknown. Callers
// must SKIP their contrast checks. Guessing white
// here is what flooded dark themes with false
// "on #ffffff" findings: one abstention costs a
// single finding, one wrong guess costs a hundred.
// • both null/false — no solid color, but a gradient or image is in
// play; callers fall back to its color stops.
function resolveBackgroundInfo(el, win, customPropMap) {
let current = el;
// Translucent layers (0.1 < a < 1) found on the way down to an opaque
// base. A browser composites these over the base; the old behavior
@@ -1783,59 +1802,81 @@ function resolveBackground(el, win, customPropMap) {
// body backgrounds.
// Real browsers serialize wide-gamut computed values as oklab()/oklch()
// (e.g. any color-mix() result), which plain parseRgb misses.
const bg = readCascadeBackgroundColor(current, style, customPropMap);
let bg = readCascadeBackgroundColor(current, style, customPropMap);
// `background-color: currentcolor` paints with the element's own text
// color — real paint whose value we know. Real browsers resolve the
// keyword before getComputedStyle output; jsdom hands it through
// verbatim, and without this substitution the layer would read as
// unparseable and force a needless abstention.
if ((!bg || bg.a < 0.1) && /^currentcolor$/i.test(String(style.backgroundColor || '').trim())) {
// The static cascade resolves var() text tokens before checks run, so
// style.color is normally already an rgb string here; parseColorResolved
// is defense in depth for any future caller that passes a live
// customPropMap (it matches the text-color path in checkElementColors
// and reduces to parseAnyColor when the map is null or absent).
bg = parseRgb(style.color) || parseColorResolved(style.color, customPropMap);
}
if (bg && bg.a > 0.1) {
if (bg.a >= 0.99) return flatten(bg);
if (bg.a >= 0.99) return { color: flatten(bg), unresolved: false };
overlays.push(bg);
} else if (!bg && !isNoPaintColorValue(style.backgroundColor)) {
// This layer names a color we could not parse (a color space we do not
// model, an unresolved var(), a syntax newer than the parser). It may
// well be opaque, which would make every ancestor below it invisible —
// so the surface is unknown and the walk stops here rather than
// reporting an ancestor the visitor never sees.
return { color: null, unresolved: true };
}
// No solid bg-color at this level, but this level paints an image. CSS
// stacks background-image layers first-on-top, so which layer leads
// decides what the visitor sees:
// • gradient on top — the gradient is the surface. Hand the caller a
// null color so it falls back to the gradient's own stops (body
// grounds, gradient buttons, hero sections).
// • url() on top — the surface is an image whose pixels this engine
// cannot read, and it may fully cover every layer and ancestor
// beneath it. Same contract as an unparseable color: abstain, so
// the gradient-stop fallback never measures a gradient the image
// hides (the shipped miss: `url(photo), linear-gradient(...)`
// reported low-contrast against the invisible gradient's stops).
if (hasGradientOrUrl) {
const layers = splitTopLevelCommas(bgImage);
const topPaintLayer = layers.find(
(layer) => /gradient\s*\(/i.test(layer) || /url\s*\(/i.test(layer),
);
const gradientOnTop = !!topPaintLayer
&& /gradient\s*\(/i.test(topPaintLayer)
&& !/^\s*url\s*\(/i.test(topPaintLayer);
if (!gradientOnTop) return { color: null, unresolved: true };
// Gradient on top of a url() layer: the image shows through wherever
// the gradient is not fully opaque, so a translucent wash like
// `linear-gradient(rgba(0,0,0,.2), rgba(0,0,0,.2)), url(photo)` paints
// a blend with pixels this engine cannot read. Only a gradient whose
// every readable stop is opaque provably covers the image; otherwise
// the surface is unknown — abstain rather than hand callers gradient
// stops (or a stop average) the visitor never sees unmixed.
const urlBeneath = layers.some(
(layer) => layer !== topPaintLayer && /url\s*\(/i.test(layer),
);
if (urlBeneath) {
const topStops = parseGradientColors(topPaintLayer);
const provablyOpaque = topStops.length > 0 && topStops.every((s) => (s.a ?? 1) >= 0.99);
if (!provablyOpaque) return { color: null, unresolved: true };
}
return { color: null, unresolved: false };
}
// No solid bg-color at this level. If THIS level has a gradient/url
// with no underlying solid color we can read:
// • on body/html: assume white. Body-level gradients are almost
// always decorative texture (paper grain, noise) on top of a
// solid bg-color the page set via `background: var(--paper)`
// shorthand — which jsdom can't decompose into bg-color. The
// downstream gradient-stops fallback path produces catastrophic
// false positives in this case (gradient noise stops have
// accidental browns/blacks that look like card backgrounds).
// • on other elements: bail to null and let the caller fall back
// to gradient stops (gradient buttons / hero sections are real
// bgs worth checking against).
// A gradient or image with no solid color under it, at any level
// including body/html, means the visible ground is that layer itself.
// Return null so the caller measures against the actual gradient stops,
// or skips when nothing is parseable — skipping beats a wrong ratio.
//
// Body/html used to assume white here, a guard written for jsdom, which
// never decomposed the `background:` shorthand and so could not see the
// solid paper color a texture gradient usually sits on. It turned every
// light-on-dark page into a wall of low-contrast false positives (a dark
// oklch body gradient produced ~120 "on #ffffff" findings on one site).
// Both engines can see shorthand solids now — the browser natively, the
// static cascade via expandStaticDeclaration + var() resolution — so a
// missing solid is real, and the old failure case cannot recur: opaque
// stops fully cover any hidden solid (they ARE the ground), alpha stops
// composite over the resolved base or the white canvas default, and
// unresolvable stops drop rather than guess.
if (hasGradientOrUrl) return null;
current = current.parentElement;
}
return flatten({ r: 255, g: 255, b: 255, a: 1 });
// Every layer up to the document root was genuinely see-through, so the
// browser paints its default canvas. This is the ONLY case that earns the
// white assumption.
return { color: flatten({ r: 255, g: 255, b: 255, a: 1 }), unresolved: false };
}
// parseGradientColors (shared) reads only the legacy serializations: rgb()
// and hex stops. Browsers keep modern-space stops in computed backgroundImage
// exactly as authored — `linear-gradient(oklch(7% 0.006 95), …)` stays oklch —
// which is what every token-driven page produces. Route those through
// parseAnyColor so a gradient ground is measurable rather than invisible.
function parseGradientColorsModern(bgImage) {
if (!bgImage || !/gradient/i.test(bgImage)) return [];
const colors = parseGradientColors(bgImage);
for (const m of bgImage.matchAll(/(?:oklch|oklab|hsla?|hwb)\(\s*[^()]*\)/gi)) {
const c = parseAnyColor(m[0]);
if (c) colors.push(c);
}
return colors;
function resolveBackground(el, win, customPropMap) {
return resolveBackgroundInfo(el, win, customPropMap).color;
}
// Walk parents looking for a gradient background and return its color stops.
@@ -1861,7 +1902,10 @@ function resolveGradientStops(el, win, customPropMap) {
if (bgImage && bgImage !== 'none' && /url\s*\(/i.test(bgImage)) return null;
let stops = null;
if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
const parsed = parseGradientColorsModern(bgImage);
// parseGradientColors (shared) reads modern-space stops too — oklch,
// color-mix and friends via balanced-paren token capture — so browser
// computed values that keep the authored syntax stay measurable.
const parsed = parseGradientColors(bgImage);
if (parsed.length > 0) stops = parsed;
}
if (!stops && !DETECTOR_IS_BROWSER) {
@@ -1869,7 +1913,7 @@ function resolveGradientStops(el, win, customPropMap) {
const rawStyle = current.getAttribute?.('style') || '';
const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
if (bgMatch && /gradient/i.test(bgMatch[1])) {
const parsed = parseGradientColorsModern(bgMatch[1]);
const parsed = parseGradientColors(bgMatch[1]);
if (parsed.length > 0) stops = parsed;
}
}
@@ -2115,13 +2159,19 @@ function checkElementColorsDOM(el) {
if (style.visibility === 'hidden' || effectiveOpacityDOM(el) <= 0.02) return [];
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
let effectiveBg = resolveBackground(el);
const bgInfo = resolveBackgroundInfo(el);
let effectiveBg = bgInfo.color;
// An unreadable surface anywhere up the chain: skip the gradient-stop
// fallback too, so nothing downstream measures against a ground we never
// resolved.
let surfaceUnresolved = bgInfo.unresolved;
let ownBg = readOwnBackgroundColor(el, style);
if (!ownBg || (ownBg.a ?? 1) <= 0.5) {
const pseudoSurface = readPseudoSurfaceDOM(el, rect);
if (pseudoSurface) {
ownBg = pseudoSurface;
effectiveBg = pseudoSurface;
surfaceUnresolved = false;
}
}
return checkColors({
@@ -2133,8 +2183,8 @@ function checkElementColorsDOM(el) {
// an oklch token near its own oklch background).
textColor: parseRgb(style.color) || parseAnyColor(style.color),
bgColor: ownBg,
effectiveBg,
effectiveBgStops: effectiveBg ? null : resolveGradientStops(el),
effectiveBg: surfaceUnresolved ? null : effectiveBg,
effectiveBgStops: surfaceUnresolved || effectiveBg ? null : resolveGradientStops(el),
fontSize: parseFloat(style.fontSize) || 16,
fontWeight: parseInt(style.fontWeight) || 400,
hasDirectText,
@@ -2284,283 +2334,6 @@ function resolveVarRefs(raw, customPropMap, depth = 0) {
});
}
// OKLCH → sRGB conversion (Björn Ottosson's matrices). L in 0..1 (or %),
// C in 0..~0.4 typical, H in degrees. Returns clamped {r,g,b,a:1} in 0..255.
// Needed because jsdom doesn't compute oklch() values — getComputedStyle
// returns the literal "oklch(...)" string. Without this, the entire
// Tailwind v4 color palette (which is OKLCH-based) is invisible to the
// detector's contrast / color checks.
function oklchToRgb(L, C, H) {
const hRad = (H * Math.PI) / 180;
return oklabToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
}
function oklabToRgb(L, a, b) {
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
const rLin = 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc;
const gLin = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc;
const bLin = -0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc;
const enc = (x) => {
const c = Math.max(0, Math.min(1, x));
return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
};
return {
r: Math.round(enc(rLin) * 255),
g: Math.round(enc(gLin) * 255),
b: Math.round(enc(bLin) * 255),
a: 1,
};
}
function hslToRgb(h, s, l) {
h = ((h % 360) + 360) % 360;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m0 = l - c / 2;
const [r, g, b] =
h < 60 ? [c, x, 0] :
h < 120 ? [x, c, 0] :
h < 180 ? [0, c, x] :
h < 240 ? [0, x, c] :
h < 300 ? [x, 0, c] : [c, 0, x];
return {
r: Math.round((r + m0) * 255),
g: Math.round((g + m0) * 255),
b: Math.round((b + m0) * 255),
a: 1,
};
}
function hwbToRgb(h, w, bl) {
if (w + bl >= 1) {
const g = Math.round((w / (w + bl)) * 255);
return { r: g, g, b: g, a: 1 };
}
const base = hslToRgb(h, 1, 0.5);
const mix = (c) => Math.round(((c / 255) * (1 - w - bl) + w) * 255);
return { r: mix(base.r), g: mix(base.g), b: mix(base.b), a: 1 };
}
// Common CSS named colors — the handful that actually show up in generated
// UIs, not the full 148-name spec list. Includes the achromatic names so a
// named gray parses (and correctly reads as no-chroma) instead of being
// treated as an unknown color.
const CSS_NAMED_COLORS = {
black: { r: 0, g: 0, b: 0 },
white: { r: 255, g: 255, b: 255 },
gray: { r: 128, g: 128, b: 128 },
grey: { r: 128, g: 128, b: 128 },
silver: { r: 192, g: 192, b: 192 },
dimgray: { r: 105, g: 105, b: 105 },
darkgray: { r: 169, g: 169, b: 169 },
lightgray: { r: 211, g: 211, b: 211 },
gainsboro: { r: 220, g: 220, b: 220 },
whitesmoke: { r: 245, g: 245, b: 245 },
red: { r: 255, g: 0, b: 0 },
crimson: { r: 220, g: 20, b: 60 },
tomato: { r: 255, g: 99, b: 71 },
coral: { r: 255, g: 127, b: 80 },
salmon: { r: 250, g: 128, b: 114 },
orange: { r: 255, g: 165, b: 0 },
gold: { r: 255, g: 215, b: 0 },
yellow: { r: 255, g: 255, b: 0 },
olive: { r: 128, g: 128, b: 0 },
lime: { r: 0, g: 255, b: 0 },
green: { r: 0, g: 128, b: 0 },
teal: { r: 0, g: 128, b: 128 },
turquoise: { r: 64, g: 224, b: 208 },
cyan: { r: 0, g: 255, b: 255 },
aqua: { r: 0, g: 255, b: 255 },
skyblue: { r: 135, g: 206, b: 235 },
dodgerblue: { r: 30, g: 144, b: 255 },
blue: { r: 0, g: 0, b: 255 },
navy: { r: 0, g: 0, b: 128 },
indigo: { r: 75, g: 0, b: 130 },
rebeccapurple: { r: 102, g: 51, b: 153 },
purple: { r: 128, g: 0, b: 128 },
violet: { r: 238, g: 130, b: 238 },
orchid: { r: 218, g: 112, b: 214 },
magenta: { r: 255, g: 0, b: 255 },
fuchsia: { r: 255, g: 0, b: 255 },
hotpink: { r: 255, g: 105, b: 180 },
pink: { r: 255, g: 192, b: 203 },
maroon: { r: 128, g: 0, b: 0 },
};
// Split a string on top-level commas (ignoring commas nested in parens).
function splitTopLevelCommas(str) {
const parts = [];
let depth = 0, start = 0;
for (let i = 0; i < str.length; i++) {
const ch = str[i];
if (ch === '(') depth++;
else if (ch === ')') depth = Math.max(0, depth - 1);
else if (ch === ',' && depth === 0) {
parts.push(str.slice(start, i).trim());
start = i + 1;
}
}
const tail = str.slice(start).trim();
if (tail) parts.push(tail);
return parts;
}
// Evaluate a CSS color-mix() expression to {r,g,b,a}. Returns null when
// the expression can't be resolved (unresolved var(), unknown colors).
//
// Mixing is done with premultiplied alpha in sRGB regardless of the
// declared interpolation space. That is exact for the dominant generated-UI
// pattern — `color-mix(in oklab, <color> N%, transparent)` — where the
// result is simply <color> at alpha N% in ANY rectangular space, and a
// close-enough approximation for opaque-opaque mixes (the detector only
// consumes these values for contrast/chroma thresholds, not for display).
function parseColorMix(str) {
const m = String(str).trim().match(/^color-mix\(/i);
if (!m) return null;
// Balanced-paren capture of the arguments.
let depth = 0, end = -1;
const open = str.indexOf('(');
for (let i = open; i < str.length; i++) {
if (str[i] === '(') depth++;
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
}
if (end < 0) return null;
const args = splitTopLevelCommas(str.slice(open + 1, end));
if (args.length !== 3 || !/^in\s/i.test(args[0])) return null;
const parseComponent = (component) => {
// Percentage may lead or trail the color per spec.
let pct = null;
let colorStr = component;
const trail = component.match(/\s+([\d.]+)%$/);
const lead = component.match(/^([\d.]+)%\s+/);
if (trail) { pct = parseFloat(trail[1]); colorStr = component.slice(0, trail.index).trim(); }
else if (lead) { pct = parseFloat(lead[1]); colorStr = component.slice(lead[0].length).trim(); }
let color;
if (/^transparent$/i.test(colorStr)) color = { r: 0, g: 0, b: 0, a: 0 };
else color = parseAnyColor(colorStr);
if (!color) return null;
return { color, pct };
};
const c1 = parseComponent(args[1]);
const c2 = parseComponent(args[2]);
if (!c1 || !c2) return null;
let p1 = c1.pct, p2 = c2.pct;
if (p1 == null && p2 == null) { p1 = 50; p2 = 50; }
else if (p1 == null) p1 = 100 - p2;
else if (p2 == null) p2 = 100 - p1;
const sum = p1 + p2;
if (sum <= 0) return null;
// Per spec: weights normalize to sum; when sum < 100 the result alpha is
// additionally scaled by sum/100.
const w1 = p1 / sum, w2 = p2 / sum;
const alphaScale = sum < 100 ? sum / 100 : 1;
const a1 = c1.color.a ?? 1, a2 = c2.color.a ?? 1;
const a = (a1 * w1 + a2 * w2) * alphaScale;
if (a <= 0) return { r: 0, g: 0, b: 0, a: 0 };
const mix = (ch) => Math.round((c1.color[ch] * a1 * w1 + c2.color[ch] * a2 * w2) / (a1 * w1 + a2 * w2));
return { r: mix('r'), g: mix('g'), b: mix('b'), a: Math.min(1, a) };
}
// Composite a translucent color over an opaque(ish) base (simple
// source-over in sRGB). Returns an opaque {r,g,b,a:1}.
function compositeColorOver(top, base) {
const a = top.a ?? 1;
return {
r: Math.round(top.r * a + base.r * (1 - a)),
g: Math.round(top.g * a + base.g * (1 - a)),
b: Math.round(top.b * a + base.b * (1 - a)),
a: 1,
};
}
// Extended color parser: rgb/rgba/hex/oklch/oklab/hsl/hwb/color-mix/common
// named colors. Returns null on no match. Use this when the input might be
// any CSS color form; use plain parseRgb when you only expect computed rgb()
// values from real browsers.
function parseAnyColor(s) {
if (!s || typeof s !== 'string') return null;
const str = s.trim();
if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
if (/^color-mix\(/i.test(str)) return parseColorMix(str);
let m;
m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+))?\s*\)/);
if (m) return { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: m[4] !== undefined ? +m[4] : 1 };
m = str.match(/^#([0-9a-f]{3,8})$/i);
if (m) {
const h = m[1];
if (h.length === 3 || h.length === 4) {
return {
r: parseInt(h[0] + h[0], 16),
g: parseInt(h[1] + h[1], 16),
b: parseInt(h[2] + h[2], 16),
a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
};
}
if (h.length === 6 || h.length === 8) {
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
};
}
}
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
// Match L (with optional %), then C and H separated permissively.
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const Lnum = parseFloat(m[1]);
const L = m[2] === '%' ? Lnum / 100 : Lnum;
const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
if (m[5] !== undefined) {
const alpha = parseFloat(m[5]);
rgb.a = m[6] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// OKLAB — a/b are signed axes; percentages map 100% → 0.4.
m = str.match(/oklab\(\s*([\d.]+)(%?)\s+(-?[\d.]+)(%?)\s+(-?[\d.]+)(%?)(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const L = m[2] === '%' ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
const a = m[4] === '%' ? parseFloat(m[3]) * 0.004 : parseFloat(m[3]);
const b = m[6] === '%' ? parseFloat(m[5]) * 0.004 : parseFloat(m[5]);
const rgb = oklabToRgb(L, a, b);
if (m[7] !== undefined) {
const alpha = parseFloat(m[7]);
rgb.a = m[8] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// HSL/HSLA — comma or space syntax, optional deg on hue.
m = str.match(/hsla?\(\s*(-?[\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%\s*[,\s]\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hslToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// HWB — hue whiteness% blackness%.
m = str.match(/hwb\(\s*(-?[\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hwbToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
const named = CSS_NAMED_COLORS[str.toLowerCase()];
if (named) return { ...named, a: 1 };
return null;
}
// Resolve var() refs in a color string (via customPropMap), then parse.
// Returns null on any failure. Used in jsdom-mode paths where
@@ -2923,15 +2696,24 @@ function checkElementGlowDOM(el) {
if (!boxShadow && !textShadow) return [];
// Use parent's background — glow radiates outward, so the surrounding context matters
// If resolveBackground returns null (gradient), try to infer from the gradient colors
let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el);
if (!parentBg) {
const parentBgInfo = resolveBackgroundInfo(el.parentElement || el);
// Unknown surface (an unreadable layer on the way up): skip only the
// gradient hunt below, which would walk PAST that layer and score the
// glow against a background the visitor never sees. checkGlow still runs
// with a null surface: the zero-offset chromatic halo tell holds on ANY
// background, and the static loop already passes the unresolved walk's
// null color straight through (detect-html.mjs uses resolveBackground).
let parentBg = parentBgInfo.color;
if (!parentBg && !parentBgInfo.unresolved) {
// Gradient background — sample its colors to determine if it's dark.
// Modern-syntax parsing matters here: body-level gradients now reach this
// fallback in browser mode, and their stops usually serialize as oklch.
// fallback in browser mode, and their stops usually serialize as oklch
// which the shared parseGradientColors reads via its color-function
// token capture.
let cur = el.parentElement;
while (cur && cur.nodeType === 1) {
const bgImage = getComputedStyle(cur).backgroundImage || '';
const gradColors = parseGradientColorsModern(bgImage);
const gradColors = parseGradientColors(bgImage);
if (gradColors.length > 0) {
// Average the gradient colors
const avg = { r: 0, g: 0, b: 0 };
@@ -2975,10 +2757,13 @@ function checkElementAIPaletteDOM(el) {
const hue = getHue(textColor);
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
if (isAIPalette) {
const parentBg = el.parentElement ? resolveBackground(el.parentElement) : null;
// Also check gradient parents
let effectiveBg = parentBg;
if (!effectiveBg) {
const parentBgInfo = el.parentElement
? resolveBackgroundInfo(el.parentElement)
: { color: null, unresolved: false };
// Unknown surface: leave effectiveBg null (no finding) rather than
// hunting gradient ancestors past a layer we could not read.
let effectiveBg = parentBgInfo.color;
if (!effectiveBg && !parentBgInfo.unresolved) {
let cur = el.parentElement;
while (cur && cur.nodeType === 1) {
const gi = getComputedStyle(cur).backgroundImage || '';
@@ -3784,7 +3569,8 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
const effectiveBg = resolveBackground(el, window, customPropMap);
const bgInfo = resolveBackgroundInfo(el, window, customPropMap);
const effectiveBg = bgInfo.color;
// jsdom returns literal "var(--X)" / "oklch(...)" for color, so plain
// parseRgb misses Tailwind-tokenized text colors. Resolve through the
// customPropMap first; fall back to parseRgb for vanilla rgb() pages.
@@ -3830,11 +3616,13 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
// element itself has no usable own background, that pseudo is the real
// surface for contrast purposes.
let finalEffectiveBg = effectiveBg;
let surfaceUnresolved = bgInfo.unresolved;
if ((!ownBg || (ownBg.a ?? 1) <= 0.5) && typeof window.getPseudoSurface === 'function') {
const pseudoSurface = window.getPseudoSurface(el);
if (pseudoSurface) {
ownBg = pseudoSurface;
finalEffectiveBg = pseudoSurface;
surfaceUnresolved = false;
}
}
@@ -3842,8 +3630,9 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
tag,
textColor,
bgColor: ownBg,
effectiveBg: finalEffectiveBg,
effectiveBgStops: finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
// Unknown surface: hand the checks nothing rather than a guess.
effectiveBg: surfaceUnresolved ? null : finalEffectiveBg,
effectiveBgStops: surfaceUnresolved || finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
fontSize: parseFloat(style.fontSize) || 16,
fontWeight: parseInt(style.fontWeight) || 400,
hasDirectText,
@@ -5663,6 +5452,7 @@ export {
checkHtmlPatterns,
readOwnBackgroundColor,
resolveBackground,
resolveBackgroundInfo,
resolveGradientStops,
parseRadiusToPx,
resolveBorderRadiusPx,
@@ -71,11 +71,43 @@ function contrastRatio(c1, c2) {
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
// The CSS color functions worth pulling out of a longer declaration. The set
// is deliberately closed: `linear-gradient(` and `url(` also look like
// `name(` and must not be read as colors.
const COLOR_FUNCTION_NAMES = new Set([
'rgb', 'rgba', 'hsl', 'hsla', 'hwb', 'oklch', 'oklab', 'lch', 'lab', 'color', 'color-mix',
]);
// Pull every color-function token out of a value, with balanced-paren capture
// so nested forms (`color-mix(in oklab, oklch(...) 20%, transparent)`) survive
// whole. Returns the raw substrings in source order.
function extractColorFunctionTokens(value) {
const str = String(value || '');
const tokens = [];
const re = /([a-z][a-z-]*)\(/gi;
let m;
while ((m = re.exec(str)) !== null) {
if (!COLOR_FUNCTION_NAMES.has(m[1].toLowerCase())) continue;
let depth = 0, end = -1;
for (let i = m.index + m[0].length - 1; i < str.length; i++) {
if (str[i] === '(') depth++;
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
}
if (end < 0) break;
tokens.push(str.slice(m.index, end + 1));
re.lastIndex = end + 1;
}
return tokens;
}
function parseGradientColors(bgImage) {
if (!bgImage || !bgImage.includes('gradient')) return [];
const colors = [];
for (const m of bgImage.matchAll(/rgba?\([^)]+\)/g)) {
const c = parseRgb(m[0]);
// Stops arrive in whatever syntax the author wrote and the browser kept.
// A dark ground painted as `linear-gradient(oklch(...), oklch(...))` used
// to read as a gradient with no stops at all.
for (const token of extractColorFunctionTokens(bgImage)) {
const c = parseAnyColor(token);
if (c) colors.push(c);
}
for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
@@ -112,13 +144,445 @@ function colorToHex(c) {
return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
}
// ─── Color-space conversions ────────────────────────────────────────────────
//
// Every function here lands on 8-bit sRGB, clamped to gamut. Chrome, Safari,
// and Firefox all keep the authored color space in getComputedStyle output
// (`oklch(0.84 0.19 80.46)`, `lch(20 5 60)`, `color(srgb 1.04 0.72 -0.21)`),
// so a detector that only reads rgb() is blind on any modern palette. The
// expected outputs are pinned in tests/detect-antipatterns.test.js against
// what Chrome itself paints for the same strings.
function clamp01(x) {
return Number.isFinite(x) ? Math.max(0, Math.min(1, x)) : 0;
}
// Linear-light sRGB channel to the encoded 0-255 value.
function encodeSrgbChannel(x) {
const c = clamp01(x);
return Math.round((c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055) * 255);
}
function decodeSrgbChannel(x) {
const c = Number.isFinite(x) ? x : 0;
const sign = c < 0 ? -1 : 1;
const abs = Math.abs(c);
return sign * (abs <= 0.04045 ? abs / 12.92 : Math.pow((abs + 0.055) / 1.055, 2.4));
}
function linearSrgbToColor(r, g, b, a = 1) {
return { r: encodeSrgbChannel(r), g: encodeSrgbChannel(g), b: encodeSrgbChannel(b), a };
}
// OKLab to sRGB (Björn Ottosson's matrices). L in 0..1, a/b are signed axes.
function oklabToRgb(L, a, b) {
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
return linearSrgbToColor(
4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc,
-1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc,
-0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc,
);
}
// OKLCH to sRGB. L in 0..1, C in 0..~0.4 typical, H in degrees. Chroma past
// the sRGB gamut clamps per channel rather than producing NaN.
function oklchToRgb(L, C, H) {
const hRad = (H * Math.PI) / 180;
return oklabToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
}
// CIE Lab to sRGB. CSS lab()/lch() use the D50 white point; the matrix below
// is the Bradford-adapted XYZ-D50 to linear-sRGB transform from CSS Color 4.
function labToRgb(L, a, b) {
const kappa = 24389 / 27, epsilon = 216 / 24389;
const fy = (L + 16) / 116, fx = fy + a / 500, fz = fy - b / 200;
const invert = (t) => (t * t * t > epsilon ? t * t * t : (116 * t - 16) / kappa);
const yr = L > kappa * epsilon ? Math.pow((L + 16) / 116, 3) : L / kappa;
const Xn = 0.3457 / 0.3585, Zn = (1 - 0.3457 - 0.3585) / 0.3585;
const x = invert(fx) * Xn, y = yr, z = invert(fz) * Zn;
return linearSrgbToColor(
3.1341359569958707 * x - 1.6173863321612538 * y - 0.4906619460083532 * z,
-0.9787955029120890 * x + 1.9162545672595240 * y + 0.0334427311613195 * z,
0.0719553798841168 * x - 0.2289768264158322 * y + 1.4053860583241250 * z,
);
}
function lchToRgb(L, C, H) {
const hRad = (H * Math.PI) / 180;
return labToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
}
// color(<space> c1 c2 c3) for the spaces that turn up in real stylesheets.
// `srgb` is what Chrome serializes most color-mix() results into, routinely
// with channels outside 0..1. Spaces we do not model return null so callers
// abstain instead of measuring against a color we invented.
function colorFunctionToRgb(space, c1, c2, c3) {
switch (space) {
case 'srgb':
return { r: Math.round(clamp01(c1) * 255), g: Math.round(clamp01(c2) * 255), b: Math.round(clamp01(c3) * 255), a: 1 };
case 'srgb-linear':
return linearSrgbToColor(c1, c2, c3);
case 'display-p3': {
const [R, G, B] = [decodeSrgbChannel(c1), decodeSrgbChannel(c2), decodeSrgbChannel(c3)];
return linearSrgbToColor(
1.2249401762805587 * R - 0.2249404646817506 * G + 0.0000002884022551 * B,
-0.0420569547096138 * R + 1.0420571661298634 * G - 0.0000002113202247 * B,
-0.0196375587040044 * R - 0.0786360772174755 * G + 1.0982736359214800 * B,
);
}
default:
return null;
}
}
function hslToRgb(h, s, l) {
h = ((h % 360) + 360) % 360;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m0 = l - c / 2;
const [r, g, b] =
h < 60 ? [c, x, 0] :
h < 120 ? [x, c, 0] :
h < 180 ? [0, c, x] :
h < 240 ? [0, x, c] :
h < 300 ? [x, 0, c] : [c, 0, x];
return {
r: Math.round((r + m0) * 255),
g: Math.round((g + m0) * 255),
b: Math.round((b + m0) * 255),
a: 1,
};
}
function hwbToRgb(h, w, bl) {
if (w + bl >= 1) {
const g = Math.round((w / (w + bl)) * 255);
return { r: g, g, b: g, a: 1 };
}
const base = hslToRgb(h, 1, 0.5);
const mix = (c) => Math.round(((c / 255) * (1 - w - bl) + w) * 255);
return { r: mix(base.r), g: mix(base.g), b: mix(base.b), a: 1 };
}
// Common CSS named colors — the handful that actually show up in generated
// UIs, not the full 148-name spec list. Includes the achromatic names so a
// named gray parses (and correctly reads as no-chroma) instead of being
// treated as an unknown color.
const CSS_NAMED_COLORS = {
black: { r: 0, g: 0, b: 0 },
white: { r: 255, g: 255, b: 255 },
gray: { r: 128, g: 128, b: 128 },
grey: { r: 128, g: 128, b: 128 },
silver: { r: 192, g: 192, b: 192 },
dimgray: { r: 105, g: 105, b: 105 },
darkgray: { r: 169, g: 169, b: 169 },
lightgray: { r: 211, g: 211, b: 211 },
gainsboro: { r: 220, g: 220, b: 220 },
whitesmoke: { r: 245, g: 245, b: 245 },
red: { r: 255, g: 0, b: 0 },
crimson: { r: 220, g: 20, b: 60 },
tomato: { r: 255, g: 99, b: 71 },
coral: { r: 255, g: 127, b: 80 },
salmon: { r: 250, g: 128, b: 114 },
orange: { r: 255, g: 165, b: 0 },
gold: { r: 255, g: 215, b: 0 },
yellow: { r: 255, g: 255, b: 0 },
olive: { r: 128, g: 128, b: 0 },
lime: { r: 0, g: 255, b: 0 },
green: { r: 0, g: 128, b: 0 },
teal: { r: 0, g: 128, b: 128 },
turquoise: { r: 64, g: 224, b: 208 },
cyan: { r: 0, g: 255, b: 255 },
aqua: { r: 0, g: 255, b: 255 },
skyblue: { r: 135, g: 206, b: 235 },
dodgerblue: { r: 30, g: 144, b: 255 },
blue: { r: 0, g: 0, b: 255 },
navy: { r: 0, g: 0, b: 128 },
indigo: { r: 75, g: 0, b: 130 },
rebeccapurple: { r: 102, g: 51, b: 153 },
purple: { r: 128, g: 0, b: 128 },
violet: { r: 238, g: 130, b: 238 },
orchid: { r: 218, g: 112, b: 214 },
magenta: { r: 255, g: 0, b: 255 },
fuchsia: { r: 255, g: 0, b: 255 },
hotpink: { r: 255, g: 105, b: 180 },
pink: { r: 255, g: 192, b: 203 },
maroon: { r: 128, g: 0, b: 0 },
};
// Split a string on top-level commas (ignoring commas nested in parens).
function splitTopLevelCommas(str) {
const parts = [];
let depth = 0, start = 0;
for (let i = 0; i < str.length; i++) {
const ch = str[i];
if (ch === '(') depth++;
else if (ch === ')') depth = Math.max(0, depth - 1);
else if (ch === ',' && depth === 0) {
parts.push(str.slice(start, i).trim());
start = i + 1;
}
}
const tail = str.slice(start).trim();
if (tail) parts.push(tail);
return parts;
}
// Evaluate a CSS color-mix() expression to {r,g,b,a}. Returns null when
// the expression can't be resolved (unresolved var(), unknown colors).
//
// Mixing is done with premultiplied alpha in sRGB regardless of the
// declared interpolation space. That is exact for the dominant generated-UI
// pattern — `color-mix(in oklab, <color> N%, transparent)` — where the
// result is simply <color> at alpha N% in ANY rectangular space, and a
// close-enough approximation for opaque-opaque mixes (the detector only
// consumes these values for contrast/chroma thresholds, not for display).
function parseColorMix(str) {
const m = String(str).trim().match(/^color-mix\(/i);
if (!m) return null;
// Balanced-paren capture of the arguments.
let depth = 0, end = -1;
const open = str.indexOf('(');
for (let i = open; i < str.length; i++) {
if (str[i] === '(') depth++;
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
}
if (end < 0) return null;
const args = splitTopLevelCommas(str.slice(open + 1, end));
if (args.length !== 3 || !/^in\s/i.test(args[0])) return null;
const parseComponent = (component) => {
// Percentage may lead or trail the color per spec.
let pct = null;
let colorStr = component;
const trail = component.match(/\s+([\d.]+)%$/);
const lead = component.match(/^([\d.]+)%\s+/);
if (trail) { pct = parseFloat(trail[1]); colorStr = component.slice(0, trail.index).trim(); }
else if (lead) { pct = parseFloat(lead[1]); colorStr = component.slice(lead[0].length).trim(); }
let color;
if (/^transparent$/i.test(colorStr)) color = { r: 0, g: 0, b: 0, a: 0 };
else color = parseAnyColor(colorStr);
if (!color) return null;
return { color, pct };
};
const c1 = parseComponent(args[1]);
const c2 = parseComponent(args[2]);
if (!c1 || !c2) return null;
let p1 = c1.pct, p2 = c2.pct;
if (p1 == null && p2 == null) { p1 = 50; p2 = 50; }
else if (p1 == null) p1 = 100 - p2;
else if (p2 == null) p2 = 100 - p1;
const sum = p1 + p2;
if (sum <= 0) return null;
// Per spec: weights normalize to sum; when sum < 100 the result alpha is
// additionally scaled by sum/100.
const w1 = p1 / sum, w2 = p2 / sum;
const alphaScale = sum < 100 ? sum / 100 : 1;
const a1 = c1.color.a ?? 1, a2 = c2.color.a ?? 1;
const a = (a1 * w1 + a2 * w2) * alphaScale;
if (a <= 0) return { r: 0, g: 0, b: 0, a: 0 };
const mix = (ch) => Math.round((c1.color[ch] * a1 * w1 + c2.color[ch] * a2 * w2) / (a1 * w1 + a2 * w2));
return { r: mix('r'), g: mix('g'), b: mix('b'), a: Math.min(1, a) };
}
// Composite a translucent color over an opaque(ish) base (simple
// source-over in sRGB). Returns an opaque {r,g,b,a:1}.
function compositeColorOver(top, base) {
const a = top.a ?? 1;
return {
r: Math.round(top.r * a + base.r * (1 - a)),
g: Math.round(top.g * a + base.g * (1 - a)),
b: Math.round(top.b * a + base.b * (1 - a)),
a: 1,
};
}
// A color() / lab() / lch() component: a bare number, a percentage against
// `scale`, or the `none` keyword (which resolves to zero for our purposes).
function parseColorComponent(token, scale = 1) {
if (token == null) return null;
const t = String(token).trim();
if (/^none$/i.test(t)) return 0;
const num = parseFloat(t);
if (!Number.isFinite(num)) return null;
return t.endsWith('%') ? (num / 100) * scale : num;
}
function parseAlphaToken(token) {
if (token == null) return 1;
const t = String(token).trim();
if (/^none$/i.test(t)) return 1;
const num = parseFloat(t);
if (!Number.isFinite(num)) return 1;
return t.endsWith('%') ? num / 100 : num;
}
// Extended color parser: rgb/rgba/hex/oklch/oklab/lch/lab/hsl/hwb/color()/
// color-mix/common named colors. Returns null on no match. Use this when the
// input might be any CSS color form; use plain parseRgb when you only expect
// computed rgb() values from real browsers.
function parseAnyColor(s) {
if (!s || typeof s !== 'string') return null;
const str = s.trim();
if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
if (/^color-mix\(/i.test(str)) return parseColorMix(str);
let m;
m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/);
if (m) {
const c = { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: 1 };
if (m[4] !== undefined) c.a = m[5] === '%' ? parseFloat(m[4]) / 100 : +m[4];
return c;
}
m = str.match(/^#([0-9a-f]{3,8})$/i);
if (m) {
const h = m[1];
if (h.length === 3 || h.length === 4) {
return {
r: parseInt(h[0] + h[0], 16),
g: parseInt(h[1] + h[1], 16),
b: parseInt(h[2] + h[2], 16),
a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
};
}
if (h.length === 6 || h.length === 8) {
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
};
}
}
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
// Match L (with optional %), then C and H separated permissively.
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const Lnum = parseFloat(m[1]);
const L = m[2] === '%' ? Lnum / 100 : Lnum;
const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
if (m[5] !== undefined) {
const alpha = parseFloat(m[5]);
rgb.a = m[6] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// OKLAB — a/b are signed axes; percentages map 100% → 0.4.
m = str.match(/oklab\(\s*([\d.]+)(%?)\s+(-?[\d.]+)(%?)\s+(-?[\d.]+)(%?)(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const L = m[2] === '%' ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
const a = m[4] === '%' ? parseFloat(m[3]) * 0.004 : parseFloat(m[3]);
const b = m[6] === '%' ? parseFloat(m[5]) * 0.004 : parseFloat(m[5]);
const rgb = oklabToRgb(L, a, b);
if (m[7] !== undefined) {
const alpha = parseFloat(m[7]);
rgb.a = m[8] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// LCH / LAB — CIE, D50 white point. Chrome serializes lch(20% 5 60) as
// `lch(20 5 60)`, so L arrives with or without its percent sign. In both
// spaces L runs 0..100 and 100% means 100.
m = str.match(/^lch\(\s*([\d.]+%?|none)\s+([\d.]+%?|none)\s+(-?[\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
if (m) {
const L = parseColorComponent(m[1], 100);
const C = parseColorComponent(m[2], 150);
const H = parseFloat(m[3]);
if (L == null || C == null || !Number.isFinite(H)) return null;
const rgb = lchToRgb(L, C, H);
rgb.a = parseAlphaToken(m[4]);
return rgb;
}
m = str.match(/^lab\(\s*([\d.]+%?|none)\s+(-?[\d.]+%?|none)\s+(-?[\d.]+%?|none)(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
if (m) {
const L = parseColorComponent(m[1], 100);
const a = parseColorComponent(m[2], 125);
const b = parseColorComponent(m[3], 125);
if (L == null || a == null || b == null) return null;
const rgb = labToRgb(L, a, b);
rgb.a = parseAlphaToken(m[4]);
return rgb;
}
// color(<space> c1 c2 c3 [/ alpha]) — what Chrome hands back for most
// color-mix() results and for any wide-gamut color an author wrote.
m = str.match(/^color\(\s*([a-z0-9-]+)\s+(-?[\d.eE+-]+%?|none)\s+(-?[\d.eE+-]+%?|none)\s+(-?[\d.eE+-]+%?|none)(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
if (m) {
const c1 = parseColorComponent(m[2]);
const c2 = parseColorComponent(m[3]);
const c3 = parseColorComponent(m[4]);
if (c1 == null || c2 == null || c3 == null) return null;
const rgb = colorFunctionToRgb(m[1].toLowerCase(), c1, c2, c3);
if (!rgb) return null;
rgb.a = parseAlphaToken(m[5]);
return rgb;
}
// HSL/HSLA — comma or space syntax, optional deg on hue.
m = str.match(/hsla?\(\s*(-?[\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%\s*[,\s]\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hslToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// HWB — hue whiteness% blackness%.
m = str.match(/hwb\(\s*(-?[\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hwbToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
const named = CSS_NAMED_COLORS[str.toLowerCase()];
if (named) return { ...named, a: 1 };
return null;
}
// True when a computed background-color string names no paint at all. Used to
// tell "this layer is see-through" (walk on to the ancestor) apart from "this
// layer has a color we could not read" (stop and abstain).
//
// `inherit` belongs here even though it is not literally see-through: it means
// "paint with the parent's background-color", and walking on to the parent IS
// that resolution. Real browsers resolve the keyword before getComputedStyle
// output; only jsdom's partial cascade hands it through verbatim, and treating
// it as unreadable would make the walk abstain on a surface it can know.
// (`currentcolor` is NOT here — it is real paint in the element's own text
// color; resolveBackgroundInfo substitutes the computed color for it.)
function isNoPaintColorValue(value) {
const v = String(value || '').trim().toLowerCase();
if (!v) return true;
return v === 'transparent' || v === 'none' || v === 'initial' || v === 'inherit' || v === 'unset' || v === 'revert' || v === 'revert-layer';
}
export {
isNeutralColor,
parseRgb,
relativeLuminance,
contrastRatio,
parseGradientColors,
extractColorFunctionTokens,
hasChroma,
getHue,
colorToHex,
oklabToRgb,
oklchToRgb,
labToRgb,
lchToRgb,
colorFunctionToRgb,
hslToRgb,
hwbToRgb,
CSS_NAMED_COLORS,
splitTopLevelCommas,
parseColorMix,
parseAnyColor,
compositeColorOver,
isNoPaintColorValue,
};
File diff suppressed because it is too large Load Diff
@@ -11,14 +11,21 @@ import {
isBrandFontOnOwnDomain,
} from '../shared/constants.mjs';
import {
CSS_NAMED_COLORS,
colorToHex,
compositeColorOver,
contrastRatio,
getHue,
hasChroma,
isNeutralColor,
isNoPaintColorValue,
oklchToRgb,
parseAnyColor,
parseColorMix,
parseGradientColors,
parseRgb,
relativeLuminance,
splitTopLevelCommas,
} from '../shared/color.mjs';
import { extractGoogleFontFamilies } from '../shared/fonts.mjs';
@@ -1731,7 +1738,7 @@ function readOwnBackgroundColor(el, computedStyle) {
// One element's background-color as the cascade walk sees it: computed style
// first (with the modern-color fallback), then, in static mode only,
// custom-prop resolution and the inline-shorthand peek. Shared by
// resolveBackground and resolveGradientStops so both walks read the same
// resolveBackgroundInfo and resolveGradientStops so both walks read the same
// surfaces.
function readCascadeBackgroundColor(current, style, customPropMap) {
let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
@@ -1755,7 +1762,19 @@ function readCascadeBackgroundColor(current, style, customPropMap) {
return bg;
}
function resolveBackground(el, win, customPropMap) {
// Walk up for the surface the element's text is painted on.
//
// Returns { color, unresolved }:
// • color set — the effective surface, overlays composited in.
// • unresolved: true — a layer on the way up paints a color this parser
// cannot read, so the surface is unknown. Callers
// must SKIP their contrast checks. Guessing white
// here is what flooded dark themes with false
// "on #ffffff" findings: one abstention costs a
// single finding, one wrong guess costs a hundred.
// • both null/false — no solid color, but a gradient or image is in
// play; callers fall back to its color stops.
function resolveBackgroundInfo(el, win, customPropMap) {
let current = el;
// Translucent layers (0.1 < a < 1) found on the way down to an opaque
// base. A browser composites these over the base; the old behavior
@@ -1783,59 +1802,81 @@ function resolveBackground(el, win, customPropMap) {
// body backgrounds.
// Real browsers serialize wide-gamut computed values as oklab()/oklch()
// (e.g. any color-mix() result), which plain parseRgb misses.
const bg = readCascadeBackgroundColor(current, style, customPropMap);
let bg = readCascadeBackgroundColor(current, style, customPropMap);
// `background-color: currentcolor` paints with the element's own text
// color — real paint whose value we know. Real browsers resolve the
// keyword before getComputedStyle output; jsdom hands it through
// verbatim, and without this substitution the layer would read as
// unparseable and force a needless abstention.
if ((!bg || bg.a < 0.1) && /^currentcolor$/i.test(String(style.backgroundColor || '').trim())) {
// The static cascade resolves var() text tokens before checks run, so
// style.color is normally already an rgb string here; parseColorResolved
// is defense in depth for any future caller that passes a live
// customPropMap (it matches the text-color path in checkElementColors
// and reduces to parseAnyColor when the map is null or absent).
bg = parseRgb(style.color) || parseColorResolved(style.color, customPropMap);
}
if (bg && bg.a > 0.1) {
if (bg.a >= 0.99) return flatten(bg);
if (bg.a >= 0.99) return { color: flatten(bg), unresolved: false };
overlays.push(bg);
} else if (!bg && !isNoPaintColorValue(style.backgroundColor)) {
// This layer names a color we could not parse (a color space we do not
// model, an unresolved var(), a syntax newer than the parser). It may
// well be opaque, which would make every ancestor below it invisible —
// so the surface is unknown and the walk stops here rather than
// reporting an ancestor the visitor never sees.
return { color: null, unresolved: true };
}
// No solid bg-color at this level, but this level paints an image. CSS
// stacks background-image layers first-on-top, so which layer leads
// decides what the visitor sees:
// • gradient on top — the gradient is the surface. Hand the caller a
// null color so it falls back to the gradient's own stops (body
// grounds, gradient buttons, hero sections).
// • url() on top — the surface is an image whose pixels this engine
// cannot read, and it may fully cover every layer and ancestor
// beneath it. Same contract as an unparseable color: abstain, so
// the gradient-stop fallback never measures a gradient the image
// hides (the shipped miss: `url(photo), linear-gradient(...)`
// reported low-contrast against the invisible gradient's stops).
if (hasGradientOrUrl) {
const layers = splitTopLevelCommas(bgImage);
const topPaintLayer = layers.find(
(layer) => /gradient\s*\(/i.test(layer) || /url\s*\(/i.test(layer),
);
const gradientOnTop = !!topPaintLayer
&& /gradient\s*\(/i.test(topPaintLayer)
&& !/^\s*url\s*\(/i.test(topPaintLayer);
if (!gradientOnTop) return { color: null, unresolved: true };
// Gradient on top of a url() layer: the image shows through wherever
// the gradient is not fully opaque, so a translucent wash like
// `linear-gradient(rgba(0,0,0,.2), rgba(0,0,0,.2)), url(photo)` paints
// a blend with pixels this engine cannot read. Only a gradient whose
// every readable stop is opaque provably covers the image; otherwise
// the surface is unknown — abstain rather than hand callers gradient
// stops (or a stop average) the visitor never sees unmixed.
const urlBeneath = layers.some(
(layer) => layer !== topPaintLayer && /url\s*\(/i.test(layer),
);
if (urlBeneath) {
const topStops = parseGradientColors(topPaintLayer);
const provablyOpaque = topStops.length > 0 && topStops.every((s) => (s.a ?? 1) >= 0.99);
if (!provablyOpaque) return { color: null, unresolved: true };
}
return { color: null, unresolved: false };
}
// No solid bg-color at this level. If THIS level has a gradient/url
// with no underlying solid color we can read:
// • on body/html: assume white. Body-level gradients are almost
// always decorative texture (paper grain, noise) on top of a
// solid bg-color the page set via `background: var(--paper)`
// shorthand — which jsdom can't decompose into bg-color. The
// downstream gradient-stops fallback path produces catastrophic
// false positives in this case (gradient noise stops have
// accidental browns/blacks that look like card backgrounds).
// • on other elements: bail to null and let the caller fall back
// to gradient stops (gradient buttons / hero sections are real
// bgs worth checking against).
// A gradient or image with no solid color under it, at any level
// including body/html, means the visible ground is that layer itself.
// Return null so the caller measures against the actual gradient stops,
// or skips when nothing is parseable — skipping beats a wrong ratio.
//
// Body/html used to assume white here, a guard written for jsdom, which
// never decomposed the `background:` shorthand and so could not see the
// solid paper color a texture gradient usually sits on. It turned every
// light-on-dark page into a wall of low-contrast false positives (a dark
// oklch body gradient produced ~120 "on #ffffff" findings on one site).
// Both engines can see shorthand solids now — the browser natively, the
// static cascade via expandStaticDeclaration + var() resolution — so a
// missing solid is real, and the old failure case cannot recur: opaque
// stops fully cover any hidden solid (they ARE the ground), alpha stops
// composite over the resolved base or the white canvas default, and
// unresolvable stops drop rather than guess.
if (hasGradientOrUrl) return null;
current = current.parentElement;
}
return flatten({ r: 255, g: 255, b: 255, a: 1 });
// Every layer up to the document root was genuinely see-through, so the
// browser paints its default canvas. This is the ONLY case that earns the
// white assumption.
return { color: flatten({ r: 255, g: 255, b: 255, a: 1 }), unresolved: false };
}
// parseGradientColors (shared) reads only the legacy serializations: rgb()
// and hex stops. Browsers keep modern-space stops in computed backgroundImage
// exactly as authored — `linear-gradient(oklch(7% 0.006 95), …)` stays oklch —
// which is what every token-driven page produces. Route those through
// parseAnyColor so a gradient ground is measurable rather than invisible.
function parseGradientColorsModern(bgImage) {
if (!bgImage || !/gradient/i.test(bgImage)) return [];
const colors = parseGradientColors(bgImage);
for (const m of bgImage.matchAll(/(?:oklch|oklab|hsla?|hwb)\(\s*[^()]*\)/gi)) {
const c = parseAnyColor(m[0]);
if (c) colors.push(c);
}
return colors;
function resolveBackground(el, win, customPropMap) {
return resolveBackgroundInfo(el, win, customPropMap).color;
}
// Walk parents looking for a gradient background and return its color stops.
@@ -1861,7 +1902,10 @@ function resolveGradientStops(el, win, customPropMap) {
if (bgImage && bgImage !== 'none' && /url\s*\(/i.test(bgImage)) return null;
let stops = null;
if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
const parsed = parseGradientColorsModern(bgImage);
// parseGradientColors (shared) reads modern-space stops too — oklch,
// color-mix and friends via balanced-paren token capture — so browser
// computed values that keep the authored syntax stay measurable.
const parsed = parseGradientColors(bgImage);
if (parsed.length > 0) stops = parsed;
}
if (!stops && !DETECTOR_IS_BROWSER) {
@@ -1869,7 +1913,7 @@ function resolveGradientStops(el, win, customPropMap) {
const rawStyle = current.getAttribute?.('style') || '';
const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
if (bgMatch && /gradient/i.test(bgMatch[1])) {
const parsed = parseGradientColorsModern(bgMatch[1]);
const parsed = parseGradientColors(bgMatch[1]);
if (parsed.length > 0) stops = parsed;
}
}
@@ -2115,13 +2159,19 @@ function checkElementColorsDOM(el) {
if (style.visibility === 'hidden' || effectiveOpacityDOM(el) <= 0.02) return [];
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
let effectiveBg = resolveBackground(el);
const bgInfo = resolveBackgroundInfo(el);
let effectiveBg = bgInfo.color;
// An unreadable surface anywhere up the chain: skip the gradient-stop
// fallback too, so nothing downstream measures against a ground we never
// resolved.
let surfaceUnresolved = bgInfo.unresolved;
let ownBg = readOwnBackgroundColor(el, style);
if (!ownBg || (ownBg.a ?? 1) <= 0.5) {
const pseudoSurface = readPseudoSurfaceDOM(el, rect);
if (pseudoSurface) {
ownBg = pseudoSurface;
effectiveBg = pseudoSurface;
surfaceUnresolved = false;
}
}
return checkColors({
@@ -2133,8 +2183,8 @@ function checkElementColorsDOM(el) {
// an oklch token near its own oklch background).
textColor: parseRgb(style.color) || parseAnyColor(style.color),
bgColor: ownBg,
effectiveBg,
effectiveBgStops: effectiveBg ? null : resolveGradientStops(el),
effectiveBg: surfaceUnresolved ? null : effectiveBg,
effectiveBgStops: surfaceUnresolved || effectiveBg ? null : resolveGradientStops(el),
fontSize: parseFloat(style.fontSize) || 16,
fontWeight: parseInt(style.fontWeight) || 400,
hasDirectText,
@@ -2284,283 +2334,6 @@ function resolveVarRefs(raw, customPropMap, depth = 0) {
});
}
// OKLCH → sRGB conversion (Björn Ottosson's matrices). L in 0..1 (or %),
// C in 0..~0.4 typical, H in degrees. Returns clamped {r,g,b,a:1} in 0..255.
// Needed because jsdom doesn't compute oklch() values — getComputedStyle
// returns the literal "oklch(...)" string. Without this, the entire
// Tailwind v4 color palette (which is OKLCH-based) is invisible to the
// detector's contrast / color checks.
function oklchToRgb(L, C, H) {
const hRad = (H * Math.PI) / 180;
return oklabToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
}
function oklabToRgb(L, a, b) {
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
const rLin = 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc;
const gLin = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc;
const bLin = -0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc;
const enc = (x) => {
const c = Math.max(0, Math.min(1, x));
return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
};
return {
r: Math.round(enc(rLin) * 255),
g: Math.round(enc(gLin) * 255),
b: Math.round(enc(bLin) * 255),
a: 1,
};
}
function hslToRgb(h, s, l) {
h = ((h % 360) + 360) % 360;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m0 = l - c / 2;
const [r, g, b] =
h < 60 ? [c, x, 0] :
h < 120 ? [x, c, 0] :
h < 180 ? [0, c, x] :
h < 240 ? [0, x, c] :
h < 300 ? [x, 0, c] : [c, 0, x];
return {
r: Math.round((r + m0) * 255),
g: Math.round((g + m0) * 255),
b: Math.round((b + m0) * 255),
a: 1,
};
}
function hwbToRgb(h, w, bl) {
if (w + bl >= 1) {
const g = Math.round((w / (w + bl)) * 255);
return { r: g, g, b: g, a: 1 };
}
const base = hslToRgb(h, 1, 0.5);
const mix = (c) => Math.round(((c / 255) * (1 - w - bl) + w) * 255);
return { r: mix(base.r), g: mix(base.g), b: mix(base.b), a: 1 };
}
// Common CSS named colors — the handful that actually show up in generated
// UIs, not the full 148-name spec list. Includes the achromatic names so a
// named gray parses (and correctly reads as no-chroma) instead of being
// treated as an unknown color.
const CSS_NAMED_COLORS = {
black: { r: 0, g: 0, b: 0 },
white: { r: 255, g: 255, b: 255 },
gray: { r: 128, g: 128, b: 128 },
grey: { r: 128, g: 128, b: 128 },
silver: { r: 192, g: 192, b: 192 },
dimgray: { r: 105, g: 105, b: 105 },
darkgray: { r: 169, g: 169, b: 169 },
lightgray: { r: 211, g: 211, b: 211 },
gainsboro: { r: 220, g: 220, b: 220 },
whitesmoke: { r: 245, g: 245, b: 245 },
red: { r: 255, g: 0, b: 0 },
crimson: { r: 220, g: 20, b: 60 },
tomato: { r: 255, g: 99, b: 71 },
coral: { r: 255, g: 127, b: 80 },
salmon: { r: 250, g: 128, b: 114 },
orange: { r: 255, g: 165, b: 0 },
gold: { r: 255, g: 215, b: 0 },
yellow: { r: 255, g: 255, b: 0 },
olive: { r: 128, g: 128, b: 0 },
lime: { r: 0, g: 255, b: 0 },
green: { r: 0, g: 128, b: 0 },
teal: { r: 0, g: 128, b: 128 },
turquoise: { r: 64, g: 224, b: 208 },
cyan: { r: 0, g: 255, b: 255 },
aqua: { r: 0, g: 255, b: 255 },
skyblue: { r: 135, g: 206, b: 235 },
dodgerblue: { r: 30, g: 144, b: 255 },
blue: { r: 0, g: 0, b: 255 },
navy: { r: 0, g: 0, b: 128 },
indigo: { r: 75, g: 0, b: 130 },
rebeccapurple: { r: 102, g: 51, b: 153 },
purple: { r: 128, g: 0, b: 128 },
violet: { r: 238, g: 130, b: 238 },
orchid: { r: 218, g: 112, b: 214 },
magenta: { r: 255, g: 0, b: 255 },
fuchsia: { r: 255, g: 0, b: 255 },
hotpink: { r: 255, g: 105, b: 180 },
pink: { r: 255, g: 192, b: 203 },
maroon: { r: 128, g: 0, b: 0 },
};
// Split a string on top-level commas (ignoring commas nested in parens).
function splitTopLevelCommas(str) {
const parts = [];
let depth = 0, start = 0;
for (let i = 0; i < str.length; i++) {
const ch = str[i];
if (ch === '(') depth++;
else if (ch === ')') depth = Math.max(0, depth - 1);
else if (ch === ',' && depth === 0) {
parts.push(str.slice(start, i).trim());
start = i + 1;
}
}
const tail = str.slice(start).trim();
if (tail) parts.push(tail);
return parts;
}
// Evaluate a CSS color-mix() expression to {r,g,b,a}. Returns null when
// the expression can't be resolved (unresolved var(), unknown colors).
//
// Mixing is done with premultiplied alpha in sRGB regardless of the
// declared interpolation space. That is exact for the dominant generated-UI
// pattern — `color-mix(in oklab, <color> N%, transparent)` — where the
// result is simply <color> at alpha N% in ANY rectangular space, and a
// close-enough approximation for opaque-opaque mixes (the detector only
// consumes these values for contrast/chroma thresholds, not for display).
function parseColorMix(str) {
const m = String(str).trim().match(/^color-mix\(/i);
if (!m) return null;
// Balanced-paren capture of the arguments.
let depth = 0, end = -1;
const open = str.indexOf('(');
for (let i = open; i < str.length; i++) {
if (str[i] === '(') depth++;
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
}
if (end < 0) return null;
const args = splitTopLevelCommas(str.slice(open + 1, end));
if (args.length !== 3 || !/^in\s/i.test(args[0])) return null;
const parseComponent = (component) => {
// Percentage may lead or trail the color per spec.
let pct = null;
let colorStr = component;
const trail = component.match(/\s+([\d.]+)%$/);
const lead = component.match(/^([\d.]+)%\s+/);
if (trail) { pct = parseFloat(trail[1]); colorStr = component.slice(0, trail.index).trim(); }
else if (lead) { pct = parseFloat(lead[1]); colorStr = component.slice(lead[0].length).trim(); }
let color;
if (/^transparent$/i.test(colorStr)) color = { r: 0, g: 0, b: 0, a: 0 };
else color = parseAnyColor(colorStr);
if (!color) return null;
return { color, pct };
};
const c1 = parseComponent(args[1]);
const c2 = parseComponent(args[2]);
if (!c1 || !c2) return null;
let p1 = c1.pct, p2 = c2.pct;
if (p1 == null && p2 == null) { p1 = 50; p2 = 50; }
else if (p1 == null) p1 = 100 - p2;
else if (p2 == null) p2 = 100 - p1;
const sum = p1 + p2;
if (sum <= 0) return null;
// Per spec: weights normalize to sum; when sum < 100 the result alpha is
// additionally scaled by sum/100.
const w1 = p1 / sum, w2 = p2 / sum;
const alphaScale = sum < 100 ? sum / 100 : 1;
const a1 = c1.color.a ?? 1, a2 = c2.color.a ?? 1;
const a = (a1 * w1 + a2 * w2) * alphaScale;
if (a <= 0) return { r: 0, g: 0, b: 0, a: 0 };
const mix = (ch) => Math.round((c1.color[ch] * a1 * w1 + c2.color[ch] * a2 * w2) / (a1 * w1 + a2 * w2));
return { r: mix('r'), g: mix('g'), b: mix('b'), a: Math.min(1, a) };
}
// Composite a translucent color over an opaque(ish) base (simple
// source-over in sRGB). Returns an opaque {r,g,b,a:1}.
function compositeColorOver(top, base) {
const a = top.a ?? 1;
return {
r: Math.round(top.r * a + base.r * (1 - a)),
g: Math.round(top.g * a + base.g * (1 - a)),
b: Math.round(top.b * a + base.b * (1 - a)),
a: 1,
};
}
// Extended color parser: rgb/rgba/hex/oklch/oklab/hsl/hwb/color-mix/common
// named colors. Returns null on no match. Use this when the input might be
// any CSS color form; use plain parseRgb when you only expect computed rgb()
// values from real browsers.
function parseAnyColor(s) {
if (!s || typeof s !== 'string') return null;
const str = s.trim();
if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
if (/^color-mix\(/i.test(str)) return parseColorMix(str);
let m;
m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+))?\s*\)/);
if (m) return { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: m[4] !== undefined ? +m[4] : 1 };
m = str.match(/^#([0-9a-f]{3,8})$/i);
if (m) {
const h = m[1];
if (h.length === 3 || h.length === 4) {
return {
r: parseInt(h[0] + h[0], 16),
g: parseInt(h[1] + h[1], 16),
b: parseInt(h[2] + h[2], 16),
a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
};
}
if (h.length === 6 || h.length === 8) {
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
};
}
}
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
// Match L (with optional %), then C and H separated permissively.
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const Lnum = parseFloat(m[1]);
const L = m[2] === '%' ? Lnum / 100 : Lnum;
const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
if (m[5] !== undefined) {
const alpha = parseFloat(m[5]);
rgb.a = m[6] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// OKLAB — a/b are signed axes; percentages map 100% → 0.4.
m = str.match(/oklab\(\s*([\d.]+)(%?)\s+(-?[\d.]+)(%?)\s+(-?[\d.]+)(%?)(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const L = m[2] === '%' ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
const a = m[4] === '%' ? parseFloat(m[3]) * 0.004 : parseFloat(m[3]);
const b = m[6] === '%' ? parseFloat(m[5]) * 0.004 : parseFloat(m[5]);
const rgb = oklabToRgb(L, a, b);
if (m[7] !== undefined) {
const alpha = parseFloat(m[7]);
rgb.a = m[8] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// HSL/HSLA — comma or space syntax, optional deg on hue.
m = str.match(/hsla?\(\s*(-?[\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%\s*[,\s]\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hslToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// HWB — hue whiteness% blackness%.
m = str.match(/hwb\(\s*(-?[\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hwbToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
const named = CSS_NAMED_COLORS[str.toLowerCase()];
if (named) return { ...named, a: 1 };
return null;
}
// Resolve var() refs in a color string (via customPropMap), then parse.
// Returns null on any failure. Used in jsdom-mode paths where
@@ -2923,15 +2696,24 @@ function checkElementGlowDOM(el) {
if (!boxShadow && !textShadow) return [];
// Use parent's background — glow radiates outward, so the surrounding context matters
// If resolveBackground returns null (gradient), try to infer from the gradient colors
let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el);
if (!parentBg) {
const parentBgInfo = resolveBackgroundInfo(el.parentElement || el);
// Unknown surface (an unreadable layer on the way up): skip only the
// gradient hunt below, which would walk PAST that layer and score the
// glow against a background the visitor never sees. checkGlow still runs
// with a null surface: the zero-offset chromatic halo tell holds on ANY
// background, and the static loop already passes the unresolved walk's
// null color straight through (detect-html.mjs uses resolveBackground).
let parentBg = parentBgInfo.color;
if (!parentBg && !parentBgInfo.unresolved) {
// Gradient background — sample its colors to determine if it's dark.
// Modern-syntax parsing matters here: body-level gradients now reach this
// fallback in browser mode, and their stops usually serialize as oklch.
// fallback in browser mode, and their stops usually serialize as oklch
// which the shared parseGradientColors reads via its color-function
// token capture.
let cur = el.parentElement;
while (cur && cur.nodeType === 1) {
const bgImage = getComputedStyle(cur).backgroundImage || '';
const gradColors = parseGradientColorsModern(bgImage);
const gradColors = parseGradientColors(bgImage);
if (gradColors.length > 0) {
// Average the gradient colors
const avg = { r: 0, g: 0, b: 0 };
@@ -2975,10 +2757,13 @@ function checkElementAIPaletteDOM(el) {
const hue = getHue(textColor);
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
if (isAIPalette) {
const parentBg = el.parentElement ? resolveBackground(el.parentElement) : null;
// Also check gradient parents
let effectiveBg = parentBg;
if (!effectiveBg) {
const parentBgInfo = el.parentElement
? resolveBackgroundInfo(el.parentElement)
: { color: null, unresolved: false };
// Unknown surface: leave effectiveBg null (no finding) rather than
// hunting gradient ancestors past a layer we could not read.
let effectiveBg = parentBgInfo.color;
if (!effectiveBg && !parentBgInfo.unresolved) {
let cur = el.parentElement;
while (cur && cur.nodeType === 1) {
const gi = getComputedStyle(cur).backgroundImage || '';
@@ -3784,7 +3569,8 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
const effectiveBg = resolveBackground(el, window, customPropMap);
const bgInfo = resolveBackgroundInfo(el, window, customPropMap);
const effectiveBg = bgInfo.color;
// jsdom returns literal "var(--X)" / "oklch(...)" for color, so plain
// parseRgb misses Tailwind-tokenized text colors. Resolve through the
// customPropMap first; fall back to parseRgb for vanilla rgb() pages.
@@ -3830,11 +3616,13 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
// element itself has no usable own background, that pseudo is the real
// surface for contrast purposes.
let finalEffectiveBg = effectiveBg;
let surfaceUnresolved = bgInfo.unresolved;
if ((!ownBg || (ownBg.a ?? 1) <= 0.5) && typeof window.getPseudoSurface === 'function') {
const pseudoSurface = window.getPseudoSurface(el);
if (pseudoSurface) {
ownBg = pseudoSurface;
finalEffectiveBg = pseudoSurface;
surfaceUnresolved = false;
}
}
@@ -3842,8 +3630,9 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
tag,
textColor,
bgColor: ownBg,
effectiveBg: finalEffectiveBg,
effectiveBgStops: finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
// Unknown surface: hand the checks nothing rather than a guess.
effectiveBg: surfaceUnresolved ? null : finalEffectiveBg,
effectiveBgStops: surfaceUnresolved || finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
fontSize: parseFloat(style.fontSize) || 16,
fontWeight: parseInt(style.fontWeight) || 400,
hasDirectText,
@@ -5663,6 +5452,7 @@ export {
checkHtmlPatterns,
readOwnBackgroundColor,
resolveBackground,
resolveBackgroundInfo,
resolveGradientStops,
parseRadiusToPx,
resolveBorderRadiusPx,
@@ -71,11 +71,43 @@ function contrastRatio(c1, c2) {
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
// The CSS color functions worth pulling out of a longer declaration. The set
// is deliberately closed: `linear-gradient(` and `url(` also look like
// `name(` and must not be read as colors.
const COLOR_FUNCTION_NAMES = new Set([
'rgb', 'rgba', 'hsl', 'hsla', 'hwb', 'oklch', 'oklab', 'lch', 'lab', 'color', 'color-mix',
]);
// Pull every color-function token out of a value, with balanced-paren capture
// so nested forms (`color-mix(in oklab, oklch(...) 20%, transparent)`) survive
// whole. Returns the raw substrings in source order.
function extractColorFunctionTokens(value) {
const str = String(value || '');
const tokens = [];
const re = /([a-z][a-z-]*)\(/gi;
let m;
while ((m = re.exec(str)) !== null) {
if (!COLOR_FUNCTION_NAMES.has(m[1].toLowerCase())) continue;
let depth = 0, end = -1;
for (let i = m.index + m[0].length - 1; i < str.length; i++) {
if (str[i] === '(') depth++;
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
}
if (end < 0) break;
tokens.push(str.slice(m.index, end + 1));
re.lastIndex = end + 1;
}
return tokens;
}
function parseGradientColors(bgImage) {
if (!bgImage || !bgImage.includes('gradient')) return [];
const colors = [];
for (const m of bgImage.matchAll(/rgba?\([^)]+\)/g)) {
const c = parseRgb(m[0]);
// Stops arrive in whatever syntax the author wrote and the browser kept.
// A dark ground painted as `linear-gradient(oklch(...), oklch(...))` used
// to read as a gradient with no stops at all.
for (const token of extractColorFunctionTokens(bgImage)) {
const c = parseAnyColor(token);
if (c) colors.push(c);
}
for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
@@ -112,13 +144,445 @@ function colorToHex(c) {
return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
}
// ─── Color-space conversions ────────────────────────────────────────────────
//
// Every function here lands on 8-bit sRGB, clamped to gamut. Chrome, Safari,
// and Firefox all keep the authored color space in getComputedStyle output
// (`oklch(0.84 0.19 80.46)`, `lch(20 5 60)`, `color(srgb 1.04 0.72 -0.21)`),
// so a detector that only reads rgb() is blind on any modern palette. The
// expected outputs are pinned in tests/detect-antipatterns.test.js against
// what Chrome itself paints for the same strings.
function clamp01(x) {
return Number.isFinite(x) ? Math.max(0, Math.min(1, x)) : 0;
}
// Linear-light sRGB channel to the encoded 0-255 value.
function encodeSrgbChannel(x) {
const c = clamp01(x);
return Math.round((c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055) * 255);
}
function decodeSrgbChannel(x) {
const c = Number.isFinite(x) ? x : 0;
const sign = c < 0 ? -1 : 1;
const abs = Math.abs(c);
return sign * (abs <= 0.04045 ? abs / 12.92 : Math.pow((abs + 0.055) / 1.055, 2.4));
}
function linearSrgbToColor(r, g, b, a = 1) {
return { r: encodeSrgbChannel(r), g: encodeSrgbChannel(g), b: encodeSrgbChannel(b), a };
}
// OKLab to sRGB (Björn Ottosson's matrices). L in 0..1, a/b are signed axes.
function oklabToRgb(L, a, b) {
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
return linearSrgbToColor(
4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc,
-1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc,
-0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc,
);
}
// OKLCH to sRGB. L in 0..1, C in 0..~0.4 typical, H in degrees. Chroma past
// the sRGB gamut clamps per channel rather than producing NaN.
function oklchToRgb(L, C, H) {
const hRad = (H * Math.PI) / 180;
return oklabToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
}
// CIE Lab to sRGB. CSS lab()/lch() use the D50 white point; the matrix below
// is the Bradford-adapted XYZ-D50 to linear-sRGB transform from CSS Color 4.
function labToRgb(L, a, b) {
const kappa = 24389 / 27, epsilon = 216 / 24389;
const fy = (L + 16) / 116, fx = fy + a / 500, fz = fy - b / 200;
const invert = (t) => (t * t * t > epsilon ? t * t * t : (116 * t - 16) / kappa);
const yr = L > kappa * epsilon ? Math.pow((L + 16) / 116, 3) : L / kappa;
const Xn = 0.3457 / 0.3585, Zn = (1 - 0.3457 - 0.3585) / 0.3585;
const x = invert(fx) * Xn, y = yr, z = invert(fz) * Zn;
return linearSrgbToColor(
3.1341359569958707 * x - 1.6173863321612538 * y - 0.4906619460083532 * z,
-0.9787955029120890 * x + 1.9162545672595240 * y + 0.0334427311613195 * z,
0.0719553798841168 * x - 0.2289768264158322 * y + 1.4053860583241250 * z,
);
}
function lchToRgb(L, C, H) {
const hRad = (H * Math.PI) / 180;
return labToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
}
// color(<space> c1 c2 c3) for the spaces that turn up in real stylesheets.
// `srgb` is what Chrome serializes most color-mix() results into, routinely
// with channels outside 0..1. Spaces we do not model return null so callers
// abstain instead of measuring against a color we invented.
function colorFunctionToRgb(space, c1, c2, c3) {
switch (space) {
case 'srgb':
return { r: Math.round(clamp01(c1) * 255), g: Math.round(clamp01(c2) * 255), b: Math.round(clamp01(c3) * 255), a: 1 };
case 'srgb-linear':
return linearSrgbToColor(c1, c2, c3);
case 'display-p3': {
const [R, G, B] = [decodeSrgbChannel(c1), decodeSrgbChannel(c2), decodeSrgbChannel(c3)];
return linearSrgbToColor(
1.2249401762805587 * R - 0.2249404646817506 * G + 0.0000002884022551 * B,
-0.0420569547096138 * R + 1.0420571661298634 * G - 0.0000002113202247 * B,
-0.0196375587040044 * R - 0.0786360772174755 * G + 1.0982736359214800 * B,
);
}
default:
return null;
}
}
function hslToRgb(h, s, l) {
h = ((h % 360) + 360) % 360;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m0 = l - c / 2;
const [r, g, b] =
h < 60 ? [c, x, 0] :
h < 120 ? [x, c, 0] :
h < 180 ? [0, c, x] :
h < 240 ? [0, x, c] :
h < 300 ? [x, 0, c] : [c, 0, x];
return {
r: Math.round((r + m0) * 255),
g: Math.round((g + m0) * 255),
b: Math.round((b + m0) * 255),
a: 1,
};
}
function hwbToRgb(h, w, bl) {
if (w + bl >= 1) {
const g = Math.round((w / (w + bl)) * 255);
return { r: g, g, b: g, a: 1 };
}
const base = hslToRgb(h, 1, 0.5);
const mix = (c) => Math.round(((c / 255) * (1 - w - bl) + w) * 255);
return { r: mix(base.r), g: mix(base.g), b: mix(base.b), a: 1 };
}
// Common CSS named colors — the handful that actually show up in generated
// UIs, not the full 148-name spec list. Includes the achromatic names so a
// named gray parses (and correctly reads as no-chroma) instead of being
// treated as an unknown color.
const CSS_NAMED_COLORS = {
black: { r: 0, g: 0, b: 0 },
white: { r: 255, g: 255, b: 255 },
gray: { r: 128, g: 128, b: 128 },
grey: { r: 128, g: 128, b: 128 },
silver: { r: 192, g: 192, b: 192 },
dimgray: { r: 105, g: 105, b: 105 },
darkgray: { r: 169, g: 169, b: 169 },
lightgray: { r: 211, g: 211, b: 211 },
gainsboro: { r: 220, g: 220, b: 220 },
whitesmoke: { r: 245, g: 245, b: 245 },
red: { r: 255, g: 0, b: 0 },
crimson: { r: 220, g: 20, b: 60 },
tomato: { r: 255, g: 99, b: 71 },
coral: { r: 255, g: 127, b: 80 },
salmon: { r: 250, g: 128, b: 114 },
orange: { r: 255, g: 165, b: 0 },
gold: { r: 255, g: 215, b: 0 },
yellow: { r: 255, g: 255, b: 0 },
olive: { r: 128, g: 128, b: 0 },
lime: { r: 0, g: 255, b: 0 },
green: { r: 0, g: 128, b: 0 },
teal: { r: 0, g: 128, b: 128 },
turquoise: { r: 64, g: 224, b: 208 },
cyan: { r: 0, g: 255, b: 255 },
aqua: { r: 0, g: 255, b: 255 },
skyblue: { r: 135, g: 206, b: 235 },
dodgerblue: { r: 30, g: 144, b: 255 },
blue: { r: 0, g: 0, b: 255 },
navy: { r: 0, g: 0, b: 128 },
indigo: { r: 75, g: 0, b: 130 },
rebeccapurple: { r: 102, g: 51, b: 153 },
purple: { r: 128, g: 0, b: 128 },
violet: { r: 238, g: 130, b: 238 },
orchid: { r: 218, g: 112, b: 214 },
magenta: { r: 255, g: 0, b: 255 },
fuchsia: { r: 255, g: 0, b: 255 },
hotpink: { r: 255, g: 105, b: 180 },
pink: { r: 255, g: 192, b: 203 },
maroon: { r: 128, g: 0, b: 0 },
};
// Split a string on top-level commas (ignoring commas nested in parens).
function splitTopLevelCommas(str) {
const parts = [];
let depth = 0, start = 0;
for (let i = 0; i < str.length; i++) {
const ch = str[i];
if (ch === '(') depth++;
else if (ch === ')') depth = Math.max(0, depth - 1);
else if (ch === ',' && depth === 0) {
parts.push(str.slice(start, i).trim());
start = i + 1;
}
}
const tail = str.slice(start).trim();
if (tail) parts.push(tail);
return parts;
}
// Evaluate a CSS color-mix() expression to {r,g,b,a}. Returns null when
// the expression can't be resolved (unresolved var(), unknown colors).
//
// Mixing is done with premultiplied alpha in sRGB regardless of the
// declared interpolation space. That is exact for the dominant generated-UI
// pattern — `color-mix(in oklab, <color> N%, transparent)` — where the
// result is simply <color> at alpha N% in ANY rectangular space, and a
// close-enough approximation for opaque-opaque mixes (the detector only
// consumes these values for contrast/chroma thresholds, not for display).
function parseColorMix(str) {
const m = String(str).trim().match(/^color-mix\(/i);
if (!m) return null;
// Balanced-paren capture of the arguments.
let depth = 0, end = -1;
const open = str.indexOf('(');
for (let i = open; i < str.length; i++) {
if (str[i] === '(') depth++;
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
}
if (end < 0) return null;
const args = splitTopLevelCommas(str.slice(open + 1, end));
if (args.length !== 3 || !/^in\s/i.test(args[0])) return null;
const parseComponent = (component) => {
// Percentage may lead or trail the color per spec.
let pct = null;
let colorStr = component;
const trail = component.match(/\s+([\d.]+)%$/);
const lead = component.match(/^([\d.]+)%\s+/);
if (trail) { pct = parseFloat(trail[1]); colorStr = component.slice(0, trail.index).trim(); }
else if (lead) { pct = parseFloat(lead[1]); colorStr = component.slice(lead[0].length).trim(); }
let color;
if (/^transparent$/i.test(colorStr)) color = { r: 0, g: 0, b: 0, a: 0 };
else color = parseAnyColor(colorStr);
if (!color) return null;
return { color, pct };
};
const c1 = parseComponent(args[1]);
const c2 = parseComponent(args[2]);
if (!c1 || !c2) return null;
let p1 = c1.pct, p2 = c2.pct;
if (p1 == null && p2 == null) { p1 = 50; p2 = 50; }
else if (p1 == null) p1 = 100 - p2;
else if (p2 == null) p2 = 100 - p1;
const sum = p1 + p2;
if (sum <= 0) return null;
// Per spec: weights normalize to sum; when sum < 100 the result alpha is
// additionally scaled by sum/100.
const w1 = p1 / sum, w2 = p2 / sum;
const alphaScale = sum < 100 ? sum / 100 : 1;
const a1 = c1.color.a ?? 1, a2 = c2.color.a ?? 1;
const a = (a1 * w1 + a2 * w2) * alphaScale;
if (a <= 0) return { r: 0, g: 0, b: 0, a: 0 };
const mix = (ch) => Math.round((c1.color[ch] * a1 * w1 + c2.color[ch] * a2 * w2) / (a1 * w1 + a2 * w2));
return { r: mix('r'), g: mix('g'), b: mix('b'), a: Math.min(1, a) };
}
// Composite a translucent color over an opaque(ish) base (simple
// source-over in sRGB). Returns an opaque {r,g,b,a:1}.
function compositeColorOver(top, base) {
const a = top.a ?? 1;
return {
r: Math.round(top.r * a + base.r * (1 - a)),
g: Math.round(top.g * a + base.g * (1 - a)),
b: Math.round(top.b * a + base.b * (1 - a)),
a: 1,
};
}
// A color() / lab() / lch() component: a bare number, a percentage against
// `scale`, or the `none` keyword (which resolves to zero for our purposes).
function parseColorComponent(token, scale = 1) {
if (token == null) return null;
const t = String(token).trim();
if (/^none$/i.test(t)) return 0;
const num = parseFloat(t);
if (!Number.isFinite(num)) return null;
return t.endsWith('%') ? (num / 100) * scale : num;
}
function parseAlphaToken(token) {
if (token == null) return 1;
const t = String(token).trim();
if (/^none$/i.test(t)) return 1;
const num = parseFloat(t);
if (!Number.isFinite(num)) return 1;
return t.endsWith('%') ? num / 100 : num;
}
// Extended color parser: rgb/rgba/hex/oklch/oklab/lch/lab/hsl/hwb/color()/
// color-mix/common named colors. Returns null on no match. Use this when the
// input might be any CSS color form; use plain parseRgb when you only expect
// computed rgb() values from real browsers.
function parseAnyColor(s) {
if (!s || typeof s !== 'string') return null;
const str = s.trim();
if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
if (/^color-mix\(/i.test(str)) return parseColorMix(str);
let m;
m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/);
if (m) {
const c = { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: 1 };
if (m[4] !== undefined) c.a = m[5] === '%' ? parseFloat(m[4]) / 100 : +m[4];
return c;
}
m = str.match(/^#([0-9a-f]{3,8})$/i);
if (m) {
const h = m[1];
if (h.length === 3 || h.length === 4) {
return {
r: parseInt(h[0] + h[0], 16),
g: parseInt(h[1] + h[1], 16),
b: parseInt(h[2] + h[2], 16),
a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
};
}
if (h.length === 6 || h.length === 8) {
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
};
}
}
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
// Match L (with optional %), then C and H separated permissively.
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const Lnum = parseFloat(m[1]);
const L = m[2] === '%' ? Lnum / 100 : Lnum;
const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
if (m[5] !== undefined) {
const alpha = parseFloat(m[5]);
rgb.a = m[6] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// OKLAB — a/b are signed axes; percentages map 100% → 0.4.
m = str.match(/oklab\(\s*([\d.]+)(%?)\s+(-?[\d.]+)(%?)\s+(-?[\d.]+)(%?)(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const L = m[2] === '%' ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
const a = m[4] === '%' ? parseFloat(m[3]) * 0.004 : parseFloat(m[3]);
const b = m[6] === '%' ? parseFloat(m[5]) * 0.004 : parseFloat(m[5]);
const rgb = oklabToRgb(L, a, b);
if (m[7] !== undefined) {
const alpha = parseFloat(m[7]);
rgb.a = m[8] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// LCH / LAB — CIE, D50 white point. Chrome serializes lch(20% 5 60) as
// `lch(20 5 60)`, so L arrives with or without its percent sign. In both
// spaces L runs 0..100 and 100% means 100.
m = str.match(/^lch\(\s*([\d.]+%?|none)\s+([\d.]+%?|none)\s+(-?[\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
if (m) {
const L = parseColorComponent(m[1], 100);
const C = parseColorComponent(m[2], 150);
const H = parseFloat(m[3]);
if (L == null || C == null || !Number.isFinite(H)) return null;
const rgb = lchToRgb(L, C, H);
rgb.a = parseAlphaToken(m[4]);
return rgb;
}
m = str.match(/^lab\(\s*([\d.]+%?|none)\s+(-?[\d.]+%?|none)\s+(-?[\d.]+%?|none)(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
if (m) {
const L = parseColorComponent(m[1], 100);
const a = parseColorComponent(m[2], 125);
const b = parseColorComponent(m[3], 125);
if (L == null || a == null || b == null) return null;
const rgb = labToRgb(L, a, b);
rgb.a = parseAlphaToken(m[4]);
return rgb;
}
// color(<space> c1 c2 c3 [/ alpha]) — what Chrome hands back for most
// color-mix() results and for any wide-gamut color an author wrote.
m = str.match(/^color\(\s*([a-z0-9-]+)\s+(-?[\d.eE+-]+%?|none)\s+(-?[\d.eE+-]+%?|none)\s+(-?[\d.eE+-]+%?|none)(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
if (m) {
const c1 = parseColorComponent(m[2]);
const c2 = parseColorComponent(m[3]);
const c3 = parseColorComponent(m[4]);
if (c1 == null || c2 == null || c3 == null) return null;
const rgb = colorFunctionToRgb(m[1].toLowerCase(), c1, c2, c3);
if (!rgb) return null;
rgb.a = parseAlphaToken(m[5]);
return rgb;
}
// HSL/HSLA — comma or space syntax, optional deg on hue.
m = str.match(/hsla?\(\s*(-?[\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%\s*[,\s]\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hslToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// HWB — hue whiteness% blackness%.
m = str.match(/hwb\(\s*(-?[\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hwbToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
const named = CSS_NAMED_COLORS[str.toLowerCase()];
if (named) return { ...named, a: 1 };
return null;
}
// True when a computed background-color string names no paint at all. Used to
// tell "this layer is see-through" (walk on to the ancestor) apart from "this
// layer has a color we could not read" (stop and abstain).
//
// `inherit` belongs here even though it is not literally see-through: it means
// "paint with the parent's background-color", and walking on to the parent IS
// that resolution. Real browsers resolve the keyword before getComputedStyle
// output; only jsdom's partial cascade hands it through verbatim, and treating
// it as unreadable would make the walk abstain on a surface it can know.
// (`currentcolor` is NOT here — it is real paint in the element's own text
// color; resolveBackgroundInfo substitutes the computed color for it.)
function isNoPaintColorValue(value) {
const v = String(value || '').trim().toLowerCase();
if (!v) return true;
return v === 'transparent' || v === 'none' || v === 'initial' || v === 'inherit' || v === 'unset' || v === 'revert' || v === 'revert-layer';
}
export {
isNeutralColor,
parseRgb,
relativeLuminance,
contrastRatio,
parseGradientColors,
extractColorFunctionTokens,
hasChroma,
getHue,
colorToHex,
oklabToRgb,
oklchToRgb,
labToRgb,
lchToRgb,
colorFunctionToRgb,
hslToRgb,
hwbToRgb,
CSS_NAMED_COLORS,
splitTopLevelCommas,
parseColorMix,
parseAnyColor,
compositeColorOver,
isNoPaintColorValue,
};
File diff suppressed because it is too large Load Diff
@@ -11,14 +11,21 @@ import {
isBrandFontOnOwnDomain,
} from '../shared/constants.mjs';
import {
CSS_NAMED_COLORS,
colorToHex,
compositeColorOver,
contrastRatio,
getHue,
hasChroma,
isNeutralColor,
isNoPaintColorValue,
oklchToRgb,
parseAnyColor,
parseColorMix,
parseGradientColors,
parseRgb,
relativeLuminance,
splitTopLevelCommas,
} from '../shared/color.mjs';
import { extractGoogleFontFamilies } from '../shared/fonts.mjs';
@@ -1731,7 +1738,7 @@ function readOwnBackgroundColor(el, computedStyle) {
// One element's background-color as the cascade walk sees it: computed style
// first (with the modern-color fallback), then, in static mode only,
// custom-prop resolution and the inline-shorthand peek. Shared by
// resolveBackground and resolveGradientStops so both walks read the same
// resolveBackgroundInfo and resolveGradientStops so both walks read the same
// surfaces.
function readCascadeBackgroundColor(current, style, customPropMap) {
let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
@@ -1755,7 +1762,19 @@ function readCascadeBackgroundColor(current, style, customPropMap) {
return bg;
}
function resolveBackground(el, win, customPropMap) {
// Walk up for the surface the element's text is painted on.
//
// Returns { color, unresolved }:
// • color set — the effective surface, overlays composited in.
// • unresolved: true — a layer on the way up paints a color this parser
// cannot read, so the surface is unknown. Callers
// must SKIP their contrast checks. Guessing white
// here is what flooded dark themes with false
// "on #ffffff" findings: one abstention costs a
// single finding, one wrong guess costs a hundred.
// • both null/false — no solid color, but a gradient or image is in
// play; callers fall back to its color stops.
function resolveBackgroundInfo(el, win, customPropMap) {
let current = el;
// Translucent layers (0.1 < a < 1) found on the way down to an opaque
// base. A browser composites these over the base; the old behavior
@@ -1783,59 +1802,81 @@ function resolveBackground(el, win, customPropMap) {
// body backgrounds.
// Real browsers serialize wide-gamut computed values as oklab()/oklch()
// (e.g. any color-mix() result), which plain parseRgb misses.
const bg = readCascadeBackgroundColor(current, style, customPropMap);
let bg = readCascadeBackgroundColor(current, style, customPropMap);
// `background-color: currentcolor` paints with the element's own text
// color — real paint whose value we know. Real browsers resolve the
// keyword before getComputedStyle output; jsdom hands it through
// verbatim, and without this substitution the layer would read as
// unparseable and force a needless abstention.
if ((!bg || bg.a < 0.1) && /^currentcolor$/i.test(String(style.backgroundColor || '').trim())) {
// The static cascade resolves var() text tokens before checks run, so
// style.color is normally already an rgb string here; parseColorResolved
// is defense in depth for any future caller that passes a live
// customPropMap (it matches the text-color path in checkElementColors
// and reduces to parseAnyColor when the map is null or absent).
bg = parseRgb(style.color) || parseColorResolved(style.color, customPropMap);
}
if (bg && bg.a > 0.1) {
if (bg.a >= 0.99) return flatten(bg);
if (bg.a >= 0.99) return { color: flatten(bg), unresolved: false };
overlays.push(bg);
} else if (!bg && !isNoPaintColorValue(style.backgroundColor)) {
// This layer names a color we could not parse (a color space we do not
// model, an unresolved var(), a syntax newer than the parser). It may
// well be opaque, which would make every ancestor below it invisible —
// so the surface is unknown and the walk stops here rather than
// reporting an ancestor the visitor never sees.
return { color: null, unresolved: true };
}
// No solid bg-color at this level, but this level paints an image. CSS
// stacks background-image layers first-on-top, so which layer leads
// decides what the visitor sees:
// • gradient on top — the gradient is the surface. Hand the caller a
// null color so it falls back to the gradient's own stops (body
// grounds, gradient buttons, hero sections).
// • url() on top — the surface is an image whose pixels this engine
// cannot read, and it may fully cover every layer and ancestor
// beneath it. Same contract as an unparseable color: abstain, so
// the gradient-stop fallback never measures a gradient the image
// hides (the shipped miss: `url(photo), linear-gradient(...)`
// reported low-contrast against the invisible gradient's stops).
if (hasGradientOrUrl) {
const layers = splitTopLevelCommas(bgImage);
const topPaintLayer = layers.find(
(layer) => /gradient\s*\(/i.test(layer) || /url\s*\(/i.test(layer),
);
const gradientOnTop = !!topPaintLayer
&& /gradient\s*\(/i.test(topPaintLayer)
&& !/^\s*url\s*\(/i.test(topPaintLayer);
if (!gradientOnTop) return { color: null, unresolved: true };
// Gradient on top of a url() layer: the image shows through wherever
// the gradient is not fully opaque, so a translucent wash like
// `linear-gradient(rgba(0,0,0,.2), rgba(0,0,0,.2)), url(photo)` paints
// a blend with pixels this engine cannot read. Only a gradient whose
// every readable stop is opaque provably covers the image; otherwise
// the surface is unknown — abstain rather than hand callers gradient
// stops (or a stop average) the visitor never sees unmixed.
const urlBeneath = layers.some(
(layer) => layer !== topPaintLayer && /url\s*\(/i.test(layer),
);
if (urlBeneath) {
const topStops = parseGradientColors(topPaintLayer);
const provablyOpaque = topStops.length > 0 && topStops.every((s) => (s.a ?? 1) >= 0.99);
if (!provablyOpaque) return { color: null, unresolved: true };
}
return { color: null, unresolved: false };
}
// No solid bg-color at this level. If THIS level has a gradient/url
// with no underlying solid color we can read:
// • on body/html: assume white. Body-level gradients are almost
// always decorative texture (paper grain, noise) on top of a
// solid bg-color the page set via `background: var(--paper)`
// shorthand — which jsdom can't decompose into bg-color. The
// downstream gradient-stops fallback path produces catastrophic
// false positives in this case (gradient noise stops have
// accidental browns/blacks that look like card backgrounds).
// • on other elements: bail to null and let the caller fall back
// to gradient stops (gradient buttons / hero sections are real
// bgs worth checking against).
// A gradient or image with no solid color under it, at any level
// including body/html, means the visible ground is that layer itself.
// Return null so the caller measures against the actual gradient stops,
// or skips when nothing is parseable — skipping beats a wrong ratio.
//
// Body/html used to assume white here, a guard written for jsdom, which
// never decomposed the `background:` shorthand and so could not see the
// solid paper color a texture gradient usually sits on. It turned every
// light-on-dark page into a wall of low-contrast false positives (a dark
// oklch body gradient produced ~120 "on #ffffff" findings on one site).
// Both engines can see shorthand solids now — the browser natively, the
// static cascade via expandStaticDeclaration + var() resolution — so a
// missing solid is real, and the old failure case cannot recur: opaque
// stops fully cover any hidden solid (they ARE the ground), alpha stops
// composite over the resolved base or the white canvas default, and
// unresolvable stops drop rather than guess.
if (hasGradientOrUrl) return null;
current = current.parentElement;
}
return flatten({ r: 255, g: 255, b: 255, a: 1 });
// Every layer up to the document root was genuinely see-through, so the
// browser paints its default canvas. This is the ONLY case that earns the
// white assumption.
return { color: flatten({ r: 255, g: 255, b: 255, a: 1 }), unresolved: false };
}
// parseGradientColors (shared) reads only the legacy serializations: rgb()
// and hex stops. Browsers keep modern-space stops in computed backgroundImage
// exactly as authored — `linear-gradient(oklch(7% 0.006 95), …)` stays oklch —
// which is what every token-driven page produces. Route those through
// parseAnyColor so a gradient ground is measurable rather than invisible.
function parseGradientColorsModern(bgImage) {
if (!bgImage || !/gradient/i.test(bgImage)) return [];
const colors = parseGradientColors(bgImage);
for (const m of bgImage.matchAll(/(?:oklch|oklab|hsla?|hwb)\(\s*[^()]*\)/gi)) {
const c = parseAnyColor(m[0]);
if (c) colors.push(c);
}
return colors;
function resolveBackground(el, win, customPropMap) {
return resolveBackgroundInfo(el, win, customPropMap).color;
}
// Walk parents looking for a gradient background and return its color stops.
@@ -1861,7 +1902,10 @@ function resolveGradientStops(el, win, customPropMap) {
if (bgImage && bgImage !== 'none' && /url\s*\(/i.test(bgImage)) return null;
let stops = null;
if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
const parsed = parseGradientColorsModern(bgImage);
// parseGradientColors (shared) reads modern-space stops too — oklch,
// color-mix and friends via balanced-paren token capture — so browser
// computed values that keep the authored syntax stay measurable.
const parsed = parseGradientColors(bgImage);
if (parsed.length > 0) stops = parsed;
}
if (!stops && !DETECTOR_IS_BROWSER) {
@@ -1869,7 +1913,7 @@ function resolveGradientStops(el, win, customPropMap) {
const rawStyle = current.getAttribute?.('style') || '';
const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
if (bgMatch && /gradient/i.test(bgMatch[1])) {
const parsed = parseGradientColorsModern(bgMatch[1]);
const parsed = parseGradientColors(bgMatch[1]);
if (parsed.length > 0) stops = parsed;
}
}
@@ -2115,13 +2159,19 @@ function checkElementColorsDOM(el) {
if (style.visibility === 'hidden' || effectiveOpacityDOM(el) <= 0.02) return [];
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
let effectiveBg = resolveBackground(el);
const bgInfo = resolveBackgroundInfo(el);
let effectiveBg = bgInfo.color;
// An unreadable surface anywhere up the chain: skip the gradient-stop
// fallback too, so nothing downstream measures against a ground we never
// resolved.
let surfaceUnresolved = bgInfo.unresolved;
let ownBg = readOwnBackgroundColor(el, style);
if (!ownBg || (ownBg.a ?? 1) <= 0.5) {
const pseudoSurface = readPseudoSurfaceDOM(el, rect);
if (pseudoSurface) {
ownBg = pseudoSurface;
effectiveBg = pseudoSurface;
surfaceUnresolved = false;
}
}
return checkColors({
@@ -2133,8 +2183,8 @@ function checkElementColorsDOM(el) {
// an oklch token near its own oklch background).
textColor: parseRgb(style.color) || parseAnyColor(style.color),
bgColor: ownBg,
effectiveBg,
effectiveBgStops: effectiveBg ? null : resolveGradientStops(el),
effectiveBg: surfaceUnresolved ? null : effectiveBg,
effectiveBgStops: surfaceUnresolved || effectiveBg ? null : resolveGradientStops(el),
fontSize: parseFloat(style.fontSize) || 16,
fontWeight: parseInt(style.fontWeight) || 400,
hasDirectText,
@@ -2284,283 +2334,6 @@ function resolveVarRefs(raw, customPropMap, depth = 0) {
});
}
// OKLCH → sRGB conversion (Björn Ottosson's matrices). L in 0..1 (or %),
// C in 0..~0.4 typical, H in degrees. Returns clamped {r,g,b,a:1} in 0..255.
// Needed because jsdom doesn't compute oklch() values — getComputedStyle
// returns the literal "oklch(...)" string. Without this, the entire
// Tailwind v4 color palette (which is OKLCH-based) is invisible to the
// detector's contrast / color checks.
function oklchToRgb(L, C, H) {
const hRad = (H * Math.PI) / 180;
return oklabToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
}
function oklabToRgb(L, a, b) {
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
const rLin = 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc;
const gLin = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc;
const bLin = -0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc;
const enc = (x) => {
const c = Math.max(0, Math.min(1, x));
return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
};
return {
r: Math.round(enc(rLin) * 255),
g: Math.round(enc(gLin) * 255),
b: Math.round(enc(bLin) * 255),
a: 1,
};
}
function hslToRgb(h, s, l) {
h = ((h % 360) + 360) % 360;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m0 = l - c / 2;
const [r, g, b] =
h < 60 ? [c, x, 0] :
h < 120 ? [x, c, 0] :
h < 180 ? [0, c, x] :
h < 240 ? [0, x, c] :
h < 300 ? [x, 0, c] : [c, 0, x];
return {
r: Math.round((r + m0) * 255),
g: Math.round((g + m0) * 255),
b: Math.round((b + m0) * 255),
a: 1,
};
}
function hwbToRgb(h, w, bl) {
if (w + bl >= 1) {
const g = Math.round((w / (w + bl)) * 255);
return { r: g, g, b: g, a: 1 };
}
const base = hslToRgb(h, 1, 0.5);
const mix = (c) => Math.round(((c / 255) * (1 - w - bl) + w) * 255);
return { r: mix(base.r), g: mix(base.g), b: mix(base.b), a: 1 };
}
// Common CSS named colors — the handful that actually show up in generated
// UIs, not the full 148-name spec list. Includes the achromatic names so a
// named gray parses (and correctly reads as no-chroma) instead of being
// treated as an unknown color.
const CSS_NAMED_COLORS = {
black: { r: 0, g: 0, b: 0 },
white: { r: 255, g: 255, b: 255 },
gray: { r: 128, g: 128, b: 128 },
grey: { r: 128, g: 128, b: 128 },
silver: { r: 192, g: 192, b: 192 },
dimgray: { r: 105, g: 105, b: 105 },
darkgray: { r: 169, g: 169, b: 169 },
lightgray: { r: 211, g: 211, b: 211 },
gainsboro: { r: 220, g: 220, b: 220 },
whitesmoke: { r: 245, g: 245, b: 245 },
red: { r: 255, g: 0, b: 0 },
crimson: { r: 220, g: 20, b: 60 },
tomato: { r: 255, g: 99, b: 71 },
coral: { r: 255, g: 127, b: 80 },
salmon: { r: 250, g: 128, b: 114 },
orange: { r: 255, g: 165, b: 0 },
gold: { r: 255, g: 215, b: 0 },
yellow: { r: 255, g: 255, b: 0 },
olive: { r: 128, g: 128, b: 0 },
lime: { r: 0, g: 255, b: 0 },
green: { r: 0, g: 128, b: 0 },
teal: { r: 0, g: 128, b: 128 },
turquoise: { r: 64, g: 224, b: 208 },
cyan: { r: 0, g: 255, b: 255 },
aqua: { r: 0, g: 255, b: 255 },
skyblue: { r: 135, g: 206, b: 235 },
dodgerblue: { r: 30, g: 144, b: 255 },
blue: { r: 0, g: 0, b: 255 },
navy: { r: 0, g: 0, b: 128 },
indigo: { r: 75, g: 0, b: 130 },
rebeccapurple: { r: 102, g: 51, b: 153 },
purple: { r: 128, g: 0, b: 128 },
violet: { r: 238, g: 130, b: 238 },
orchid: { r: 218, g: 112, b: 214 },
magenta: { r: 255, g: 0, b: 255 },
fuchsia: { r: 255, g: 0, b: 255 },
hotpink: { r: 255, g: 105, b: 180 },
pink: { r: 255, g: 192, b: 203 },
maroon: { r: 128, g: 0, b: 0 },
};
// Split a string on top-level commas (ignoring commas nested in parens).
function splitTopLevelCommas(str) {
const parts = [];
let depth = 0, start = 0;
for (let i = 0; i < str.length; i++) {
const ch = str[i];
if (ch === '(') depth++;
else if (ch === ')') depth = Math.max(0, depth - 1);
else if (ch === ',' && depth === 0) {
parts.push(str.slice(start, i).trim());
start = i + 1;
}
}
const tail = str.slice(start).trim();
if (tail) parts.push(tail);
return parts;
}
// Evaluate a CSS color-mix() expression to {r,g,b,a}. Returns null when
// the expression can't be resolved (unresolved var(), unknown colors).
//
// Mixing is done with premultiplied alpha in sRGB regardless of the
// declared interpolation space. That is exact for the dominant generated-UI
// pattern — `color-mix(in oklab, <color> N%, transparent)` — where the
// result is simply <color> at alpha N% in ANY rectangular space, and a
// close-enough approximation for opaque-opaque mixes (the detector only
// consumes these values for contrast/chroma thresholds, not for display).
function parseColorMix(str) {
const m = String(str).trim().match(/^color-mix\(/i);
if (!m) return null;
// Balanced-paren capture of the arguments.
let depth = 0, end = -1;
const open = str.indexOf('(');
for (let i = open; i < str.length; i++) {
if (str[i] === '(') depth++;
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
}
if (end < 0) return null;
const args = splitTopLevelCommas(str.slice(open + 1, end));
if (args.length !== 3 || !/^in\s/i.test(args[0])) return null;
const parseComponent = (component) => {
// Percentage may lead or trail the color per spec.
let pct = null;
let colorStr = component;
const trail = component.match(/\s+([\d.]+)%$/);
const lead = component.match(/^([\d.]+)%\s+/);
if (trail) { pct = parseFloat(trail[1]); colorStr = component.slice(0, trail.index).trim(); }
else if (lead) { pct = parseFloat(lead[1]); colorStr = component.slice(lead[0].length).trim(); }
let color;
if (/^transparent$/i.test(colorStr)) color = { r: 0, g: 0, b: 0, a: 0 };
else color = parseAnyColor(colorStr);
if (!color) return null;
return { color, pct };
};
const c1 = parseComponent(args[1]);
const c2 = parseComponent(args[2]);
if (!c1 || !c2) return null;
let p1 = c1.pct, p2 = c2.pct;
if (p1 == null && p2 == null) { p1 = 50; p2 = 50; }
else if (p1 == null) p1 = 100 - p2;
else if (p2 == null) p2 = 100 - p1;
const sum = p1 + p2;
if (sum <= 0) return null;
// Per spec: weights normalize to sum; when sum < 100 the result alpha is
// additionally scaled by sum/100.
const w1 = p1 / sum, w2 = p2 / sum;
const alphaScale = sum < 100 ? sum / 100 : 1;
const a1 = c1.color.a ?? 1, a2 = c2.color.a ?? 1;
const a = (a1 * w1 + a2 * w2) * alphaScale;
if (a <= 0) return { r: 0, g: 0, b: 0, a: 0 };
const mix = (ch) => Math.round((c1.color[ch] * a1 * w1 + c2.color[ch] * a2 * w2) / (a1 * w1 + a2 * w2));
return { r: mix('r'), g: mix('g'), b: mix('b'), a: Math.min(1, a) };
}
// Composite a translucent color over an opaque(ish) base (simple
// source-over in sRGB). Returns an opaque {r,g,b,a:1}.
function compositeColorOver(top, base) {
const a = top.a ?? 1;
return {
r: Math.round(top.r * a + base.r * (1 - a)),
g: Math.round(top.g * a + base.g * (1 - a)),
b: Math.round(top.b * a + base.b * (1 - a)),
a: 1,
};
}
// Extended color parser: rgb/rgba/hex/oklch/oklab/hsl/hwb/color-mix/common
// named colors. Returns null on no match. Use this when the input might be
// any CSS color form; use plain parseRgb when you only expect computed rgb()
// values from real browsers.
function parseAnyColor(s) {
if (!s || typeof s !== 'string') return null;
const str = s.trim();
if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
if (/^color-mix\(/i.test(str)) return parseColorMix(str);
let m;
m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+))?\s*\)/);
if (m) return { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: m[4] !== undefined ? +m[4] : 1 };
m = str.match(/^#([0-9a-f]{3,8})$/i);
if (m) {
const h = m[1];
if (h.length === 3 || h.length === 4) {
return {
r: parseInt(h[0] + h[0], 16),
g: parseInt(h[1] + h[1], 16),
b: parseInt(h[2] + h[2], 16),
a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
};
}
if (h.length === 6 || h.length === 8) {
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
};
}
}
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
// Match L (with optional %), then C and H separated permissively.
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const Lnum = parseFloat(m[1]);
const L = m[2] === '%' ? Lnum / 100 : Lnum;
const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
if (m[5] !== undefined) {
const alpha = parseFloat(m[5]);
rgb.a = m[6] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// OKLAB — a/b are signed axes; percentages map 100% → 0.4.
m = str.match(/oklab\(\s*([\d.]+)(%?)\s+(-?[\d.]+)(%?)\s+(-?[\d.]+)(%?)(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const L = m[2] === '%' ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
const a = m[4] === '%' ? parseFloat(m[3]) * 0.004 : parseFloat(m[3]);
const b = m[6] === '%' ? parseFloat(m[5]) * 0.004 : parseFloat(m[5]);
const rgb = oklabToRgb(L, a, b);
if (m[7] !== undefined) {
const alpha = parseFloat(m[7]);
rgb.a = m[8] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// HSL/HSLA — comma or space syntax, optional deg on hue.
m = str.match(/hsla?\(\s*(-?[\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%\s*[,\s]\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hslToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// HWB — hue whiteness% blackness%.
m = str.match(/hwb\(\s*(-?[\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hwbToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
const named = CSS_NAMED_COLORS[str.toLowerCase()];
if (named) return { ...named, a: 1 };
return null;
}
// Resolve var() refs in a color string (via customPropMap), then parse.
// Returns null on any failure. Used in jsdom-mode paths where
@@ -2923,15 +2696,24 @@ function checkElementGlowDOM(el) {
if (!boxShadow && !textShadow) return [];
// Use parent's background — glow radiates outward, so the surrounding context matters
// If resolveBackground returns null (gradient), try to infer from the gradient colors
let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el);
if (!parentBg) {
const parentBgInfo = resolveBackgroundInfo(el.parentElement || el);
// Unknown surface (an unreadable layer on the way up): skip only the
// gradient hunt below, which would walk PAST that layer and score the
// glow against a background the visitor never sees. checkGlow still runs
// with a null surface: the zero-offset chromatic halo tell holds on ANY
// background, and the static loop already passes the unresolved walk's
// null color straight through (detect-html.mjs uses resolveBackground).
let parentBg = parentBgInfo.color;
if (!parentBg && !parentBgInfo.unresolved) {
// Gradient background — sample its colors to determine if it's dark.
// Modern-syntax parsing matters here: body-level gradients now reach this
// fallback in browser mode, and their stops usually serialize as oklch.
// fallback in browser mode, and their stops usually serialize as oklch
// which the shared parseGradientColors reads via its color-function
// token capture.
let cur = el.parentElement;
while (cur && cur.nodeType === 1) {
const bgImage = getComputedStyle(cur).backgroundImage || '';
const gradColors = parseGradientColorsModern(bgImage);
const gradColors = parseGradientColors(bgImage);
if (gradColors.length > 0) {
// Average the gradient colors
const avg = { r: 0, g: 0, b: 0 };
@@ -2975,10 +2757,13 @@ function checkElementAIPaletteDOM(el) {
const hue = getHue(textColor);
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
if (isAIPalette) {
const parentBg = el.parentElement ? resolveBackground(el.parentElement) : null;
// Also check gradient parents
let effectiveBg = parentBg;
if (!effectiveBg) {
const parentBgInfo = el.parentElement
? resolveBackgroundInfo(el.parentElement)
: { color: null, unresolved: false };
// Unknown surface: leave effectiveBg null (no finding) rather than
// hunting gradient ancestors past a layer we could not read.
let effectiveBg = parentBgInfo.color;
if (!effectiveBg && !parentBgInfo.unresolved) {
let cur = el.parentElement;
while (cur && cur.nodeType === 1) {
const gi = getComputedStyle(cur).backgroundImage || '';
@@ -3784,7 +3569,8 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
const effectiveBg = resolveBackground(el, window, customPropMap);
const bgInfo = resolveBackgroundInfo(el, window, customPropMap);
const effectiveBg = bgInfo.color;
// jsdom returns literal "var(--X)" / "oklch(...)" for color, so plain
// parseRgb misses Tailwind-tokenized text colors. Resolve through the
// customPropMap first; fall back to parseRgb for vanilla rgb() pages.
@@ -3830,11 +3616,13 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
// element itself has no usable own background, that pseudo is the real
// surface for contrast purposes.
let finalEffectiveBg = effectiveBg;
let surfaceUnresolved = bgInfo.unresolved;
if ((!ownBg || (ownBg.a ?? 1) <= 0.5) && typeof window.getPseudoSurface === 'function') {
const pseudoSurface = window.getPseudoSurface(el);
if (pseudoSurface) {
ownBg = pseudoSurface;
finalEffectiveBg = pseudoSurface;
surfaceUnresolved = false;
}
}
@@ -3842,8 +3630,9 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
tag,
textColor,
bgColor: ownBg,
effectiveBg: finalEffectiveBg,
effectiveBgStops: finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
// Unknown surface: hand the checks nothing rather than a guess.
effectiveBg: surfaceUnresolved ? null : finalEffectiveBg,
effectiveBgStops: surfaceUnresolved || finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
fontSize: parseFloat(style.fontSize) || 16,
fontWeight: parseInt(style.fontWeight) || 400,
hasDirectText,
@@ -5663,6 +5452,7 @@ export {
checkHtmlPatterns,
readOwnBackgroundColor,
resolveBackground,
resolveBackgroundInfo,
resolveGradientStops,
parseRadiusToPx,
resolveBorderRadiusPx,
@@ -71,11 +71,43 @@ function contrastRatio(c1, c2) {
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
// The CSS color functions worth pulling out of a longer declaration. The set
// is deliberately closed: `linear-gradient(` and `url(` also look like
// `name(` and must not be read as colors.
const COLOR_FUNCTION_NAMES = new Set([
'rgb', 'rgba', 'hsl', 'hsla', 'hwb', 'oklch', 'oklab', 'lch', 'lab', 'color', 'color-mix',
]);
// Pull every color-function token out of a value, with balanced-paren capture
// so nested forms (`color-mix(in oklab, oklch(...) 20%, transparent)`) survive
// whole. Returns the raw substrings in source order.
function extractColorFunctionTokens(value) {
const str = String(value || '');
const tokens = [];
const re = /([a-z][a-z-]*)\(/gi;
let m;
while ((m = re.exec(str)) !== null) {
if (!COLOR_FUNCTION_NAMES.has(m[1].toLowerCase())) continue;
let depth = 0, end = -1;
for (let i = m.index + m[0].length - 1; i < str.length; i++) {
if (str[i] === '(') depth++;
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
}
if (end < 0) break;
tokens.push(str.slice(m.index, end + 1));
re.lastIndex = end + 1;
}
return tokens;
}
function parseGradientColors(bgImage) {
if (!bgImage || !bgImage.includes('gradient')) return [];
const colors = [];
for (const m of bgImage.matchAll(/rgba?\([^)]+\)/g)) {
const c = parseRgb(m[0]);
// Stops arrive in whatever syntax the author wrote and the browser kept.
// A dark ground painted as `linear-gradient(oklch(...), oklch(...))` used
// to read as a gradient with no stops at all.
for (const token of extractColorFunctionTokens(bgImage)) {
const c = parseAnyColor(token);
if (c) colors.push(c);
}
for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
@@ -112,13 +144,445 @@ function colorToHex(c) {
return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
}
// ─── Color-space conversions ────────────────────────────────────────────────
//
// Every function here lands on 8-bit sRGB, clamped to gamut. Chrome, Safari,
// and Firefox all keep the authored color space in getComputedStyle output
// (`oklch(0.84 0.19 80.46)`, `lch(20 5 60)`, `color(srgb 1.04 0.72 -0.21)`),
// so a detector that only reads rgb() is blind on any modern palette. The
// expected outputs are pinned in tests/detect-antipatterns.test.js against
// what Chrome itself paints for the same strings.
function clamp01(x) {
return Number.isFinite(x) ? Math.max(0, Math.min(1, x)) : 0;
}
// Linear-light sRGB channel to the encoded 0-255 value.
function encodeSrgbChannel(x) {
const c = clamp01(x);
return Math.round((c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055) * 255);
}
function decodeSrgbChannel(x) {
const c = Number.isFinite(x) ? x : 0;
const sign = c < 0 ? -1 : 1;
const abs = Math.abs(c);
return sign * (abs <= 0.04045 ? abs / 12.92 : Math.pow((abs + 0.055) / 1.055, 2.4));
}
function linearSrgbToColor(r, g, b, a = 1) {
return { r: encodeSrgbChannel(r), g: encodeSrgbChannel(g), b: encodeSrgbChannel(b), a };
}
// OKLab to sRGB (Björn Ottosson's matrices). L in 0..1, a/b are signed axes.
function oklabToRgb(L, a, b) {
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
return linearSrgbToColor(
4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc,
-1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc,
-0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc,
);
}
// OKLCH to sRGB. L in 0..1, C in 0..~0.4 typical, H in degrees. Chroma past
// the sRGB gamut clamps per channel rather than producing NaN.
function oklchToRgb(L, C, H) {
const hRad = (H * Math.PI) / 180;
return oklabToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
}
// CIE Lab to sRGB. CSS lab()/lch() use the D50 white point; the matrix below
// is the Bradford-adapted XYZ-D50 to linear-sRGB transform from CSS Color 4.
function labToRgb(L, a, b) {
const kappa = 24389 / 27, epsilon = 216 / 24389;
const fy = (L + 16) / 116, fx = fy + a / 500, fz = fy - b / 200;
const invert = (t) => (t * t * t > epsilon ? t * t * t : (116 * t - 16) / kappa);
const yr = L > kappa * epsilon ? Math.pow((L + 16) / 116, 3) : L / kappa;
const Xn = 0.3457 / 0.3585, Zn = (1 - 0.3457 - 0.3585) / 0.3585;
const x = invert(fx) * Xn, y = yr, z = invert(fz) * Zn;
return linearSrgbToColor(
3.1341359569958707 * x - 1.6173863321612538 * y - 0.4906619460083532 * z,
-0.9787955029120890 * x + 1.9162545672595240 * y + 0.0334427311613195 * z,
0.0719553798841168 * x - 0.2289768264158322 * y + 1.4053860583241250 * z,
);
}
function lchToRgb(L, C, H) {
const hRad = (H * Math.PI) / 180;
return labToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
}
// color(<space> c1 c2 c3) for the spaces that turn up in real stylesheets.
// `srgb` is what Chrome serializes most color-mix() results into, routinely
// with channels outside 0..1. Spaces we do not model return null so callers
// abstain instead of measuring against a color we invented.
function colorFunctionToRgb(space, c1, c2, c3) {
switch (space) {
case 'srgb':
return { r: Math.round(clamp01(c1) * 255), g: Math.round(clamp01(c2) * 255), b: Math.round(clamp01(c3) * 255), a: 1 };
case 'srgb-linear':
return linearSrgbToColor(c1, c2, c3);
case 'display-p3': {
const [R, G, B] = [decodeSrgbChannel(c1), decodeSrgbChannel(c2), decodeSrgbChannel(c3)];
return linearSrgbToColor(
1.2249401762805587 * R - 0.2249404646817506 * G + 0.0000002884022551 * B,
-0.0420569547096138 * R + 1.0420571661298634 * G - 0.0000002113202247 * B,
-0.0196375587040044 * R - 0.0786360772174755 * G + 1.0982736359214800 * B,
);
}
default:
return null;
}
}
function hslToRgb(h, s, l) {
h = ((h % 360) + 360) % 360;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m0 = l - c / 2;
const [r, g, b] =
h < 60 ? [c, x, 0] :
h < 120 ? [x, c, 0] :
h < 180 ? [0, c, x] :
h < 240 ? [0, x, c] :
h < 300 ? [x, 0, c] : [c, 0, x];
return {
r: Math.round((r + m0) * 255),
g: Math.round((g + m0) * 255),
b: Math.round((b + m0) * 255),
a: 1,
};
}
function hwbToRgb(h, w, bl) {
if (w + bl >= 1) {
const g = Math.round((w / (w + bl)) * 255);
return { r: g, g, b: g, a: 1 };
}
const base = hslToRgb(h, 1, 0.5);
const mix = (c) => Math.round(((c / 255) * (1 - w - bl) + w) * 255);
return { r: mix(base.r), g: mix(base.g), b: mix(base.b), a: 1 };
}
// Common CSS named colors — the handful that actually show up in generated
// UIs, not the full 148-name spec list. Includes the achromatic names so a
// named gray parses (and correctly reads as no-chroma) instead of being
// treated as an unknown color.
const CSS_NAMED_COLORS = {
black: { r: 0, g: 0, b: 0 },
white: { r: 255, g: 255, b: 255 },
gray: { r: 128, g: 128, b: 128 },
grey: { r: 128, g: 128, b: 128 },
silver: { r: 192, g: 192, b: 192 },
dimgray: { r: 105, g: 105, b: 105 },
darkgray: { r: 169, g: 169, b: 169 },
lightgray: { r: 211, g: 211, b: 211 },
gainsboro: { r: 220, g: 220, b: 220 },
whitesmoke: { r: 245, g: 245, b: 245 },
red: { r: 255, g: 0, b: 0 },
crimson: { r: 220, g: 20, b: 60 },
tomato: { r: 255, g: 99, b: 71 },
coral: { r: 255, g: 127, b: 80 },
salmon: { r: 250, g: 128, b: 114 },
orange: { r: 255, g: 165, b: 0 },
gold: { r: 255, g: 215, b: 0 },
yellow: { r: 255, g: 255, b: 0 },
olive: { r: 128, g: 128, b: 0 },
lime: { r: 0, g: 255, b: 0 },
green: { r: 0, g: 128, b: 0 },
teal: { r: 0, g: 128, b: 128 },
turquoise: { r: 64, g: 224, b: 208 },
cyan: { r: 0, g: 255, b: 255 },
aqua: { r: 0, g: 255, b: 255 },
skyblue: { r: 135, g: 206, b: 235 },
dodgerblue: { r: 30, g: 144, b: 255 },
blue: { r: 0, g: 0, b: 255 },
navy: { r: 0, g: 0, b: 128 },
indigo: { r: 75, g: 0, b: 130 },
rebeccapurple: { r: 102, g: 51, b: 153 },
purple: { r: 128, g: 0, b: 128 },
violet: { r: 238, g: 130, b: 238 },
orchid: { r: 218, g: 112, b: 214 },
magenta: { r: 255, g: 0, b: 255 },
fuchsia: { r: 255, g: 0, b: 255 },
hotpink: { r: 255, g: 105, b: 180 },
pink: { r: 255, g: 192, b: 203 },
maroon: { r: 128, g: 0, b: 0 },
};
// Split a string on top-level commas (ignoring commas nested in parens).
function splitTopLevelCommas(str) {
const parts = [];
let depth = 0, start = 0;
for (let i = 0; i < str.length; i++) {
const ch = str[i];
if (ch === '(') depth++;
else if (ch === ')') depth = Math.max(0, depth - 1);
else if (ch === ',' && depth === 0) {
parts.push(str.slice(start, i).trim());
start = i + 1;
}
}
const tail = str.slice(start).trim();
if (tail) parts.push(tail);
return parts;
}
// Evaluate a CSS color-mix() expression to {r,g,b,a}. Returns null when
// the expression can't be resolved (unresolved var(), unknown colors).
//
// Mixing is done with premultiplied alpha in sRGB regardless of the
// declared interpolation space. That is exact for the dominant generated-UI
// pattern — `color-mix(in oklab, <color> N%, transparent)` — where the
// result is simply <color> at alpha N% in ANY rectangular space, and a
// close-enough approximation for opaque-opaque mixes (the detector only
// consumes these values for contrast/chroma thresholds, not for display).
function parseColorMix(str) {
const m = String(str).trim().match(/^color-mix\(/i);
if (!m) return null;
// Balanced-paren capture of the arguments.
let depth = 0, end = -1;
const open = str.indexOf('(');
for (let i = open; i < str.length; i++) {
if (str[i] === '(') depth++;
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
}
if (end < 0) return null;
const args = splitTopLevelCommas(str.slice(open + 1, end));
if (args.length !== 3 || !/^in\s/i.test(args[0])) return null;
const parseComponent = (component) => {
// Percentage may lead or trail the color per spec.
let pct = null;
let colorStr = component;
const trail = component.match(/\s+([\d.]+)%$/);
const lead = component.match(/^([\d.]+)%\s+/);
if (trail) { pct = parseFloat(trail[1]); colorStr = component.slice(0, trail.index).trim(); }
else if (lead) { pct = parseFloat(lead[1]); colorStr = component.slice(lead[0].length).trim(); }
let color;
if (/^transparent$/i.test(colorStr)) color = { r: 0, g: 0, b: 0, a: 0 };
else color = parseAnyColor(colorStr);
if (!color) return null;
return { color, pct };
};
const c1 = parseComponent(args[1]);
const c2 = parseComponent(args[2]);
if (!c1 || !c2) return null;
let p1 = c1.pct, p2 = c2.pct;
if (p1 == null && p2 == null) { p1 = 50; p2 = 50; }
else if (p1 == null) p1 = 100 - p2;
else if (p2 == null) p2 = 100 - p1;
const sum = p1 + p2;
if (sum <= 0) return null;
// Per spec: weights normalize to sum; when sum < 100 the result alpha is
// additionally scaled by sum/100.
const w1 = p1 / sum, w2 = p2 / sum;
const alphaScale = sum < 100 ? sum / 100 : 1;
const a1 = c1.color.a ?? 1, a2 = c2.color.a ?? 1;
const a = (a1 * w1 + a2 * w2) * alphaScale;
if (a <= 0) return { r: 0, g: 0, b: 0, a: 0 };
const mix = (ch) => Math.round((c1.color[ch] * a1 * w1 + c2.color[ch] * a2 * w2) / (a1 * w1 + a2 * w2));
return { r: mix('r'), g: mix('g'), b: mix('b'), a: Math.min(1, a) };
}
// Composite a translucent color over an opaque(ish) base (simple
// source-over in sRGB). Returns an opaque {r,g,b,a:1}.
function compositeColorOver(top, base) {
const a = top.a ?? 1;
return {
r: Math.round(top.r * a + base.r * (1 - a)),
g: Math.round(top.g * a + base.g * (1 - a)),
b: Math.round(top.b * a + base.b * (1 - a)),
a: 1,
};
}
// A color() / lab() / lch() component: a bare number, a percentage against
// `scale`, or the `none` keyword (which resolves to zero for our purposes).
function parseColorComponent(token, scale = 1) {
if (token == null) return null;
const t = String(token).trim();
if (/^none$/i.test(t)) return 0;
const num = parseFloat(t);
if (!Number.isFinite(num)) return null;
return t.endsWith('%') ? (num / 100) * scale : num;
}
function parseAlphaToken(token) {
if (token == null) return 1;
const t = String(token).trim();
if (/^none$/i.test(t)) return 1;
const num = parseFloat(t);
if (!Number.isFinite(num)) return 1;
return t.endsWith('%') ? num / 100 : num;
}
// Extended color parser: rgb/rgba/hex/oklch/oklab/lch/lab/hsl/hwb/color()/
// color-mix/common named colors. Returns null on no match. Use this when the
// input might be any CSS color form; use plain parseRgb when you only expect
// computed rgb() values from real browsers.
function parseAnyColor(s) {
if (!s || typeof s !== 'string') return null;
const str = s.trim();
if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
if (/^color-mix\(/i.test(str)) return parseColorMix(str);
let m;
m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/);
if (m) {
const c = { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: 1 };
if (m[4] !== undefined) c.a = m[5] === '%' ? parseFloat(m[4]) / 100 : +m[4];
return c;
}
m = str.match(/^#([0-9a-f]{3,8})$/i);
if (m) {
const h = m[1];
if (h.length === 3 || h.length === 4) {
return {
r: parseInt(h[0] + h[0], 16),
g: parseInt(h[1] + h[1], 16),
b: parseInt(h[2] + h[2], 16),
a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
};
}
if (h.length === 6 || h.length === 8) {
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
};
}
}
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
// Match L (with optional %), then C and H separated permissively.
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const Lnum = parseFloat(m[1]);
const L = m[2] === '%' ? Lnum / 100 : Lnum;
const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
if (m[5] !== undefined) {
const alpha = parseFloat(m[5]);
rgb.a = m[6] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// OKLAB — a/b are signed axes; percentages map 100% → 0.4.
m = str.match(/oklab\(\s*([\d.]+)(%?)\s+(-?[\d.]+)(%?)\s+(-?[\d.]+)(%?)(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const L = m[2] === '%' ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
const a = m[4] === '%' ? parseFloat(m[3]) * 0.004 : parseFloat(m[3]);
const b = m[6] === '%' ? parseFloat(m[5]) * 0.004 : parseFloat(m[5]);
const rgb = oklabToRgb(L, a, b);
if (m[7] !== undefined) {
const alpha = parseFloat(m[7]);
rgb.a = m[8] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// LCH / LAB — CIE, D50 white point. Chrome serializes lch(20% 5 60) as
// `lch(20 5 60)`, so L arrives with or without its percent sign. In both
// spaces L runs 0..100 and 100% means 100.
m = str.match(/^lch\(\s*([\d.]+%?|none)\s+([\d.]+%?|none)\s+(-?[\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
if (m) {
const L = parseColorComponent(m[1], 100);
const C = parseColorComponent(m[2], 150);
const H = parseFloat(m[3]);
if (L == null || C == null || !Number.isFinite(H)) return null;
const rgb = lchToRgb(L, C, H);
rgb.a = parseAlphaToken(m[4]);
return rgb;
}
m = str.match(/^lab\(\s*([\d.]+%?|none)\s+(-?[\d.]+%?|none)\s+(-?[\d.]+%?|none)(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
if (m) {
const L = parseColorComponent(m[1], 100);
const a = parseColorComponent(m[2], 125);
const b = parseColorComponent(m[3], 125);
if (L == null || a == null || b == null) return null;
const rgb = labToRgb(L, a, b);
rgb.a = parseAlphaToken(m[4]);
return rgb;
}
// color(<space> c1 c2 c3 [/ alpha]) — what Chrome hands back for most
// color-mix() results and for any wide-gamut color an author wrote.
m = str.match(/^color\(\s*([a-z0-9-]+)\s+(-?[\d.eE+-]+%?|none)\s+(-?[\d.eE+-]+%?|none)\s+(-?[\d.eE+-]+%?|none)(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
if (m) {
const c1 = parseColorComponent(m[2]);
const c2 = parseColorComponent(m[3]);
const c3 = parseColorComponent(m[4]);
if (c1 == null || c2 == null || c3 == null) return null;
const rgb = colorFunctionToRgb(m[1].toLowerCase(), c1, c2, c3);
if (!rgb) return null;
rgb.a = parseAlphaToken(m[5]);
return rgb;
}
// HSL/HSLA — comma or space syntax, optional deg on hue.
m = str.match(/hsla?\(\s*(-?[\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%\s*[,\s]\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hslToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// HWB — hue whiteness% blackness%.
m = str.match(/hwb\(\s*(-?[\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hwbToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
const named = CSS_NAMED_COLORS[str.toLowerCase()];
if (named) return { ...named, a: 1 };
return null;
}
// True when a computed background-color string names no paint at all. Used to
// tell "this layer is see-through" (walk on to the ancestor) apart from "this
// layer has a color we could not read" (stop and abstain).
//
// `inherit` belongs here even though it is not literally see-through: it means
// "paint with the parent's background-color", and walking on to the parent IS
// that resolution. Real browsers resolve the keyword before getComputedStyle
// output; only jsdom's partial cascade hands it through verbatim, and treating
// it as unreadable would make the walk abstain on a surface it can know.
// (`currentcolor` is NOT here — it is real paint in the element's own text
// color; resolveBackgroundInfo substitutes the computed color for it.)
function isNoPaintColorValue(value) {
const v = String(value || '').trim().toLowerCase();
if (!v) return true;
return v === 'transparent' || v === 'none' || v === 'initial' || v === 'inherit' || v === 'unset' || v === 'revert' || v === 'revert-layer';
}
export {
isNeutralColor,
parseRgb,
relativeLuminance,
contrastRatio,
parseGradientColors,
extractColorFunctionTokens,
hasChroma,
getHue,
colorToHex,
oklabToRgb,
oklchToRgb,
labToRgb,
lchToRgb,
colorFunctionToRgb,
hslToRgb,
hwbToRgb,
CSS_NAMED_COLORS,
splitTopLevelCommas,
parseColorMix,
parseAnyColor,
compositeColorOver,
isNoPaintColorValue,
};
File diff suppressed because it is too large Load Diff
@@ -11,14 +11,21 @@ import {
isBrandFontOnOwnDomain,
} from '../shared/constants.mjs';
import {
CSS_NAMED_COLORS,
colorToHex,
compositeColorOver,
contrastRatio,
getHue,
hasChroma,
isNeutralColor,
isNoPaintColorValue,
oklchToRgb,
parseAnyColor,
parseColorMix,
parseGradientColors,
parseRgb,
relativeLuminance,
splitTopLevelCommas,
} from '../shared/color.mjs';
import { extractGoogleFontFamilies } from '../shared/fonts.mjs';
@@ -1731,7 +1738,7 @@ function readOwnBackgroundColor(el, computedStyle) {
// One element's background-color as the cascade walk sees it: computed style
// first (with the modern-color fallback), then, in static mode only,
// custom-prop resolution and the inline-shorthand peek. Shared by
// resolveBackground and resolveGradientStops so both walks read the same
// resolveBackgroundInfo and resolveGradientStops so both walks read the same
// surfaces.
function readCascadeBackgroundColor(current, style, customPropMap) {
let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
@@ -1755,7 +1762,19 @@ function readCascadeBackgroundColor(current, style, customPropMap) {
return bg;
}
function resolveBackground(el, win, customPropMap) {
// Walk up for the surface the element's text is painted on.
//
// Returns { color, unresolved }:
// • color set — the effective surface, overlays composited in.
// • unresolved: true — a layer on the way up paints a color this parser
// cannot read, so the surface is unknown. Callers
// must SKIP their contrast checks. Guessing white
// here is what flooded dark themes with false
// "on #ffffff" findings: one abstention costs a
// single finding, one wrong guess costs a hundred.
// • both null/false — no solid color, but a gradient or image is in
// play; callers fall back to its color stops.
function resolveBackgroundInfo(el, win, customPropMap) {
let current = el;
// Translucent layers (0.1 < a < 1) found on the way down to an opaque
// base. A browser composites these over the base; the old behavior
@@ -1783,59 +1802,81 @@ function resolveBackground(el, win, customPropMap) {
// body backgrounds.
// Real browsers serialize wide-gamut computed values as oklab()/oklch()
// (e.g. any color-mix() result), which plain parseRgb misses.
const bg = readCascadeBackgroundColor(current, style, customPropMap);
let bg = readCascadeBackgroundColor(current, style, customPropMap);
// `background-color: currentcolor` paints with the element's own text
// color — real paint whose value we know. Real browsers resolve the
// keyword before getComputedStyle output; jsdom hands it through
// verbatim, and without this substitution the layer would read as
// unparseable and force a needless abstention.
if ((!bg || bg.a < 0.1) && /^currentcolor$/i.test(String(style.backgroundColor || '').trim())) {
// The static cascade resolves var() text tokens before checks run, so
// style.color is normally already an rgb string here; parseColorResolved
// is defense in depth for any future caller that passes a live
// customPropMap (it matches the text-color path in checkElementColors
// and reduces to parseAnyColor when the map is null or absent).
bg = parseRgb(style.color) || parseColorResolved(style.color, customPropMap);
}
if (bg && bg.a > 0.1) {
if (bg.a >= 0.99) return flatten(bg);
if (bg.a >= 0.99) return { color: flatten(bg), unresolved: false };
overlays.push(bg);
} else if (!bg && !isNoPaintColorValue(style.backgroundColor)) {
// This layer names a color we could not parse (a color space we do not
// model, an unresolved var(), a syntax newer than the parser). It may
// well be opaque, which would make every ancestor below it invisible —
// so the surface is unknown and the walk stops here rather than
// reporting an ancestor the visitor never sees.
return { color: null, unresolved: true };
}
// No solid bg-color at this level, but this level paints an image. CSS
// stacks background-image layers first-on-top, so which layer leads
// decides what the visitor sees:
// • gradient on top — the gradient is the surface. Hand the caller a
// null color so it falls back to the gradient's own stops (body
// grounds, gradient buttons, hero sections).
// • url() on top — the surface is an image whose pixels this engine
// cannot read, and it may fully cover every layer and ancestor
// beneath it. Same contract as an unparseable color: abstain, so
// the gradient-stop fallback never measures a gradient the image
// hides (the shipped miss: `url(photo), linear-gradient(...)`
// reported low-contrast against the invisible gradient's stops).
if (hasGradientOrUrl) {
const layers = splitTopLevelCommas(bgImage);
const topPaintLayer = layers.find(
(layer) => /gradient\s*\(/i.test(layer) || /url\s*\(/i.test(layer),
);
const gradientOnTop = !!topPaintLayer
&& /gradient\s*\(/i.test(topPaintLayer)
&& !/^\s*url\s*\(/i.test(topPaintLayer);
if (!gradientOnTop) return { color: null, unresolved: true };
// Gradient on top of a url() layer: the image shows through wherever
// the gradient is not fully opaque, so a translucent wash like
// `linear-gradient(rgba(0,0,0,.2), rgba(0,0,0,.2)), url(photo)` paints
// a blend with pixels this engine cannot read. Only a gradient whose
// every readable stop is opaque provably covers the image; otherwise
// the surface is unknown — abstain rather than hand callers gradient
// stops (or a stop average) the visitor never sees unmixed.
const urlBeneath = layers.some(
(layer) => layer !== topPaintLayer && /url\s*\(/i.test(layer),
);
if (urlBeneath) {
const topStops = parseGradientColors(topPaintLayer);
const provablyOpaque = topStops.length > 0 && topStops.every((s) => (s.a ?? 1) >= 0.99);
if (!provablyOpaque) return { color: null, unresolved: true };
}
return { color: null, unresolved: false };
}
// No solid bg-color at this level. If THIS level has a gradient/url
// with no underlying solid color we can read:
// • on body/html: assume white. Body-level gradients are almost
// always decorative texture (paper grain, noise) on top of a
// solid bg-color the page set via `background: var(--paper)`
// shorthand — which jsdom can't decompose into bg-color. The
// downstream gradient-stops fallback path produces catastrophic
// false positives in this case (gradient noise stops have
// accidental browns/blacks that look like card backgrounds).
// • on other elements: bail to null and let the caller fall back
// to gradient stops (gradient buttons / hero sections are real
// bgs worth checking against).
// A gradient or image with no solid color under it, at any level
// including body/html, means the visible ground is that layer itself.
// Return null so the caller measures against the actual gradient stops,
// or skips when nothing is parseable — skipping beats a wrong ratio.
//
// Body/html used to assume white here, a guard written for jsdom, which
// never decomposed the `background:` shorthand and so could not see the
// solid paper color a texture gradient usually sits on. It turned every
// light-on-dark page into a wall of low-contrast false positives (a dark
// oklch body gradient produced ~120 "on #ffffff" findings on one site).
// Both engines can see shorthand solids now — the browser natively, the
// static cascade via expandStaticDeclaration + var() resolution — so a
// missing solid is real, and the old failure case cannot recur: opaque
// stops fully cover any hidden solid (they ARE the ground), alpha stops
// composite over the resolved base or the white canvas default, and
// unresolvable stops drop rather than guess.
if (hasGradientOrUrl) return null;
current = current.parentElement;
}
return flatten({ r: 255, g: 255, b: 255, a: 1 });
// Every layer up to the document root was genuinely see-through, so the
// browser paints its default canvas. This is the ONLY case that earns the
// white assumption.
return { color: flatten({ r: 255, g: 255, b: 255, a: 1 }), unresolved: false };
}
// parseGradientColors (shared) reads only the legacy serializations: rgb()
// and hex stops. Browsers keep modern-space stops in computed backgroundImage
// exactly as authored — `linear-gradient(oklch(7% 0.006 95), …)` stays oklch —
// which is what every token-driven page produces. Route those through
// parseAnyColor so a gradient ground is measurable rather than invisible.
function parseGradientColorsModern(bgImage) {
if (!bgImage || !/gradient/i.test(bgImage)) return [];
const colors = parseGradientColors(bgImage);
for (const m of bgImage.matchAll(/(?:oklch|oklab|hsla?|hwb)\(\s*[^()]*\)/gi)) {
const c = parseAnyColor(m[0]);
if (c) colors.push(c);
}
return colors;
function resolveBackground(el, win, customPropMap) {
return resolveBackgroundInfo(el, win, customPropMap).color;
}
// Walk parents looking for a gradient background and return its color stops.
@@ -1861,7 +1902,10 @@ function resolveGradientStops(el, win, customPropMap) {
if (bgImage && bgImage !== 'none' && /url\s*\(/i.test(bgImage)) return null;
let stops = null;
if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
const parsed = parseGradientColorsModern(bgImage);
// parseGradientColors (shared) reads modern-space stops too — oklch,
// color-mix and friends via balanced-paren token capture — so browser
// computed values that keep the authored syntax stay measurable.
const parsed = parseGradientColors(bgImage);
if (parsed.length > 0) stops = parsed;
}
if (!stops && !DETECTOR_IS_BROWSER) {
@@ -1869,7 +1913,7 @@ function resolveGradientStops(el, win, customPropMap) {
const rawStyle = current.getAttribute?.('style') || '';
const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
if (bgMatch && /gradient/i.test(bgMatch[1])) {
const parsed = parseGradientColorsModern(bgMatch[1]);
const parsed = parseGradientColors(bgMatch[1]);
if (parsed.length > 0) stops = parsed;
}
}
@@ -2115,13 +2159,19 @@ function checkElementColorsDOM(el) {
if (style.visibility === 'hidden' || effectiveOpacityDOM(el) <= 0.02) return [];
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
let effectiveBg = resolveBackground(el);
const bgInfo = resolveBackgroundInfo(el);
let effectiveBg = bgInfo.color;
// An unreadable surface anywhere up the chain: skip the gradient-stop
// fallback too, so nothing downstream measures against a ground we never
// resolved.
let surfaceUnresolved = bgInfo.unresolved;
let ownBg = readOwnBackgroundColor(el, style);
if (!ownBg || (ownBg.a ?? 1) <= 0.5) {
const pseudoSurface = readPseudoSurfaceDOM(el, rect);
if (pseudoSurface) {
ownBg = pseudoSurface;
effectiveBg = pseudoSurface;
surfaceUnresolved = false;
}
}
return checkColors({
@@ -2133,8 +2183,8 @@ function checkElementColorsDOM(el) {
// an oklch token near its own oklch background).
textColor: parseRgb(style.color) || parseAnyColor(style.color),
bgColor: ownBg,
effectiveBg,
effectiveBgStops: effectiveBg ? null : resolveGradientStops(el),
effectiveBg: surfaceUnresolved ? null : effectiveBg,
effectiveBgStops: surfaceUnresolved || effectiveBg ? null : resolveGradientStops(el),
fontSize: parseFloat(style.fontSize) || 16,
fontWeight: parseInt(style.fontWeight) || 400,
hasDirectText,
@@ -2284,283 +2334,6 @@ function resolveVarRefs(raw, customPropMap, depth = 0) {
});
}
// OKLCH → sRGB conversion (Björn Ottosson's matrices). L in 0..1 (or %),
// C in 0..~0.4 typical, H in degrees. Returns clamped {r,g,b,a:1} in 0..255.
// Needed because jsdom doesn't compute oklch() values — getComputedStyle
// returns the literal "oklch(...)" string. Without this, the entire
// Tailwind v4 color palette (which is OKLCH-based) is invisible to the
// detector's contrast / color checks.
function oklchToRgb(L, C, H) {
const hRad = (H * Math.PI) / 180;
return oklabToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
}
function oklabToRgb(L, a, b) {
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
const rLin = 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc;
const gLin = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc;
const bLin = -0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc;
const enc = (x) => {
const c = Math.max(0, Math.min(1, x));
return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
};
return {
r: Math.round(enc(rLin) * 255),
g: Math.round(enc(gLin) * 255),
b: Math.round(enc(bLin) * 255),
a: 1,
};
}
function hslToRgb(h, s, l) {
h = ((h % 360) + 360) % 360;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m0 = l - c / 2;
const [r, g, b] =
h < 60 ? [c, x, 0] :
h < 120 ? [x, c, 0] :
h < 180 ? [0, c, x] :
h < 240 ? [0, x, c] :
h < 300 ? [x, 0, c] : [c, 0, x];
return {
r: Math.round((r + m0) * 255),
g: Math.round((g + m0) * 255),
b: Math.round((b + m0) * 255),
a: 1,
};
}
function hwbToRgb(h, w, bl) {
if (w + bl >= 1) {
const g = Math.round((w / (w + bl)) * 255);
return { r: g, g, b: g, a: 1 };
}
const base = hslToRgb(h, 1, 0.5);
const mix = (c) => Math.round(((c / 255) * (1 - w - bl) + w) * 255);
return { r: mix(base.r), g: mix(base.g), b: mix(base.b), a: 1 };
}
// Common CSS named colors — the handful that actually show up in generated
// UIs, not the full 148-name spec list. Includes the achromatic names so a
// named gray parses (and correctly reads as no-chroma) instead of being
// treated as an unknown color.
const CSS_NAMED_COLORS = {
black: { r: 0, g: 0, b: 0 },
white: { r: 255, g: 255, b: 255 },
gray: { r: 128, g: 128, b: 128 },
grey: { r: 128, g: 128, b: 128 },
silver: { r: 192, g: 192, b: 192 },
dimgray: { r: 105, g: 105, b: 105 },
darkgray: { r: 169, g: 169, b: 169 },
lightgray: { r: 211, g: 211, b: 211 },
gainsboro: { r: 220, g: 220, b: 220 },
whitesmoke: { r: 245, g: 245, b: 245 },
red: { r: 255, g: 0, b: 0 },
crimson: { r: 220, g: 20, b: 60 },
tomato: { r: 255, g: 99, b: 71 },
coral: { r: 255, g: 127, b: 80 },
salmon: { r: 250, g: 128, b: 114 },
orange: { r: 255, g: 165, b: 0 },
gold: { r: 255, g: 215, b: 0 },
yellow: { r: 255, g: 255, b: 0 },
olive: { r: 128, g: 128, b: 0 },
lime: { r: 0, g: 255, b: 0 },
green: { r: 0, g: 128, b: 0 },
teal: { r: 0, g: 128, b: 128 },
turquoise: { r: 64, g: 224, b: 208 },
cyan: { r: 0, g: 255, b: 255 },
aqua: { r: 0, g: 255, b: 255 },
skyblue: { r: 135, g: 206, b: 235 },
dodgerblue: { r: 30, g: 144, b: 255 },
blue: { r: 0, g: 0, b: 255 },
navy: { r: 0, g: 0, b: 128 },
indigo: { r: 75, g: 0, b: 130 },
rebeccapurple: { r: 102, g: 51, b: 153 },
purple: { r: 128, g: 0, b: 128 },
violet: { r: 238, g: 130, b: 238 },
orchid: { r: 218, g: 112, b: 214 },
magenta: { r: 255, g: 0, b: 255 },
fuchsia: { r: 255, g: 0, b: 255 },
hotpink: { r: 255, g: 105, b: 180 },
pink: { r: 255, g: 192, b: 203 },
maroon: { r: 128, g: 0, b: 0 },
};
// Split a string on top-level commas (ignoring commas nested in parens).
function splitTopLevelCommas(str) {
const parts = [];
let depth = 0, start = 0;
for (let i = 0; i < str.length; i++) {
const ch = str[i];
if (ch === '(') depth++;
else if (ch === ')') depth = Math.max(0, depth - 1);
else if (ch === ',' && depth === 0) {
parts.push(str.slice(start, i).trim());
start = i + 1;
}
}
const tail = str.slice(start).trim();
if (tail) parts.push(tail);
return parts;
}
// Evaluate a CSS color-mix() expression to {r,g,b,a}. Returns null when
// the expression can't be resolved (unresolved var(), unknown colors).
//
// Mixing is done with premultiplied alpha in sRGB regardless of the
// declared interpolation space. That is exact for the dominant generated-UI
// pattern — `color-mix(in oklab, <color> N%, transparent)` — where the
// result is simply <color> at alpha N% in ANY rectangular space, and a
// close-enough approximation for opaque-opaque mixes (the detector only
// consumes these values for contrast/chroma thresholds, not for display).
function parseColorMix(str) {
const m = String(str).trim().match(/^color-mix\(/i);
if (!m) return null;
// Balanced-paren capture of the arguments.
let depth = 0, end = -1;
const open = str.indexOf('(');
for (let i = open; i < str.length; i++) {
if (str[i] === '(') depth++;
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
}
if (end < 0) return null;
const args = splitTopLevelCommas(str.slice(open + 1, end));
if (args.length !== 3 || !/^in\s/i.test(args[0])) return null;
const parseComponent = (component) => {
// Percentage may lead or trail the color per spec.
let pct = null;
let colorStr = component;
const trail = component.match(/\s+([\d.]+)%$/);
const lead = component.match(/^([\d.]+)%\s+/);
if (trail) { pct = parseFloat(trail[1]); colorStr = component.slice(0, trail.index).trim(); }
else if (lead) { pct = parseFloat(lead[1]); colorStr = component.slice(lead[0].length).trim(); }
let color;
if (/^transparent$/i.test(colorStr)) color = { r: 0, g: 0, b: 0, a: 0 };
else color = parseAnyColor(colorStr);
if (!color) return null;
return { color, pct };
};
const c1 = parseComponent(args[1]);
const c2 = parseComponent(args[2]);
if (!c1 || !c2) return null;
let p1 = c1.pct, p2 = c2.pct;
if (p1 == null && p2 == null) { p1 = 50; p2 = 50; }
else if (p1 == null) p1 = 100 - p2;
else if (p2 == null) p2 = 100 - p1;
const sum = p1 + p2;
if (sum <= 0) return null;
// Per spec: weights normalize to sum; when sum < 100 the result alpha is
// additionally scaled by sum/100.
const w1 = p1 / sum, w2 = p2 / sum;
const alphaScale = sum < 100 ? sum / 100 : 1;
const a1 = c1.color.a ?? 1, a2 = c2.color.a ?? 1;
const a = (a1 * w1 + a2 * w2) * alphaScale;
if (a <= 0) return { r: 0, g: 0, b: 0, a: 0 };
const mix = (ch) => Math.round((c1.color[ch] * a1 * w1 + c2.color[ch] * a2 * w2) / (a1 * w1 + a2 * w2));
return { r: mix('r'), g: mix('g'), b: mix('b'), a: Math.min(1, a) };
}
// Composite a translucent color over an opaque(ish) base (simple
// source-over in sRGB). Returns an opaque {r,g,b,a:1}.
function compositeColorOver(top, base) {
const a = top.a ?? 1;
return {
r: Math.round(top.r * a + base.r * (1 - a)),
g: Math.round(top.g * a + base.g * (1 - a)),
b: Math.round(top.b * a + base.b * (1 - a)),
a: 1,
};
}
// Extended color parser: rgb/rgba/hex/oklch/oklab/hsl/hwb/color-mix/common
// named colors. Returns null on no match. Use this when the input might be
// any CSS color form; use plain parseRgb when you only expect computed rgb()
// values from real browsers.
function parseAnyColor(s) {
if (!s || typeof s !== 'string') return null;
const str = s.trim();
if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
if (/^color-mix\(/i.test(str)) return parseColorMix(str);
let m;
m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+))?\s*\)/);
if (m) return { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: m[4] !== undefined ? +m[4] : 1 };
m = str.match(/^#([0-9a-f]{3,8})$/i);
if (m) {
const h = m[1];
if (h.length === 3 || h.length === 4) {
return {
r: parseInt(h[0] + h[0], 16),
g: parseInt(h[1] + h[1], 16),
b: parseInt(h[2] + h[2], 16),
a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
};
}
if (h.length === 6 || h.length === 8) {
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
};
}
}
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
// Match L (with optional %), then C and H separated permissively.
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const Lnum = parseFloat(m[1]);
const L = m[2] === '%' ? Lnum / 100 : Lnum;
const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
if (m[5] !== undefined) {
const alpha = parseFloat(m[5]);
rgb.a = m[6] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// OKLAB — a/b are signed axes; percentages map 100% → 0.4.
m = str.match(/oklab\(\s*([\d.]+)(%?)\s+(-?[\d.]+)(%?)\s+(-?[\d.]+)(%?)(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const L = m[2] === '%' ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
const a = m[4] === '%' ? parseFloat(m[3]) * 0.004 : parseFloat(m[3]);
const b = m[6] === '%' ? parseFloat(m[5]) * 0.004 : parseFloat(m[5]);
const rgb = oklabToRgb(L, a, b);
if (m[7] !== undefined) {
const alpha = parseFloat(m[7]);
rgb.a = m[8] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// HSL/HSLA — comma or space syntax, optional deg on hue.
m = str.match(/hsla?\(\s*(-?[\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%\s*[,\s]\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hslToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// HWB — hue whiteness% blackness%.
m = str.match(/hwb\(\s*(-?[\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hwbToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
const named = CSS_NAMED_COLORS[str.toLowerCase()];
if (named) return { ...named, a: 1 };
return null;
}
// Resolve var() refs in a color string (via customPropMap), then parse.
// Returns null on any failure. Used in jsdom-mode paths where
@@ -2923,15 +2696,24 @@ function checkElementGlowDOM(el) {
if (!boxShadow && !textShadow) return [];
// Use parent's background — glow radiates outward, so the surrounding context matters
// If resolveBackground returns null (gradient), try to infer from the gradient colors
let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el);
if (!parentBg) {
const parentBgInfo = resolveBackgroundInfo(el.parentElement || el);
// Unknown surface (an unreadable layer on the way up): skip only the
// gradient hunt below, which would walk PAST that layer and score the
// glow against a background the visitor never sees. checkGlow still runs
// with a null surface: the zero-offset chromatic halo tell holds on ANY
// background, and the static loop already passes the unresolved walk's
// null color straight through (detect-html.mjs uses resolveBackground).
let parentBg = parentBgInfo.color;
if (!parentBg && !parentBgInfo.unresolved) {
// Gradient background — sample its colors to determine if it's dark.
// Modern-syntax parsing matters here: body-level gradients now reach this
// fallback in browser mode, and their stops usually serialize as oklch.
// fallback in browser mode, and their stops usually serialize as oklch
// which the shared parseGradientColors reads via its color-function
// token capture.
let cur = el.parentElement;
while (cur && cur.nodeType === 1) {
const bgImage = getComputedStyle(cur).backgroundImage || '';
const gradColors = parseGradientColorsModern(bgImage);
const gradColors = parseGradientColors(bgImage);
if (gradColors.length > 0) {
// Average the gradient colors
const avg = { r: 0, g: 0, b: 0 };
@@ -2975,10 +2757,13 @@ function checkElementAIPaletteDOM(el) {
const hue = getHue(textColor);
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
if (isAIPalette) {
const parentBg = el.parentElement ? resolveBackground(el.parentElement) : null;
// Also check gradient parents
let effectiveBg = parentBg;
if (!effectiveBg) {
const parentBgInfo = el.parentElement
? resolveBackgroundInfo(el.parentElement)
: { color: null, unresolved: false };
// Unknown surface: leave effectiveBg null (no finding) rather than
// hunting gradient ancestors past a layer we could not read.
let effectiveBg = parentBgInfo.color;
if (!effectiveBg && !parentBgInfo.unresolved) {
let cur = el.parentElement;
while (cur && cur.nodeType === 1) {
const gi = getComputedStyle(cur).backgroundImage || '';
@@ -3784,7 +3569,8 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
const effectiveBg = resolveBackground(el, window, customPropMap);
const bgInfo = resolveBackgroundInfo(el, window, customPropMap);
const effectiveBg = bgInfo.color;
// jsdom returns literal "var(--X)" / "oklch(...)" for color, so plain
// parseRgb misses Tailwind-tokenized text colors. Resolve through the
// customPropMap first; fall back to parseRgb for vanilla rgb() pages.
@@ -3830,11 +3616,13 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
// element itself has no usable own background, that pseudo is the real
// surface for contrast purposes.
let finalEffectiveBg = effectiveBg;
let surfaceUnresolved = bgInfo.unresolved;
if ((!ownBg || (ownBg.a ?? 1) <= 0.5) && typeof window.getPseudoSurface === 'function') {
const pseudoSurface = window.getPseudoSurface(el);
if (pseudoSurface) {
ownBg = pseudoSurface;
finalEffectiveBg = pseudoSurface;
surfaceUnresolved = false;
}
}
@@ -3842,8 +3630,9 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
tag,
textColor,
bgColor: ownBg,
effectiveBg: finalEffectiveBg,
effectiveBgStops: finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
// Unknown surface: hand the checks nothing rather than a guess.
effectiveBg: surfaceUnresolved ? null : finalEffectiveBg,
effectiveBgStops: surfaceUnresolved || finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
fontSize: parseFloat(style.fontSize) || 16,
fontWeight: parseInt(style.fontWeight) || 400,
hasDirectText,
@@ -5663,6 +5452,7 @@ export {
checkHtmlPatterns,
readOwnBackgroundColor,
resolveBackground,
resolveBackgroundInfo,
resolveGradientStops,
parseRadiusToPx,
resolveBorderRadiusPx,
@@ -71,11 +71,43 @@ function contrastRatio(c1, c2) {
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
// The CSS color functions worth pulling out of a longer declaration. The set
// is deliberately closed: `linear-gradient(` and `url(` also look like
// `name(` and must not be read as colors.
const COLOR_FUNCTION_NAMES = new Set([
'rgb', 'rgba', 'hsl', 'hsla', 'hwb', 'oklch', 'oklab', 'lch', 'lab', 'color', 'color-mix',
]);
// Pull every color-function token out of a value, with balanced-paren capture
// so nested forms (`color-mix(in oklab, oklch(...) 20%, transparent)`) survive
// whole. Returns the raw substrings in source order.
function extractColorFunctionTokens(value) {
const str = String(value || '');
const tokens = [];
const re = /([a-z][a-z-]*)\(/gi;
let m;
while ((m = re.exec(str)) !== null) {
if (!COLOR_FUNCTION_NAMES.has(m[1].toLowerCase())) continue;
let depth = 0, end = -1;
for (let i = m.index + m[0].length - 1; i < str.length; i++) {
if (str[i] === '(') depth++;
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
}
if (end < 0) break;
tokens.push(str.slice(m.index, end + 1));
re.lastIndex = end + 1;
}
return tokens;
}
function parseGradientColors(bgImage) {
if (!bgImage || !bgImage.includes('gradient')) return [];
const colors = [];
for (const m of bgImage.matchAll(/rgba?\([^)]+\)/g)) {
const c = parseRgb(m[0]);
// Stops arrive in whatever syntax the author wrote and the browser kept.
// A dark ground painted as `linear-gradient(oklch(...), oklch(...))` used
// to read as a gradient with no stops at all.
for (const token of extractColorFunctionTokens(bgImage)) {
const c = parseAnyColor(token);
if (c) colors.push(c);
}
for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
@@ -112,13 +144,445 @@ function colorToHex(c) {
return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
}
// ─── Color-space conversions ────────────────────────────────────────────────
//
// Every function here lands on 8-bit sRGB, clamped to gamut. Chrome, Safari,
// and Firefox all keep the authored color space in getComputedStyle output
// (`oklch(0.84 0.19 80.46)`, `lch(20 5 60)`, `color(srgb 1.04 0.72 -0.21)`),
// so a detector that only reads rgb() is blind on any modern palette. The
// expected outputs are pinned in tests/detect-antipatterns.test.js against
// what Chrome itself paints for the same strings.
function clamp01(x) {
return Number.isFinite(x) ? Math.max(0, Math.min(1, x)) : 0;
}
// Linear-light sRGB channel to the encoded 0-255 value.
function encodeSrgbChannel(x) {
const c = clamp01(x);
return Math.round((c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055) * 255);
}
function decodeSrgbChannel(x) {
const c = Number.isFinite(x) ? x : 0;
const sign = c < 0 ? -1 : 1;
const abs = Math.abs(c);
return sign * (abs <= 0.04045 ? abs / 12.92 : Math.pow((abs + 0.055) / 1.055, 2.4));
}
function linearSrgbToColor(r, g, b, a = 1) {
return { r: encodeSrgbChannel(r), g: encodeSrgbChannel(g), b: encodeSrgbChannel(b), a };
}
// OKLab to sRGB (Björn Ottosson's matrices). L in 0..1, a/b are signed axes.
function oklabToRgb(L, a, b) {
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
return linearSrgbToColor(
4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc,
-1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc,
-0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc,
);
}
// OKLCH to sRGB. L in 0..1, C in 0..~0.4 typical, H in degrees. Chroma past
// the sRGB gamut clamps per channel rather than producing NaN.
function oklchToRgb(L, C, H) {
const hRad = (H * Math.PI) / 180;
return oklabToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
}
// CIE Lab to sRGB. CSS lab()/lch() use the D50 white point; the matrix below
// is the Bradford-adapted XYZ-D50 to linear-sRGB transform from CSS Color 4.
function labToRgb(L, a, b) {
const kappa = 24389 / 27, epsilon = 216 / 24389;
const fy = (L + 16) / 116, fx = fy + a / 500, fz = fy - b / 200;
const invert = (t) => (t * t * t > epsilon ? t * t * t : (116 * t - 16) / kappa);
const yr = L > kappa * epsilon ? Math.pow((L + 16) / 116, 3) : L / kappa;
const Xn = 0.3457 / 0.3585, Zn = (1 - 0.3457 - 0.3585) / 0.3585;
const x = invert(fx) * Xn, y = yr, z = invert(fz) * Zn;
return linearSrgbToColor(
3.1341359569958707 * x - 1.6173863321612538 * y - 0.4906619460083532 * z,
-0.9787955029120890 * x + 1.9162545672595240 * y + 0.0334427311613195 * z,
0.0719553798841168 * x - 0.2289768264158322 * y + 1.4053860583241250 * z,
);
}
function lchToRgb(L, C, H) {
const hRad = (H * Math.PI) / 180;
return labToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
}
// color(<space> c1 c2 c3) for the spaces that turn up in real stylesheets.
// `srgb` is what Chrome serializes most color-mix() results into, routinely
// with channels outside 0..1. Spaces we do not model return null so callers
// abstain instead of measuring against a color we invented.
function colorFunctionToRgb(space, c1, c2, c3) {
switch (space) {
case 'srgb':
return { r: Math.round(clamp01(c1) * 255), g: Math.round(clamp01(c2) * 255), b: Math.round(clamp01(c3) * 255), a: 1 };
case 'srgb-linear':
return linearSrgbToColor(c1, c2, c3);
case 'display-p3': {
const [R, G, B] = [decodeSrgbChannel(c1), decodeSrgbChannel(c2), decodeSrgbChannel(c3)];
return linearSrgbToColor(
1.2249401762805587 * R - 0.2249404646817506 * G + 0.0000002884022551 * B,
-0.0420569547096138 * R + 1.0420571661298634 * G - 0.0000002113202247 * B,
-0.0196375587040044 * R - 0.0786360772174755 * G + 1.0982736359214800 * B,
);
}
default:
return null;
}
}
function hslToRgb(h, s, l) {
h = ((h % 360) + 360) % 360;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m0 = l - c / 2;
const [r, g, b] =
h < 60 ? [c, x, 0] :
h < 120 ? [x, c, 0] :
h < 180 ? [0, c, x] :
h < 240 ? [0, x, c] :
h < 300 ? [x, 0, c] : [c, 0, x];
return {
r: Math.round((r + m0) * 255),
g: Math.round((g + m0) * 255),
b: Math.round((b + m0) * 255),
a: 1,
};
}
function hwbToRgb(h, w, bl) {
if (w + bl >= 1) {
const g = Math.round((w / (w + bl)) * 255);
return { r: g, g, b: g, a: 1 };
}
const base = hslToRgb(h, 1, 0.5);
const mix = (c) => Math.round(((c / 255) * (1 - w - bl) + w) * 255);
return { r: mix(base.r), g: mix(base.g), b: mix(base.b), a: 1 };
}
// Common CSS named colors — the handful that actually show up in generated
// UIs, not the full 148-name spec list. Includes the achromatic names so a
// named gray parses (and correctly reads as no-chroma) instead of being
// treated as an unknown color.
const CSS_NAMED_COLORS = {
black: { r: 0, g: 0, b: 0 },
white: { r: 255, g: 255, b: 255 },
gray: { r: 128, g: 128, b: 128 },
grey: { r: 128, g: 128, b: 128 },
silver: { r: 192, g: 192, b: 192 },
dimgray: { r: 105, g: 105, b: 105 },
darkgray: { r: 169, g: 169, b: 169 },
lightgray: { r: 211, g: 211, b: 211 },
gainsboro: { r: 220, g: 220, b: 220 },
whitesmoke: { r: 245, g: 245, b: 245 },
red: { r: 255, g: 0, b: 0 },
crimson: { r: 220, g: 20, b: 60 },
tomato: { r: 255, g: 99, b: 71 },
coral: { r: 255, g: 127, b: 80 },
salmon: { r: 250, g: 128, b: 114 },
orange: { r: 255, g: 165, b: 0 },
gold: { r: 255, g: 215, b: 0 },
yellow: { r: 255, g: 255, b: 0 },
olive: { r: 128, g: 128, b: 0 },
lime: { r: 0, g: 255, b: 0 },
green: { r: 0, g: 128, b: 0 },
teal: { r: 0, g: 128, b: 128 },
turquoise: { r: 64, g: 224, b: 208 },
cyan: { r: 0, g: 255, b: 255 },
aqua: { r: 0, g: 255, b: 255 },
skyblue: { r: 135, g: 206, b: 235 },
dodgerblue: { r: 30, g: 144, b: 255 },
blue: { r: 0, g: 0, b: 255 },
navy: { r: 0, g: 0, b: 128 },
indigo: { r: 75, g: 0, b: 130 },
rebeccapurple: { r: 102, g: 51, b: 153 },
purple: { r: 128, g: 0, b: 128 },
violet: { r: 238, g: 130, b: 238 },
orchid: { r: 218, g: 112, b: 214 },
magenta: { r: 255, g: 0, b: 255 },
fuchsia: { r: 255, g: 0, b: 255 },
hotpink: { r: 255, g: 105, b: 180 },
pink: { r: 255, g: 192, b: 203 },
maroon: { r: 128, g: 0, b: 0 },
};
// Split a string on top-level commas (ignoring commas nested in parens).
function splitTopLevelCommas(str) {
const parts = [];
let depth = 0, start = 0;
for (let i = 0; i < str.length; i++) {
const ch = str[i];
if (ch === '(') depth++;
else if (ch === ')') depth = Math.max(0, depth - 1);
else if (ch === ',' && depth === 0) {
parts.push(str.slice(start, i).trim());
start = i + 1;
}
}
const tail = str.slice(start).trim();
if (tail) parts.push(tail);
return parts;
}
// Evaluate a CSS color-mix() expression to {r,g,b,a}. Returns null when
// the expression can't be resolved (unresolved var(), unknown colors).
//
// Mixing is done with premultiplied alpha in sRGB regardless of the
// declared interpolation space. That is exact for the dominant generated-UI
// pattern — `color-mix(in oklab, <color> N%, transparent)` — where the
// result is simply <color> at alpha N% in ANY rectangular space, and a
// close-enough approximation for opaque-opaque mixes (the detector only
// consumes these values for contrast/chroma thresholds, not for display).
function parseColorMix(str) {
const m = String(str).trim().match(/^color-mix\(/i);
if (!m) return null;
// Balanced-paren capture of the arguments.
let depth = 0, end = -1;
const open = str.indexOf('(');
for (let i = open; i < str.length; i++) {
if (str[i] === '(') depth++;
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
}
if (end < 0) return null;
const args = splitTopLevelCommas(str.slice(open + 1, end));
if (args.length !== 3 || !/^in\s/i.test(args[0])) return null;
const parseComponent = (component) => {
// Percentage may lead or trail the color per spec.
let pct = null;
let colorStr = component;
const trail = component.match(/\s+([\d.]+)%$/);
const lead = component.match(/^([\d.]+)%\s+/);
if (trail) { pct = parseFloat(trail[1]); colorStr = component.slice(0, trail.index).trim(); }
else if (lead) { pct = parseFloat(lead[1]); colorStr = component.slice(lead[0].length).trim(); }
let color;
if (/^transparent$/i.test(colorStr)) color = { r: 0, g: 0, b: 0, a: 0 };
else color = parseAnyColor(colorStr);
if (!color) return null;
return { color, pct };
};
const c1 = parseComponent(args[1]);
const c2 = parseComponent(args[2]);
if (!c1 || !c2) return null;
let p1 = c1.pct, p2 = c2.pct;
if (p1 == null && p2 == null) { p1 = 50; p2 = 50; }
else if (p1 == null) p1 = 100 - p2;
else if (p2 == null) p2 = 100 - p1;
const sum = p1 + p2;
if (sum <= 0) return null;
// Per spec: weights normalize to sum; when sum < 100 the result alpha is
// additionally scaled by sum/100.
const w1 = p1 / sum, w2 = p2 / sum;
const alphaScale = sum < 100 ? sum / 100 : 1;
const a1 = c1.color.a ?? 1, a2 = c2.color.a ?? 1;
const a = (a1 * w1 + a2 * w2) * alphaScale;
if (a <= 0) return { r: 0, g: 0, b: 0, a: 0 };
const mix = (ch) => Math.round((c1.color[ch] * a1 * w1 + c2.color[ch] * a2 * w2) / (a1 * w1 + a2 * w2));
return { r: mix('r'), g: mix('g'), b: mix('b'), a: Math.min(1, a) };
}
// Composite a translucent color over an opaque(ish) base (simple
// source-over in sRGB). Returns an opaque {r,g,b,a:1}.
function compositeColorOver(top, base) {
const a = top.a ?? 1;
return {
r: Math.round(top.r * a + base.r * (1 - a)),
g: Math.round(top.g * a + base.g * (1 - a)),
b: Math.round(top.b * a + base.b * (1 - a)),
a: 1,
};
}
// A color() / lab() / lch() component: a bare number, a percentage against
// `scale`, or the `none` keyword (which resolves to zero for our purposes).
function parseColorComponent(token, scale = 1) {
if (token == null) return null;
const t = String(token).trim();
if (/^none$/i.test(t)) return 0;
const num = parseFloat(t);
if (!Number.isFinite(num)) return null;
return t.endsWith('%') ? (num / 100) * scale : num;
}
function parseAlphaToken(token) {
if (token == null) return 1;
const t = String(token).trim();
if (/^none$/i.test(t)) return 1;
const num = parseFloat(t);
if (!Number.isFinite(num)) return 1;
return t.endsWith('%') ? num / 100 : num;
}
// Extended color parser: rgb/rgba/hex/oklch/oklab/lch/lab/hsl/hwb/color()/
// color-mix/common named colors. Returns null on no match. Use this when the
// input might be any CSS color form; use plain parseRgb when you only expect
// computed rgb() values from real browsers.
function parseAnyColor(s) {
if (!s || typeof s !== 'string') return null;
const str = s.trim();
if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
if (/^color-mix\(/i.test(str)) return parseColorMix(str);
let m;
m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/);
if (m) {
const c = { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: 1 };
if (m[4] !== undefined) c.a = m[5] === '%' ? parseFloat(m[4]) / 100 : +m[4];
return c;
}
m = str.match(/^#([0-9a-f]{3,8})$/i);
if (m) {
const h = m[1];
if (h.length === 3 || h.length === 4) {
return {
r: parseInt(h[0] + h[0], 16),
g: parseInt(h[1] + h[1], 16),
b: parseInt(h[2] + h[2], 16),
a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
};
}
if (h.length === 6 || h.length === 8) {
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
};
}
}
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
// Match L (with optional %), then C and H separated permissively.
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const Lnum = parseFloat(m[1]);
const L = m[2] === '%' ? Lnum / 100 : Lnum;
const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
if (m[5] !== undefined) {
const alpha = parseFloat(m[5]);
rgb.a = m[6] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// OKLAB — a/b are signed axes; percentages map 100% → 0.4.
m = str.match(/oklab\(\s*([\d.]+)(%?)\s+(-?[\d.]+)(%?)\s+(-?[\d.]+)(%?)(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const L = m[2] === '%' ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
const a = m[4] === '%' ? parseFloat(m[3]) * 0.004 : parseFloat(m[3]);
const b = m[6] === '%' ? parseFloat(m[5]) * 0.004 : parseFloat(m[5]);
const rgb = oklabToRgb(L, a, b);
if (m[7] !== undefined) {
const alpha = parseFloat(m[7]);
rgb.a = m[8] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// LCH / LAB — CIE, D50 white point. Chrome serializes lch(20% 5 60) as
// `lch(20 5 60)`, so L arrives with or without its percent sign. In both
// spaces L runs 0..100 and 100% means 100.
m = str.match(/^lch\(\s*([\d.]+%?|none)\s+([\d.]+%?|none)\s+(-?[\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
if (m) {
const L = parseColorComponent(m[1], 100);
const C = parseColorComponent(m[2], 150);
const H = parseFloat(m[3]);
if (L == null || C == null || !Number.isFinite(H)) return null;
const rgb = lchToRgb(L, C, H);
rgb.a = parseAlphaToken(m[4]);
return rgb;
}
m = str.match(/^lab\(\s*([\d.]+%?|none)\s+(-?[\d.]+%?|none)\s+(-?[\d.]+%?|none)(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
if (m) {
const L = parseColorComponent(m[1], 100);
const a = parseColorComponent(m[2], 125);
const b = parseColorComponent(m[3], 125);
if (L == null || a == null || b == null) return null;
const rgb = labToRgb(L, a, b);
rgb.a = parseAlphaToken(m[4]);
return rgb;
}
// color(<space> c1 c2 c3 [/ alpha]) — what Chrome hands back for most
// color-mix() results and for any wide-gamut color an author wrote.
m = str.match(/^color\(\s*([a-z0-9-]+)\s+(-?[\d.eE+-]+%?|none)\s+(-?[\d.eE+-]+%?|none)\s+(-?[\d.eE+-]+%?|none)(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
if (m) {
const c1 = parseColorComponent(m[2]);
const c2 = parseColorComponent(m[3]);
const c3 = parseColorComponent(m[4]);
if (c1 == null || c2 == null || c3 == null) return null;
const rgb = colorFunctionToRgb(m[1].toLowerCase(), c1, c2, c3);
if (!rgb) return null;
rgb.a = parseAlphaToken(m[5]);
return rgb;
}
// HSL/HSLA — comma or space syntax, optional deg on hue.
m = str.match(/hsla?\(\s*(-?[\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%\s*[,\s]\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hslToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// HWB — hue whiteness% blackness%.
m = str.match(/hwb\(\s*(-?[\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hwbToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
const named = CSS_NAMED_COLORS[str.toLowerCase()];
if (named) return { ...named, a: 1 };
return null;
}
// True when a computed background-color string names no paint at all. Used to
// tell "this layer is see-through" (walk on to the ancestor) apart from "this
// layer has a color we could not read" (stop and abstain).
//
// `inherit` belongs here even though it is not literally see-through: it means
// "paint with the parent's background-color", and walking on to the parent IS
// that resolution. Real browsers resolve the keyword before getComputedStyle
// output; only jsdom's partial cascade hands it through verbatim, and treating
// it as unreadable would make the walk abstain on a surface it can know.
// (`currentcolor` is NOT here — it is real paint in the element's own text
// color; resolveBackgroundInfo substitutes the computed color for it.)
function isNoPaintColorValue(value) {
const v = String(value || '').trim().toLowerCase();
if (!v) return true;
return v === 'transparent' || v === 'none' || v === 'initial' || v === 'inherit' || v === 'unset' || v === 'revert' || v === 'revert-layer';
}
export {
isNeutralColor,
parseRgb,
relativeLuminance,
contrastRatio,
parseGradientColors,
extractColorFunctionTokens,
hasChroma,
getHue,
colorToHex,
oklabToRgb,
oklchToRgb,
labToRgb,
lchToRgb,
colorFunctionToRgb,
hslToRgb,
hwbToRgb,
CSS_NAMED_COLORS,
splitTopLevelCommas,
parseColorMix,
parseAnyColor,
compositeColorOver,
isNoPaintColorValue,
};
File diff suppressed because it is too large Load Diff
@@ -11,14 +11,21 @@ import {
isBrandFontOnOwnDomain,
} from '../shared/constants.mjs';
import {
CSS_NAMED_COLORS,
colorToHex,
compositeColorOver,
contrastRatio,
getHue,
hasChroma,
isNeutralColor,
isNoPaintColorValue,
oklchToRgb,
parseAnyColor,
parseColorMix,
parseGradientColors,
parseRgb,
relativeLuminance,
splitTopLevelCommas,
} from '../shared/color.mjs';
import { extractGoogleFontFamilies } from '../shared/fonts.mjs';
@@ -1731,7 +1738,7 @@ function readOwnBackgroundColor(el, computedStyle) {
// One element's background-color as the cascade walk sees it: computed style
// first (with the modern-color fallback), then, in static mode only,
// custom-prop resolution and the inline-shorthand peek. Shared by
// resolveBackground and resolveGradientStops so both walks read the same
// resolveBackgroundInfo and resolveGradientStops so both walks read the same
// surfaces.
function readCascadeBackgroundColor(current, style, customPropMap) {
let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
@@ -1755,7 +1762,19 @@ function readCascadeBackgroundColor(current, style, customPropMap) {
return bg;
}
function resolveBackground(el, win, customPropMap) {
// Walk up for the surface the element's text is painted on.
//
// Returns { color, unresolved }:
// • color set — the effective surface, overlays composited in.
// • unresolved: true — a layer on the way up paints a color this parser
// cannot read, so the surface is unknown. Callers
// must SKIP their contrast checks. Guessing white
// here is what flooded dark themes with false
// "on #ffffff" findings: one abstention costs a
// single finding, one wrong guess costs a hundred.
// • both null/false — no solid color, but a gradient or image is in
// play; callers fall back to its color stops.
function resolveBackgroundInfo(el, win, customPropMap) {
let current = el;
// Translucent layers (0.1 < a < 1) found on the way down to an opaque
// base. A browser composites these over the base; the old behavior
@@ -1783,59 +1802,81 @@ function resolveBackground(el, win, customPropMap) {
// body backgrounds.
// Real browsers serialize wide-gamut computed values as oklab()/oklch()
// (e.g. any color-mix() result), which plain parseRgb misses.
const bg = readCascadeBackgroundColor(current, style, customPropMap);
let bg = readCascadeBackgroundColor(current, style, customPropMap);
// `background-color: currentcolor` paints with the element's own text
// color — real paint whose value we know. Real browsers resolve the
// keyword before getComputedStyle output; jsdom hands it through
// verbatim, and without this substitution the layer would read as
// unparseable and force a needless abstention.
if ((!bg || bg.a < 0.1) && /^currentcolor$/i.test(String(style.backgroundColor || '').trim())) {
// The static cascade resolves var() text tokens before checks run, so
// style.color is normally already an rgb string here; parseColorResolved
// is defense in depth for any future caller that passes a live
// customPropMap (it matches the text-color path in checkElementColors
// and reduces to parseAnyColor when the map is null or absent).
bg = parseRgb(style.color) || parseColorResolved(style.color, customPropMap);
}
if (bg && bg.a > 0.1) {
if (bg.a >= 0.99) return flatten(bg);
if (bg.a >= 0.99) return { color: flatten(bg), unresolved: false };
overlays.push(bg);
} else if (!bg && !isNoPaintColorValue(style.backgroundColor)) {
// This layer names a color we could not parse (a color space we do not
// model, an unresolved var(), a syntax newer than the parser). It may
// well be opaque, which would make every ancestor below it invisible —
// so the surface is unknown and the walk stops here rather than
// reporting an ancestor the visitor never sees.
return { color: null, unresolved: true };
}
// No solid bg-color at this level, but this level paints an image. CSS
// stacks background-image layers first-on-top, so which layer leads
// decides what the visitor sees:
// • gradient on top — the gradient is the surface. Hand the caller a
// null color so it falls back to the gradient's own stops (body
// grounds, gradient buttons, hero sections).
// • url() on top — the surface is an image whose pixels this engine
// cannot read, and it may fully cover every layer and ancestor
// beneath it. Same contract as an unparseable color: abstain, so
// the gradient-stop fallback never measures a gradient the image
// hides (the shipped miss: `url(photo), linear-gradient(...)`
// reported low-contrast against the invisible gradient's stops).
if (hasGradientOrUrl) {
const layers = splitTopLevelCommas(bgImage);
const topPaintLayer = layers.find(
(layer) => /gradient\s*\(/i.test(layer) || /url\s*\(/i.test(layer),
);
const gradientOnTop = !!topPaintLayer
&& /gradient\s*\(/i.test(topPaintLayer)
&& !/^\s*url\s*\(/i.test(topPaintLayer);
if (!gradientOnTop) return { color: null, unresolved: true };
// Gradient on top of a url() layer: the image shows through wherever
// the gradient is not fully opaque, so a translucent wash like
// `linear-gradient(rgba(0,0,0,.2), rgba(0,0,0,.2)), url(photo)` paints
// a blend with pixels this engine cannot read. Only a gradient whose
// every readable stop is opaque provably covers the image; otherwise
// the surface is unknown — abstain rather than hand callers gradient
// stops (or a stop average) the visitor never sees unmixed.
const urlBeneath = layers.some(
(layer) => layer !== topPaintLayer && /url\s*\(/i.test(layer),
);
if (urlBeneath) {
const topStops = parseGradientColors(topPaintLayer);
const provablyOpaque = topStops.length > 0 && topStops.every((s) => (s.a ?? 1) >= 0.99);
if (!provablyOpaque) return { color: null, unresolved: true };
}
return { color: null, unresolved: false };
}
// No solid bg-color at this level. If THIS level has a gradient/url
// with no underlying solid color we can read:
// • on body/html: assume white. Body-level gradients are almost
// always decorative texture (paper grain, noise) on top of a
// solid bg-color the page set via `background: var(--paper)`
// shorthand — which jsdom can't decompose into bg-color. The
// downstream gradient-stops fallback path produces catastrophic
// false positives in this case (gradient noise stops have
// accidental browns/blacks that look like card backgrounds).
// • on other elements: bail to null and let the caller fall back
// to gradient stops (gradient buttons / hero sections are real
// bgs worth checking against).
// A gradient or image with no solid color under it, at any level
// including body/html, means the visible ground is that layer itself.
// Return null so the caller measures against the actual gradient stops,
// or skips when nothing is parseable — skipping beats a wrong ratio.
//
// Body/html used to assume white here, a guard written for jsdom, which
// never decomposed the `background:` shorthand and so could not see the
// solid paper color a texture gradient usually sits on. It turned every
// light-on-dark page into a wall of low-contrast false positives (a dark
// oklch body gradient produced ~120 "on #ffffff" findings on one site).
// Both engines can see shorthand solids now — the browser natively, the
// static cascade via expandStaticDeclaration + var() resolution — so a
// missing solid is real, and the old failure case cannot recur: opaque
// stops fully cover any hidden solid (they ARE the ground), alpha stops
// composite over the resolved base or the white canvas default, and
// unresolvable stops drop rather than guess.
if (hasGradientOrUrl) return null;
current = current.parentElement;
}
return flatten({ r: 255, g: 255, b: 255, a: 1 });
// Every layer up to the document root was genuinely see-through, so the
// browser paints its default canvas. This is the ONLY case that earns the
// white assumption.
return { color: flatten({ r: 255, g: 255, b: 255, a: 1 }), unresolved: false };
}
// parseGradientColors (shared) reads only the legacy serializations: rgb()
// and hex stops. Browsers keep modern-space stops in computed backgroundImage
// exactly as authored — `linear-gradient(oklch(7% 0.006 95), …)` stays oklch —
// which is what every token-driven page produces. Route those through
// parseAnyColor so a gradient ground is measurable rather than invisible.
function parseGradientColorsModern(bgImage) {
if (!bgImage || !/gradient/i.test(bgImage)) return [];
const colors = parseGradientColors(bgImage);
for (const m of bgImage.matchAll(/(?:oklch|oklab|hsla?|hwb)\(\s*[^()]*\)/gi)) {
const c = parseAnyColor(m[0]);
if (c) colors.push(c);
}
return colors;
function resolveBackground(el, win, customPropMap) {
return resolveBackgroundInfo(el, win, customPropMap).color;
}
// Walk parents looking for a gradient background and return its color stops.
@@ -1861,7 +1902,10 @@ function resolveGradientStops(el, win, customPropMap) {
if (bgImage && bgImage !== 'none' && /url\s*\(/i.test(bgImage)) return null;
let stops = null;
if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
const parsed = parseGradientColorsModern(bgImage);
// parseGradientColors (shared) reads modern-space stops too — oklch,
// color-mix and friends via balanced-paren token capture — so browser
// computed values that keep the authored syntax stay measurable.
const parsed = parseGradientColors(bgImage);
if (parsed.length > 0) stops = parsed;
}
if (!stops && !DETECTOR_IS_BROWSER) {
@@ -1869,7 +1913,7 @@ function resolveGradientStops(el, win, customPropMap) {
const rawStyle = current.getAttribute?.('style') || '';
const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
if (bgMatch && /gradient/i.test(bgMatch[1])) {
const parsed = parseGradientColorsModern(bgMatch[1]);
const parsed = parseGradientColors(bgMatch[1]);
if (parsed.length > 0) stops = parsed;
}
}
@@ -2115,13 +2159,19 @@ function checkElementColorsDOM(el) {
if (style.visibility === 'hidden' || effectiveOpacityDOM(el) <= 0.02) return [];
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
let effectiveBg = resolveBackground(el);
const bgInfo = resolveBackgroundInfo(el);
let effectiveBg = bgInfo.color;
// An unreadable surface anywhere up the chain: skip the gradient-stop
// fallback too, so nothing downstream measures against a ground we never
// resolved.
let surfaceUnresolved = bgInfo.unresolved;
let ownBg = readOwnBackgroundColor(el, style);
if (!ownBg || (ownBg.a ?? 1) <= 0.5) {
const pseudoSurface = readPseudoSurfaceDOM(el, rect);
if (pseudoSurface) {
ownBg = pseudoSurface;
effectiveBg = pseudoSurface;
surfaceUnresolved = false;
}
}
return checkColors({
@@ -2133,8 +2183,8 @@ function checkElementColorsDOM(el) {
// an oklch token near its own oklch background).
textColor: parseRgb(style.color) || parseAnyColor(style.color),
bgColor: ownBg,
effectiveBg,
effectiveBgStops: effectiveBg ? null : resolveGradientStops(el),
effectiveBg: surfaceUnresolved ? null : effectiveBg,
effectiveBgStops: surfaceUnresolved || effectiveBg ? null : resolveGradientStops(el),
fontSize: parseFloat(style.fontSize) || 16,
fontWeight: parseInt(style.fontWeight) || 400,
hasDirectText,
@@ -2284,283 +2334,6 @@ function resolveVarRefs(raw, customPropMap, depth = 0) {
});
}
// OKLCH → sRGB conversion (Björn Ottosson's matrices). L in 0..1 (or %),
// C in 0..~0.4 typical, H in degrees. Returns clamped {r,g,b,a:1} in 0..255.
// Needed because jsdom doesn't compute oklch() values — getComputedStyle
// returns the literal "oklch(...)" string. Without this, the entire
// Tailwind v4 color palette (which is OKLCH-based) is invisible to the
// detector's contrast / color checks.
function oklchToRgb(L, C, H) {
const hRad = (H * Math.PI) / 180;
return oklabToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
}
function oklabToRgb(L, a, b) {
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
const rLin = 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc;
const gLin = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc;
const bLin = -0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc;
const enc = (x) => {
const c = Math.max(0, Math.min(1, x));
return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
};
return {
r: Math.round(enc(rLin) * 255),
g: Math.round(enc(gLin) * 255),
b: Math.round(enc(bLin) * 255),
a: 1,
};
}
function hslToRgb(h, s, l) {
h = ((h % 360) + 360) % 360;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m0 = l - c / 2;
const [r, g, b] =
h < 60 ? [c, x, 0] :
h < 120 ? [x, c, 0] :
h < 180 ? [0, c, x] :
h < 240 ? [0, x, c] :
h < 300 ? [x, 0, c] : [c, 0, x];
return {
r: Math.round((r + m0) * 255),
g: Math.round((g + m0) * 255),
b: Math.round((b + m0) * 255),
a: 1,
};
}
function hwbToRgb(h, w, bl) {
if (w + bl >= 1) {
const g = Math.round((w / (w + bl)) * 255);
return { r: g, g, b: g, a: 1 };
}
const base = hslToRgb(h, 1, 0.5);
const mix = (c) => Math.round(((c / 255) * (1 - w - bl) + w) * 255);
return { r: mix(base.r), g: mix(base.g), b: mix(base.b), a: 1 };
}
// Common CSS named colors — the handful that actually show up in generated
// UIs, not the full 148-name spec list. Includes the achromatic names so a
// named gray parses (and correctly reads as no-chroma) instead of being
// treated as an unknown color.
const CSS_NAMED_COLORS = {
black: { r: 0, g: 0, b: 0 },
white: { r: 255, g: 255, b: 255 },
gray: { r: 128, g: 128, b: 128 },
grey: { r: 128, g: 128, b: 128 },
silver: { r: 192, g: 192, b: 192 },
dimgray: { r: 105, g: 105, b: 105 },
darkgray: { r: 169, g: 169, b: 169 },
lightgray: { r: 211, g: 211, b: 211 },
gainsboro: { r: 220, g: 220, b: 220 },
whitesmoke: { r: 245, g: 245, b: 245 },
red: { r: 255, g: 0, b: 0 },
crimson: { r: 220, g: 20, b: 60 },
tomato: { r: 255, g: 99, b: 71 },
coral: { r: 255, g: 127, b: 80 },
salmon: { r: 250, g: 128, b: 114 },
orange: { r: 255, g: 165, b: 0 },
gold: { r: 255, g: 215, b: 0 },
yellow: { r: 255, g: 255, b: 0 },
olive: { r: 128, g: 128, b: 0 },
lime: { r: 0, g: 255, b: 0 },
green: { r: 0, g: 128, b: 0 },
teal: { r: 0, g: 128, b: 128 },
turquoise: { r: 64, g: 224, b: 208 },
cyan: { r: 0, g: 255, b: 255 },
aqua: { r: 0, g: 255, b: 255 },
skyblue: { r: 135, g: 206, b: 235 },
dodgerblue: { r: 30, g: 144, b: 255 },
blue: { r: 0, g: 0, b: 255 },
navy: { r: 0, g: 0, b: 128 },
indigo: { r: 75, g: 0, b: 130 },
rebeccapurple: { r: 102, g: 51, b: 153 },
purple: { r: 128, g: 0, b: 128 },
violet: { r: 238, g: 130, b: 238 },
orchid: { r: 218, g: 112, b: 214 },
magenta: { r: 255, g: 0, b: 255 },
fuchsia: { r: 255, g: 0, b: 255 },
hotpink: { r: 255, g: 105, b: 180 },
pink: { r: 255, g: 192, b: 203 },
maroon: { r: 128, g: 0, b: 0 },
};
// Split a string on top-level commas (ignoring commas nested in parens).
function splitTopLevelCommas(str) {
const parts = [];
let depth = 0, start = 0;
for (let i = 0; i < str.length; i++) {
const ch = str[i];
if (ch === '(') depth++;
else if (ch === ')') depth = Math.max(0, depth - 1);
else if (ch === ',' && depth === 0) {
parts.push(str.slice(start, i).trim());
start = i + 1;
}
}
const tail = str.slice(start).trim();
if (tail) parts.push(tail);
return parts;
}
// Evaluate a CSS color-mix() expression to {r,g,b,a}. Returns null when
// the expression can't be resolved (unresolved var(), unknown colors).
//
// Mixing is done with premultiplied alpha in sRGB regardless of the
// declared interpolation space. That is exact for the dominant generated-UI
// pattern — `color-mix(in oklab, <color> N%, transparent)` — where the
// result is simply <color> at alpha N% in ANY rectangular space, and a
// close-enough approximation for opaque-opaque mixes (the detector only
// consumes these values for contrast/chroma thresholds, not for display).
function parseColorMix(str) {
const m = String(str).trim().match(/^color-mix\(/i);
if (!m) return null;
// Balanced-paren capture of the arguments.
let depth = 0, end = -1;
const open = str.indexOf('(');
for (let i = open; i < str.length; i++) {
if (str[i] === '(') depth++;
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
}
if (end < 0) return null;
const args = splitTopLevelCommas(str.slice(open + 1, end));
if (args.length !== 3 || !/^in\s/i.test(args[0])) return null;
const parseComponent = (component) => {
// Percentage may lead or trail the color per spec.
let pct = null;
let colorStr = component;
const trail = component.match(/\s+([\d.]+)%$/);
const lead = component.match(/^([\d.]+)%\s+/);
if (trail) { pct = parseFloat(trail[1]); colorStr = component.slice(0, trail.index).trim(); }
else if (lead) { pct = parseFloat(lead[1]); colorStr = component.slice(lead[0].length).trim(); }
let color;
if (/^transparent$/i.test(colorStr)) color = { r: 0, g: 0, b: 0, a: 0 };
else color = parseAnyColor(colorStr);
if (!color) return null;
return { color, pct };
};
const c1 = parseComponent(args[1]);
const c2 = parseComponent(args[2]);
if (!c1 || !c2) return null;
let p1 = c1.pct, p2 = c2.pct;
if (p1 == null && p2 == null) { p1 = 50; p2 = 50; }
else if (p1 == null) p1 = 100 - p2;
else if (p2 == null) p2 = 100 - p1;
const sum = p1 + p2;
if (sum <= 0) return null;
// Per spec: weights normalize to sum; when sum < 100 the result alpha is
// additionally scaled by sum/100.
const w1 = p1 / sum, w2 = p2 / sum;
const alphaScale = sum < 100 ? sum / 100 : 1;
const a1 = c1.color.a ?? 1, a2 = c2.color.a ?? 1;
const a = (a1 * w1 + a2 * w2) * alphaScale;
if (a <= 0) return { r: 0, g: 0, b: 0, a: 0 };
const mix = (ch) => Math.round((c1.color[ch] * a1 * w1 + c2.color[ch] * a2 * w2) / (a1 * w1 + a2 * w2));
return { r: mix('r'), g: mix('g'), b: mix('b'), a: Math.min(1, a) };
}
// Composite a translucent color over an opaque(ish) base (simple
// source-over in sRGB). Returns an opaque {r,g,b,a:1}.
function compositeColorOver(top, base) {
const a = top.a ?? 1;
return {
r: Math.round(top.r * a + base.r * (1 - a)),
g: Math.round(top.g * a + base.g * (1 - a)),
b: Math.round(top.b * a + base.b * (1 - a)),
a: 1,
};
}
// Extended color parser: rgb/rgba/hex/oklch/oklab/hsl/hwb/color-mix/common
// named colors. Returns null on no match. Use this when the input might be
// any CSS color form; use plain parseRgb when you only expect computed rgb()
// values from real browsers.
function parseAnyColor(s) {
if (!s || typeof s !== 'string') return null;
const str = s.trim();
if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
if (/^color-mix\(/i.test(str)) return parseColorMix(str);
let m;
m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+))?\s*\)/);
if (m) return { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: m[4] !== undefined ? +m[4] : 1 };
m = str.match(/^#([0-9a-f]{3,8})$/i);
if (m) {
const h = m[1];
if (h.length === 3 || h.length === 4) {
return {
r: parseInt(h[0] + h[0], 16),
g: parseInt(h[1] + h[1], 16),
b: parseInt(h[2] + h[2], 16),
a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
};
}
if (h.length === 6 || h.length === 8) {
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
};
}
}
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
// Match L (with optional %), then C and H separated permissively.
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const Lnum = parseFloat(m[1]);
const L = m[2] === '%' ? Lnum / 100 : Lnum;
const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
if (m[5] !== undefined) {
const alpha = parseFloat(m[5]);
rgb.a = m[6] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// OKLAB — a/b are signed axes; percentages map 100% → 0.4.
m = str.match(/oklab\(\s*([\d.]+)(%?)\s+(-?[\d.]+)(%?)\s+(-?[\d.]+)(%?)(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const L = m[2] === '%' ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
const a = m[4] === '%' ? parseFloat(m[3]) * 0.004 : parseFloat(m[3]);
const b = m[6] === '%' ? parseFloat(m[5]) * 0.004 : parseFloat(m[5]);
const rgb = oklabToRgb(L, a, b);
if (m[7] !== undefined) {
const alpha = parseFloat(m[7]);
rgb.a = m[8] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// HSL/HSLA — comma or space syntax, optional deg on hue.
m = str.match(/hsla?\(\s*(-?[\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%\s*[,\s]\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hslToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// HWB — hue whiteness% blackness%.
m = str.match(/hwb\(\s*(-?[\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hwbToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
const named = CSS_NAMED_COLORS[str.toLowerCase()];
if (named) return { ...named, a: 1 };
return null;
}
// Resolve var() refs in a color string (via customPropMap), then parse.
// Returns null on any failure. Used in jsdom-mode paths where
@@ -2923,15 +2696,24 @@ function checkElementGlowDOM(el) {
if (!boxShadow && !textShadow) return [];
// Use parent's background — glow radiates outward, so the surrounding context matters
// If resolveBackground returns null (gradient), try to infer from the gradient colors
let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el);
if (!parentBg) {
const parentBgInfo = resolveBackgroundInfo(el.parentElement || el);
// Unknown surface (an unreadable layer on the way up): skip only the
// gradient hunt below, which would walk PAST that layer and score the
// glow against a background the visitor never sees. checkGlow still runs
// with a null surface: the zero-offset chromatic halo tell holds on ANY
// background, and the static loop already passes the unresolved walk's
// null color straight through (detect-html.mjs uses resolveBackground).
let parentBg = parentBgInfo.color;
if (!parentBg && !parentBgInfo.unresolved) {
// Gradient background — sample its colors to determine if it's dark.
// Modern-syntax parsing matters here: body-level gradients now reach this
// fallback in browser mode, and their stops usually serialize as oklch.
// fallback in browser mode, and their stops usually serialize as oklch
// which the shared parseGradientColors reads via its color-function
// token capture.
let cur = el.parentElement;
while (cur && cur.nodeType === 1) {
const bgImage = getComputedStyle(cur).backgroundImage || '';
const gradColors = parseGradientColorsModern(bgImage);
const gradColors = parseGradientColors(bgImage);
if (gradColors.length > 0) {
// Average the gradient colors
const avg = { r: 0, g: 0, b: 0 };
@@ -2975,10 +2757,13 @@ function checkElementAIPaletteDOM(el) {
const hue = getHue(textColor);
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
if (isAIPalette) {
const parentBg = el.parentElement ? resolveBackground(el.parentElement) : null;
// Also check gradient parents
let effectiveBg = parentBg;
if (!effectiveBg) {
const parentBgInfo = el.parentElement
? resolveBackgroundInfo(el.parentElement)
: { color: null, unresolved: false };
// Unknown surface: leave effectiveBg null (no finding) rather than
// hunting gradient ancestors past a layer we could not read.
let effectiveBg = parentBgInfo.color;
if (!effectiveBg && !parentBgInfo.unresolved) {
let cur = el.parentElement;
while (cur && cur.nodeType === 1) {
const gi = getComputedStyle(cur).backgroundImage || '';
@@ -3784,7 +3569,8 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
const effectiveBg = resolveBackground(el, window, customPropMap);
const bgInfo = resolveBackgroundInfo(el, window, customPropMap);
const effectiveBg = bgInfo.color;
// jsdom returns literal "var(--X)" / "oklch(...)" for color, so plain
// parseRgb misses Tailwind-tokenized text colors. Resolve through the
// customPropMap first; fall back to parseRgb for vanilla rgb() pages.
@@ -3830,11 +3616,13 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
// element itself has no usable own background, that pseudo is the real
// surface for contrast purposes.
let finalEffectiveBg = effectiveBg;
let surfaceUnresolved = bgInfo.unresolved;
if ((!ownBg || (ownBg.a ?? 1) <= 0.5) && typeof window.getPseudoSurface === 'function') {
const pseudoSurface = window.getPseudoSurface(el);
if (pseudoSurface) {
ownBg = pseudoSurface;
finalEffectiveBg = pseudoSurface;
surfaceUnresolved = false;
}
}
@@ -3842,8 +3630,9 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
tag,
textColor,
bgColor: ownBg,
effectiveBg: finalEffectiveBg,
effectiveBgStops: finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
// Unknown surface: hand the checks nothing rather than a guess.
effectiveBg: surfaceUnresolved ? null : finalEffectiveBg,
effectiveBgStops: surfaceUnresolved || finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
fontSize: parseFloat(style.fontSize) || 16,
fontWeight: parseInt(style.fontWeight) || 400,
hasDirectText,
@@ -5663,6 +5452,7 @@ export {
checkHtmlPatterns,
readOwnBackgroundColor,
resolveBackground,
resolveBackgroundInfo,
resolveGradientStops,
parseRadiusToPx,
resolveBorderRadiusPx,
@@ -71,11 +71,43 @@ function contrastRatio(c1, c2) {
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
// The CSS color functions worth pulling out of a longer declaration. The set
// is deliberately closed: `linear-gradient(` and `url(` also look like
// `name(` and must not be read as colors.
const COLOR_FUNCTION_NAMES = new Set([
'rgb', 'rgba', 'hsl', 'hsla', 'hwb', 'oklch', 'oklab', 'lch', 'lab', 'color', 'color-mix',
]);
// Pull every color-function token out of a value, with balanced-paren capture
// so nested forms (`color-mix(in oklab, oklch(...) 20%, transparent)`) survive
// whole. Returns the raw substrings in source order.
function extractColorFunctionTokens(value) {
const str = String(value || '');
const tokens = [];
const re = /([a-z][a-z-]*)\(/gi;
let m;
while ((m = re.exec(str)) !== null) {
if (!COLOR_FUNCTION_NAMES.has(m[1].toLowerCase())) continue;
let depth = 0, end = -1;
for (let i = m.index + m[0].length - 1; i < str.length; i++) {
if (str[i] === '(') depth++;
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
}
if (end < 0) break;
tokens.push(str.slice(m.index, end + 1));
re.lastIndex = end + 1;
}
return tokens;
}
function parseGradientColors(bgImage) {
if (!bgImage || !bgImage.includes('gradient')) return [];
const colors = [];
for (const m of bgImage.matchAll(/rgba?\([^)]+\)/g)) {
const c = parseRgb(m[0]);
// Stops arrive in whatever syntax the author wrote and the browser kept.
// A dark ground painted as `linear-gradient(oklch(...), oklch(...))` used
// to read as a gradient with no stops at all.
for (const token of extractColorFunctionTokens(bgImage)) {
const c = parseAnyColor(token);
if (c) colors.push(c);
}
for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
@@ -112,13 +144,445 @@ function colorToHex(c) {
return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
}
// ─── Color-space conversions ────────────────────────────────────────────────
//
// Every function here lands on 8-bit sRGB, clamped to gamut. Chrome, Safari,
// and Firefox all keep the authored color space in getComputedStyle output
// (`oklch(0.84 0.19 80.46)`, `lch(20 5 60)`, `color(srgb 1.04 0.72 -0.21)`),
// so a detector that only reads rgb() is blind on any modern palette. The
// expected outputs are pinned in tests/detect-antipatterns.test.js against
// what Chrome itself paints for the same strings.
function clamp01(x) {
return Number.isFinite(x) ? Math.max(0, Math.min(1, x)) : 0;
}
// Linear-light sRGB channel to the encoded 0-255 value.
function encodeSrgbChannel(x) {
const c = clamp01(x);
return Math.round((c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055) * 255);
}
function decodeSrgbChannel(x) {
const c = Number.isFinite(x) ? x : 0;
const sign = c < 0 ? -1 : 1;
const abs = Math.abs(c);
return sign * (abs <= 0.04045 ? abs / 12.92 : Math.pow((abs + 0.055) / 1.055, 2.4));
}
function linearSrgbToColor(r, g, b, a = 1) {
return { r: encodeSrgbChannel(r), g: encodeSrgbChannel(g), b: encodeSrgbChannel(b), a };
}
// OKLab to sRGB (Björn Ottosson's matrices). L in 0..1, a/b are signed axes.
function oklabToRgb(L, a, b) {
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
return linearSrgbToColor(
4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc,
-1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc,
-0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc,
);
}
// OKLCH to sRGB. L in 0..1, C in 0..~0.4 typical, H in degrees. Chroma past
// the sRGB gamut clamps per channel rather than producing NaN.
function oklchToRgb(L, C, H) {
const hRad = (H * Math.PI) / 180;
return oklabToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
}
// CIE Lab to sRGB. CSS lab()/lch() use the D50 white point; the matrix below
// is the Bradford-adapted XYZ-D50 to linear-sRGB transform from CSS Color 4.
function labToRgb(L, a, b) {
const kappa = 24389 / 27, epsilon = 216 / 24389;
const fy = (L + 16) / 116, fx = fy + a / 500, fz = fy - b / 200;
const invert = (t) => (t * t * t > epsilon ? t * t * t : (116 * t - 16) / kappa);
const yr = L > kappa * epsilon ? Math.pow((L + 16) / 116, 3) : L / kappa;
const Xn = 0.3457 / 0.3585, Zn = (1 - 0.3457 - 0.3585) / 0.3585;
const x = invert(fx) * Xn, y = yr, z = invert(fz) * Zn;
return linearSrgbToColor(
3.1341359569958707 * x - 1.6173863321612538 * y - 0.4906619460083532 * z,
-0.9787955029120890 * x + 1.9162545672595240 * y + 0.0334427311613195 * z,
0.0719553798841168 * x - 0.2289768264158322 * y + 1.4053860583241250 * z,
);
}
function lchToRgb(L, C, H) {
const hRad = (H * Math.PI) / 180;
return labToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
}
// color(<space> c1 c2 c3) for the spaces that turn up in real stylesheets.
// `srgb` is what Chrome serializes most color-mix() results into, routinely
// with channels outside 0..1. Spaces we do not model return null so callers
// abstain instead of measuring against a color we invented.
function colorFunctionToRgb(space, c1, c2, c3) {
switch (space) {
case 'srgb':
return { r: Math.round(clamp01(c1) * 255), g: Math.round(clamp01(c2) * 255), b: Math.round(clamp01(c3) * 255), a: 1 };
case 'srgb-linear':
return linearSrgbToColor(c1, c2, c3);
case 'display-p3': {
const [R, G, B] = [decodeSrgbChannel(c1), decodeSrgbChannel(c2), decodeSrgbChannel(c3)];
return linearSrgbToColor(
1.2249401762805587 * R - 0.2249404646817506 * G + 0.0000002884022551 * B,
-0.0420569547096138 * R + 1.0420571661298634 * G - 0.0000002113202247 * B,
-0.0196375587040044 * R - 0.0786360772174755 * G + 1.0982736359214800 * B,
);
}
default:
return null;
}
}
function hslToRgb(h, s, l) {
h = ((h % 360) + 360) % 360;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m0 = l - c / 2;
const [r, g, b] =
h < 60 ? [c, x, 0] :
h < 120 ? [x, c, 0] :
h < 180 ? [0, c, x] :
h < 240 ? [0, x, c] :
h < 300 ? [x, 0, c] : [c, 0, x];
return {
r: Math.round((r + m0) * 255),
g: Math.round((g + m0) * 255),
b: Math.round((b + m0) * 255),
a: 1,
};
}
function hwbToRgb(h, w, bl) {
if (w + bl >= 1) {
const g = Math.round((w / (w + bl)) * 255);
return { r: g, g, b: g, a: 1 };
}
const base = hslToRgb(h, 1, 0.5);
const mix = (c) => Math.round(((c / 255) * (1 - w - bl) + w) * 255);
return { r: mix(base.r), g: mix(base.g), b: mix(base.b), a: 1 };
}
// Common CSS named colors — the handful that actually show up in generated
// UIs, not the full 148-name spec list. Includes the achromatic names so a
// named gray parses (and correctly reads as no-chroma) instead of being
// treated as an unknown color.
const CSS_NAMED_COLORS = {
black: { r: 0, g: 0, b: 0 },
white: { r: 255, g: 255, b: 255 },
gray: { r: 128, g: 128, b: 128 },
grey: { r: 128, g: 128, b: 128 },
silver: { r: 192, g: 192, b: 192 },
dimgray: { r: 105, g: 105, b: 105 },
darkgray: { r: 169, g: 169, b: 169 },
lightgray: { r: 211, g: 211, b: 211 },
gainsboro: { r: 220, g: 220, b: 220 },
whitesmoke: { r: 245, g: 245, b: 245 },
red: { r: 255, g: 0, b: 0 },
crimson: { r: 220, g: 20, b: 60 },
tomato: { r: 255, g: 99, b: 71 },
coral: { r: 255, g: 127, b: 80 },
salmon: { r: 250, g: 128, b: 114 },
orange: { r: 255, g: 165, b: 0 },
gold: { r: 255, g: 215, b: 0 },
yellow: { r: 255, g: 255, b: 0 },
olive: { r: 128, g: 128, b: 0 },
lime: { r: 0, g: 255, b: 0 },
green: { r: 0, g: 128, b: 0 },
teal: { r: 0, g: 128, b: 128 },
turquoise: { r: 64, g: 224, b: 208 },
cyan: { r: 0, g: 255, b: 255 },
aqua: { r: 0, g: 255, b: 255 },
skyblue: { r: 135, g: 206, b: 235 },
dodgerblue: { r: 30, g: 144, b: 255 },
blue: { r: 0, g: 0, b: 255 },
navy: { r: 0, g: 0, b: 128 },
indigo: { r: 75, g: 0, b: 130 },
rebeccapurple: { r: 102, g: 51, b: 153 },
purple: { r: 128, g: 0, b: 128 },
violet: { r: 238, g: 130, b: 238 },
orchid: { r: 218, g: 112, b: 214 },
magenta: { r: 255, g: 0, b: 255 },
fuchsia: { r: 255, g: 0, b: 255 },
hotpink: { r: 255, g: 105, b: 180 },
pink: { r: 255, g: 192, b: 203 },
maroon: { r: 128, g: 0, b: 0 },
};
// Split a string on top-level commas (ignoring commas nested in parens).
function splitTopLevelCommas(str) {
const parts = [];
let depth = 0, start = 0;
for (let i = 0; i < str.length; i++) {
const ch = str[i];
if (ch === '(') depth++;
else if (ch === ')') depth = Math.max(0, depth - 1);
else if (ch === ',' && depth === 0) {
parts.push(str.slice(start, i).trim());
start = i + 1;
}
}
const tail = str.slice(start).trim();
if (tail) parts.push(tail);
return parts;
}
// Evaluate a CSS color-mix() expression to {r,g,b,a}. Returns null when
// the expression can't be resolved (unresolved var(), unknown colors).
//
// Mixing is done with premultiplied alpha in sRGB regardless of the
// declared interpolation space. That is exact for the dominant generated-UI
// pattern — `color-mix(in oklab, <color> N%, transparent)` — where the
// result is simply <color> at alpha N% in ANY rectangular space, and a
// close-enough approximation for opaque-opaque mixes (the detector only
// consumes these values for contrast/chroma thresholds, not for display).
function parseColorMix(str) {
const m = String(str).trim().match(/^color-mix\(/i);
if (!m) return null;
// Balanced-paren capture of the arguments.
let depth = 0, end = -1;
const open = str.indexOf('(');
for (let i = open; i < str.length; i++) {
if (str[i] === '(') depth++;
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
}
if (end < 0) return null;
const args = splitTopLevelCommas(str.slice(open + 1, end));
if (args.length !== 3 || !/^in\s/i.test(args[0])) return null;
const parseComponent = (component) => {
// Percentage may lead or trail the color per spec.
let pct = null;
let colorStr = component;
const trail = component.match(/\s+([\d.]+)%$/);
const lead = component.match(/^([\d.]+)%\s+/);
if (trail) { pct = parseFloat(trail[1]); colorStr = component.slice(0, trail.index).trim(); }
else if (lead) { pct = parseFloat(lead[1]); colorStr = component.slice(lead[0].length).trim(); }
let color;
if (/^transparent$/i.test(colorStr)) color = { r: 0, g: 0, b: 0, a: 0 };
else color = parseAnyColor(colorStr);
if (!color) return null;
return { color, pct };
};
const c1 = parseComponent(args[1]);
const c2 = parseComponent(args[2]);
if (!c1 || !c2) return null;
let p1 = c1.pct, p2 = c2.pct;
if (p1 == null && p2 == null) { p1 = 50; p2 = 50; }
else if (p1 == null) p1 = 100 - p2;
else if (p2 == null) p2 = 100 - p1;
const sum = p1 + p2;
if (sum <= 0) return null;
// Per spec: weights normalize to sum; when sum < 100 the result alpha is
// additionally scaled by sum/100.
const w1 = p1 / sum, w2 = p2 / sum;
const alphaScale = sum < 100 ? sum / 100 : 1;
const a1 = c1.color.a ?? 1, a2 = c2.color.a ?? 1;
const a = (a1 * w1 + a2 * w2) * alphaScale;
if (a <= 0) return { r: 0, g: 0, b: 0, a: 0 };
const mix = (ch) => Math.round((c1.color[ch] * a1 * w1 + c2.color[ch] * a2 * w2) / (a1 * w1 + a2 * w2));
return { r: mix('r'), g: mix('g'), b: mix('b'), a: Math.min(1, a) };
}
// Composite a translucent color over an opaque(ish) base (simple
// source-over in sRGB). Returns an opaque {r,g,b,a:1}.
function compositeColorOver(top, base) {
const a = top.a ?? 1;
return {
r: Math.round(top.r * a + base.r * (1 - a)),
g: Math.round(top.g * a + base.g * (1 - a)),
b: Math.round(top.b * a + base.b * (1 - a)),
a: 1,
};
}
// A color() / lab() / lch() component: a bare number, a percentage against
// `scale`, or the `none` keyword (which resolves to zero for our purposes).
function parseColorComponent(token, scale = 1) {
if (token == null) return null;
const t = String(token).trim();
if (/^none$/i.test(t)) return 0;
const num = parseFloat(t);
if (!Number.isFinite(num)) return null;
return t.endsWith('%') ? (num / 100) * scale : num;
}
function parseAlphaToken(token) {
if (token == null) return 1;
const t = String(token).trim();
if (/^none$/i.test(t)) return 1;
const num = parseFloat(t);
if (!Number.isFinite(num)) return 1;
return t.endsWith('%') ? num / 100 : num;
}
// Extended color parser: rgb/rgba/hex/oklch/oklab/lch/lab/hsl/hwb/color()/
// color-mix/common named colors. Returns null on no match. Use this when the
// input might be any CSS color form; use plain parseRgb when you only expect
// computed rgb() values from real browsers.
function parseAnyColor(s) {
if (!s || typeof s !== 'string') return null;
const str = s.trim();
if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
if (/^color-mix\(/i.test(str)) return parseColorMix(str);
let m;
m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/);
if (m) {
const c = { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: 1 };
if (m[4] !== undefined) c.a = m[5] === '%' ? parseFloat(m[4]) / 100 : +m[4];
return c;
}
m = str.match(/^#([0-9a-f]{3,8})$/i);
if (m) {
const h = m[1];
if (h.length === 3 || h.length === 4) {
return {
r: parseInt(h[0] + h[0], 16),
g: parseInt(h[1] + h[1], 16),
b: parseInt(h[2] + h[2], 16),
a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
};
}
if (h.length === 6 || h.length === 8) {
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
};
}
}
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
// Match L (with optional %), then C and H separated permissively.
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const Lnum = parseFloat(m[1]);
const L = m[2] === '%' ? Lnum / 100 : Lnum;
const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
if (m[5] !== undefined) {
const alpha = parseFloat(m[5]);
rgb.a = m[6] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// OKLAB — a/b are signed axes; percentages map 100% → 0.4.
m = str.match(/oklab\(\s*([\d.]+)(%?)\s+(-?[\d.]+)(%?)\s+(-?[\d.]+)(%?)(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const L = m[2] === '%' ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
const a = m[4] === '%' ? parseFloat(m[3]) * 0.004 : parseFloat(m[3]);
const b = m[6] === '%' ? parseFloat(m[5]) * 0.004 : parseFloat(m[5]);
const rgb = oklabToRgb(L, a, b);
if (m[7] !== undefined) {
const alpha = parseFloat(m[7]);
rgb.a = m[8] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// LCH / LAB — CIE, D50 white point. Chrome serializes lch(20% 5 60) as
// `lch(20 5 60)`, so L arrives with or without its percent sign. In both
// spaces L runs 0..100 and 100% means 100.
m = str.match(/^lch\(\s*([\d.]+%?|none)\s+([\d.]+%?|none)\s+(-?[\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
if (m) {
const L = parseColorComponent(m[1], 100);
const C = parseColorComponent(m[2], 150);
const H = parseFloat(m[3]);
if (L == null || C == null || !Number.isFinite(H)) return null;
const rgb = lchToRgb(L, C, H);
rgb.a = parseAlphaToken(m[4]);
return rgb;
}
m = str.match(/^lab\(\s*([\d.]+%?|none)\s+(-?[\d.]+%?|none)\s+(-?[\d.]+%?|none)(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
if (m) {
const L = parseColorComponent(m[1], 100);
const a = parseColorComponent(m[2], 125);
const b = parseColorComponent(m[3], 125);
if (L == null || a == null || b == null) return null;
const rgb = labToRgb(L, a, b);
rgb.a = parseAlphaToken(m[4]);
return rgb;
}
// color(<space> c1 c2 c3 [/ alpha]) — what Chrome hands back for most
// color-mix() results and for any wide-gamut color an author wrote.
m = str.match(/^color\(\s*([a-z0-9-]+)\s+(-?[\d.eE+-]+%?|none)\s+(-?[\d.eE+-]+%?|none)\s+(-?[\d.eE+-]+%?|none)(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
if (m) {
const c1 = parseColorComponent(m[2]);
const c2 = parseColorComponent(m[3]);
const c3 = parseColorComponent(m[4]);
if (c1 == null || c2 == null || c3 == null) return null;
const rgb = colorFunctionToRgb(m[1].toLowerCase(), c1, c2, c3);
if (!rgb) return null;
rgb.a = parseAlphaToken(m[5]);
return rgb;
}
// HSL/HSLA — comma or space syntax, optional deg on hue.
m = str.match(/hsla?\(\s*(-?[\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%\s*[,\s]\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hslToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// HWB — hue whiteness% blackness%.
m = str.match(/hwb\(\s*(-?[\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hwbToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
const named = CSS_NAMED_COLORS[str.toLowerCase()];
if (named) return { ...named, a: 1 };
return null;
}
// True when a computed background-color string names no paint at all. Used to
// tell "this layer is see-through" (walk on to the ancestor) apart from "this
// layer has a color we could not read" (stop and abstain).
//
// `inherit` belongs here even though it is not literally see-through: it means
// "paint with the parent's background-color", and walking on to the parent IS
// that resolution. Real browsers resolve the keyword before getComputedStyle
// output; only jsdom's partial cascade hands it through verbatim, and treating
// it as unreadable would make the walk abstain on a surface it can know.
// (`currentcolor` is NOT here — it is real paint in the element's own text
// color; resolveBackgroundInfo substitutes the computed color for it.)
function isNoPaintColorValue(value) {
const v = String(value || '').trim().toLowerCase();
if (!v) return true;
return v === 'transparent' || v === 'none' || v === 'initial' || v === 'inherit' || v === 'unset' || v === 'revert' || v === 'revert-layer';
}
export {
isNeutralColor,
parseRgb,
relativeLuminance,
contrastRatio,
parseGradientColors,
extractColorFunctionTokens,
hasChroma,
getHue,
colorToHex,
oklabToRgb,
oklchToRgb,
labToRgb,
lchToRgb,
colorFunctionToRgb,
hslToRgb,
hwbToRgb,
CSS_NAMED_COLORS,
splitTopLevelCommas,
parseColorMix,
parseAnyColor,
compositeColorOver,
isNoPaintColorValue,
};
File diff suppressed because it is too large Load Diff
@@ -11,14 +11,21 @@ import {
isBrandFontOnOwnDomain,
} from '../shared/constants.mjs';
import {
CSS_NAMED_COLORS,
colorToHex,
compositeColorOver,
contrastRatio,
getHue,
hasChroma,
isNeutralColor,
isNoPaintColorValue,
oklchToRgb,
parseAnyColor,
parseColorMix,
parseGradientColors,
parseRgb,
relativeLuminance,
splitTopLevelCommas,
} from '../shared/color.mjs';
import { extractGoogleFontFamilies } from '../shared/fonts.mjs';
@@ -1731,7 +1738,7 @@ function readOwnBackgroundColor(el, computedStyle) {
// One element's background-color as the cascade walk sees it: computed style
// first (with the modern-color fallback), then, in static mode only,
// custom-prop resolution and the inline-shorthand peek. Shared by
// resolveBackground and resolveGradientStops so both walks read the same
// resolveBackgroundInfo and resolveGradientStops so both walks read the same
// surfaces.
function readCascadeBackgroundColor(current, style, customPropMap) {
let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
@@ -1755,7 +1762,19 @@ function readCascadeBackgroundColor(current, style, customPropMap) {
return bg;
}
function resolveBackground(el, win, customPropMap) {
// Walk up for the surface the element's text is painted on.
//
// Returns { color, unresolved }:
// • color set — the effective surface, overlays composited in.
// • unresolved: true — a layer on the way up paints a color this parser
// cannot read, so the surface is unknown. Callers
// must SKIP their contrast checks. Guessing white
// here is what flooded dark themes with false
// "on #ffffff" findings: one abstention costs a
// single finding, one wrong guess costs a hundred.
// • both null/false — no solid color, but a gradient or image is in
// play; callers fall back to its color stops.
function resolveBackgroundInfo(el, win, customPropMap) {
let current = el;
// Translucent layers (0.1 < a < 1) found on the way down to an opaque
// base. A browser composites these over the base; the old behavior
@@ -1783,59 +1802,81 @@ function resolveBackground(el, win, customPropMap) {
// body backgrounds.
// Real browsers serialize wide-gamut computed values as oklab()/oklch()
// (e.g. any color-mix() result), which plain parseRgb misses.
const bg = readCascadeBackgroundColor(current, style, customPropMap);
let bg = readCascadeBackgroundColor(current, style, customPropMap);
// `background-color: currentcolor` paints with the element's own text
// color — real paint whose value we know. Real browsers resolve the
// keyword before getComputedStyle output; jsdom hands it through
// verbatim, and without this substitution the layer would read as
// unparseable and force a needless abstention.
if ((!bg || bg.a < 0.1) && /^currentcolor$/i.test(String(style.backgroundColor || '').trim())) {
// The static cascade resolves var() text tokens before checks run, so
// style.color is normally already an rgb string here; parseColorResolved
// is defense in depth for any future caller that passes a live
// customPropMap (it matches the text-color path in checkElementColors
// and reduces to parseAnyColor when the map is null or absent).
bg = parseRgb(style.color) || parseColorResolved(style.color, customPropMap);
}
if (bg && bg.a > 0.1) {
if (bg.a >= 0.99) return flatten(bg);
if (bg.a >= 0.99) return { color: flatten(bg), unresolved: false };
overlays.push(bg);
} else if (!bg && !isNoPaintColorValue(style.backgroundColor)) {
// This layer names a color we could not parse (a color space we do not
// model, an unresolved var(), a syntax newer than the parser). It may
// well be opaque, which would make every ancestor below it invisible —
// so the surface is unknown and the walk stops here rather than
// reporting an ancestor the visitor never sees.
return { color: null, unresolved: true };
}
// No solid bg-color at this level, but this level paints an image. CSS
// stacks background-image layers first-on-top, so which layer leads
// decides what the visitor sees:
// • gradient on top — the gradient is the surface. Hand the caller a
// null color so it falls back to the gradient's own stops (body
// grounds, gradient buttons, hero sections).
// • url() on top — the surface is an image whose pixels this engine
// cannot read, and it may fully cover every layer and ancestor
// beneath it. Same contract as an unparseable color: abstain, so
// the gradient-stop fallback never measures a gradient the image
// hides (the shipped miss: `url(photo), linear-gradient(...)`
// reported low-contrast against the invisible gradient's stops).
if (hasGradientOrUrl) {
const layers = splitTopLevelCommas(bgImage);
const topPaintLayer = layers.find(
(layer) => /gradient\s*\(/i.test(layer) || /url\s*\(/i.test(layer),
);
const gradientOnTop = !!topPaintLayer
&& /gradient\s*\(/i.test(topPaintLayer)
&& !/^\s*url\s*\(/i.test(topPaintLayer);
if (!gradientOnTop) return { color: null, unresolved: true };
// Gradient on top of a url() layer: the image shows through wherever
// the gradient is not fully opaque, so a translucent wash like
// `linear-gradient(rgba(0,0,0,.2), rgba(0,0,0,.2)), url(photo)` paints
// a blend with pixels this engine cannot read. Only a gradient whose
// every readable stop is opaque provably covers the image; otherwise
// the surface is unknown — abstain rather than hand callers gradient
// stops (or a stop average) the visitor never sees unmixed.
const urlBeneath = layers.some(
(layer) => layer !== topPaintLayer && /url\s*\(/i.test(layer),
);
if (urlBeneath) {
const topStops = parseGradientColors(topPaintLayer);
const provablyOpaque = topStops.length > 0 && topStops.every((s) => (s.a ?? 1) >= 0.99);
if (!provablyOpaque) return { color: null, unresolved: true };
}
return { color: null, unresolved: false };
}
// No solid bg-color at this level. If THIS level has a gradient/url
// with no underlying solid color we can read:
// • on body/html: assume white. Body-level gradients are almost
// always decorative texture (paper grain, noise) on top of a
// solid bg-color the page set via `background: var(--paper)`
// shorthand — which jsdom can't decompose into bg-color. The
// downstream gradient-stops fallback path produces catastrophic
// false positives in this case (gradient noise stops have
// accidental browns/blacks that look like card backgrounds).
// • on other elements: bail to null and let the caller fall back
// to gradient stops (gradient buttons / hero sections are real
// bgs worth checking against).
// A gradient or image with no solid color under it, at any level
// including body/html, means the visible ground is that layer itself.
// Return null so the caller measures against the actual gradient stops,
// or skips when nothing is parseable — skipping beats a wrong ratio.
//
// Body/html used to assume white here, a guard written for jsdom, which
// never decomposed the `background:` shorthand and so could not see the
// solid paper color a texture gradient usually sits on. It turned every
// light-on-dark page into a wall of low-contrast false positives (a dark
// oklch body gradient produced ~120 "on #ffffff" findings on one site).
// Both engines can see shorthand solids now — the browser natively, the
// static cascade via expandStaticDeclaration + var() resolution — so a
// missing solid is real, and the old failure case cannot recur: opaque
// stops fully cover any hidden solid (they ARE the ground), alpha stops
// composite over the resolved base or the white canvas default, and
// unresolvable stops drop rather than guess.
if (hasGradientOrUrl) return null;
current = current.parentElement;
}
return flatten({ r: 255, g: 255, b: 255, a: 1 });
// Every layer up to the document root was genuinely see-through, so the
// browser paints its default canvas. This is the ONLY case that earns the
// white assumption.
return { color: flatten({ r: 255, g: 255, b: 255, a: 1 }), unresolved: false };
}
// parseGradientColors (shared) reads only the legacy serializations: rgb()
// and hex stops. Browsers keep modern-space stops in computed backgroundImage
// exactly as authored — `linear-gradient(oklch(7% 0.006 95), …)` stays oklch —
// which is what every token-driven page produces. Route those through
// parseAnyColor so a gradient ground is measurable rather than invisible.
function parseGradientColorsModern(bgImage) {
if (!bgImage || !/gradient/i.test(bgImage)) return [];
const colors = parseGradientColors(bgImage);
for (const m of bgImage.matchAll(/(?:oklch|oklab|hsla?|hwb)\(\s*[^()]*\)/gi)) {
const c = parseAnyColor(m[0]);
if (c) colors.push(c);
}
return colors;
function resolveBackground(el, win, customPropMap) {
return resolveBackgroundInfo(el, win, customPropMap).color;
}
// Walk parents looking for a gradient background and return its color stops.
@@ -1861,7 +1902,10 @@ function resolveGradientStops(el, win, customPropMap) {
if (bgImage && bgImage !== 'none' && /url\s*\(/i.test(bgImage)) return null;
let stops = null;
if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
const parsed = parseGradientColorsModern(bgImage);
// parseGradientColors (shared) reads modern-space stops too — oklch,
// color-mix and friends via balanced-paren token capture — so browser
// computed values that keep the authored syntax stay measurable.
const parsed = parseGradientColors(bgImage);
if (parsed.length > 0) stops = parsed;
}
if (!stops && !DETECTOR_IS_BROWSER) {
@@ -1869,7 +1913,7 @@ function resolveGradientStops(el, win, customPropMap) {
const rawStyle = current.getAttribute?.('style') || '';
const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
if (bgMatch && /gradient/i.test(bgMatch[1])) {
const parsed = parseGradientColorsModern(bgMatch[1]);
const parsed = parseGradientColors(bgMatch[1]);
if (parsed.length > 0) stops = parsed;
}
}
@@ -2115,13 +2159,19 @@ function checkElementColorsDOM(el) {
if (style.visibility === 'hidden' || effectiveOpacityDOM(el) <= 0.02) return [];
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
let effectiveBg = resolveBackground(el);
const bgInfo = resolveBackgroundInfo(el);
let effectiveBg = bgInfo.color;
// An unreadable surface anywhere up the chain: skip the gradient-stop
// fallback too, so nothing downstream measures against a ground we never
// resolved.
let surfaceUnresolved = bgInfo.unresolved;
let ownBg = readOwnBackgroundColor(el, style);
if (!ownBg || (ownBg.a ?? 1) <= 0.5) {
const pseudoSurface = readPseudoSurfaceDOM(el, rect);
if (pseudoSurface) {
ownBg = pseudoSurface;
effectiveBg = pseudoSurface;
surfaceUnresolved = false;
}
}
return checkColors({
@@ -2133,8 +2183,8 @@ function checkElementColorsDOM(el) {
// an oklch token near its own oklch background).
textColor: parseRgb(style.color) || parseAnyColor(style.color),
bgColor: ownBg,
effectiveBg,
effectiveBgStops: effectiveBg ? null : resolveGradientStops(el),
effectiveBg: surfaceUnresolved ? null : effectiveBg,
effectiveBgStops: surfaceUnresolved || effectiveBg ? null : resolveGradientStops(el),
fontSize: parseFloat(style.fontSize) || 16,
fontWeight: parseInt(style.fontWeight) || 400,
hasDirectText,
@@ -2284,283 +2334,6 @@ function resolveVarRefs(raw, customPropMap, depth = 0) {
});
}
// OKLCH → sRGB conversion (Björn Ottosson's matrices). L in 0..1 (or %),
// C in 0..~0.4 typical, H in degrees. Returns clamped {r,g,b,a:1} in 0..255.
// Needed because jsdom doesn't compute oklch() values — getComputedStyle
// returns the literal "oklch(...)" string. Without this, the entire
// Tailwind v4 color palette (which is OKLCH-based) is invisible to the
// detector's contrast / color checks.
function oklchToRgb(L, C, H) {
const hRad = (H * Math.PI) / 180;
return oklabToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
}
function oklabToRgb(L, a, b) {
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
const rLin = 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc;
const gLin = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc;
const bLin = -0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc;
const enc = (x) => {
const c = Math.max(0, Math.min(1, x));
return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
};
return {
r: Math.round(enc(rLin) * 255),
g: Math.round(enc(gLin) * 255),
b: Math.round(enc(bLin) * 255),
a: 1,
};
}
function hslToRgb(h, s, l) {
h = ((h % 360) + 360) % 360;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m0 = l - c / 2;
const [r, g, b] =
h < 60 ? [c, x, 0] :
h < 120 ? [x, c, 0] :
h < 180 ? [0, c, x] :
h < 240 ? [0, x, c] :
h < 300 ? [x, 0, c] : [c, 0, x];
return {
r: Math.round((r + m0) * 255),
g: Math.round((g + m0) * 255),
b: Math.round((b + m0) * 255),
a: 1,
};
}
function hwbToRgb(h, w, bl) {
if (w + bl >= 1) {
const g = Math.round((w / (w + bl)) * 255);
return { r: g, g, b: g, a: 1 };
}
const base = hslToRgb(h, 1, 0.5);
const mix = (c) => Math.round(((c / 255) * (1 - w - bl) + w) * 255);
return { r: mix(base.r), g: mix(base.g), b: mix(base.b), a: 1 };
}
// Common CSS named colors — the handful that actually show up in generated
// UIs, not the full 148-name spec list. Includes the achromatic names so a
// named gray parses (and correctly reads as no-chroma) instead of being
// treated as an unknown color.
const CSS_NAMED_COLORS = {
black: { r: 0, g: 0, b: 0 },
white: { r: 255, g: 255, b: 255 },
gray: { r: 128, g: 128, b: 128 },
grey: { r: 128, g: 128, b: 128 },
silver: { r: 192, g: 192, b: 192 },
dimgray: { r: 105, g: 105, b: 105 },
darkgray: { r: 169, g: 169, b: 169 },
lightgray: { r: 211, g: 211, b: 211 },
gainsboro: { r: 220, g: 220, b: 220 },
whitesmoke: { r: 245, g: 245, b: 245 },
red: { r: 255, g: 0, b: 0 },
crimson: { r: 220, g: 20, b: 60 },
tomato: { r: 255, g: 99, b: 71 },
coral: { r: 255, g: 127, b: 80 },
salmon: { r: 250, g: 128, b: 114 },
orange: { r: 255, g: 165, b: 0 },
gold: { r: 255, g: 215, b: 0 },
yellow: { r: 255, g: 255, b: 0 },
olive: { r: 128, g: 128, b: 0 },
lime: { r: 0, g: 255, b: 0 },
green: { r: 0, g: 128, b: 0 },
teal: { r: 0, g: 128, b: 128 },
turquoise: { r: 64, g: 224, b: 208 },
cyan: { r: 0, g: 255, b: 255 },
aqua: { r: 0, g: 255, b: 255 },
skyblue: { r: 135, g: 206, b: 235 },
dodgerblue: { r: 30, g: 144, b: 255 },
blue: { r: 0, g: 0, b: 255 },
navy: { r: 0, g: 0, b: 128 },
indigo: { r: 75, g: 0, b: 130 },
rebeccapurple: { r: 102, g: 51, b: 153 },
purple: { r: 128, g: 0, b: 128 },
violet: { r: 238, g: 130, b: 238 },
orchid: { r: 218, g: 112, b: 214 },
magenta: { r: 255, g: 0, b: 255 },
fuchsia: { r: 255, g: 0, b: 255 },
hotpink: { r: 255, g: 105, b: 180 },
pink: { r: 255, g: 192, b: 203 },
maroon: { r: 128, g: 0, b: 0 },
};
// Split a string on top-level commas (ignoring commas nested in parens).
function splitTopLevelCommas(str) {
const parts = [];
let depth = 0, start = 0;
for (let i = 0; i < str.length; i++) {
const ch = str[i];
if (ch === '(') depth++;
else if (ch === ')') depth = Math.max(0, depth - 1);
else if (ch === ',' && depth === 0) {
parts.push(str.slice(start, i).trim());
start = i + 1;
}
}
const tail = str.slice(start).trim();
if (tail) parts.push(tail);
return parts;
}
// Evaluate a CSS color-mix() expression to {r,g,b,a}. Returns null when
// the expression can't be resolved (unresolved var(), unknown colors).
//
// Mixing is done with premultiplied alpha in sRGB regardless of the
// declared interpolation space. That is exact for the dominant generated-UI
// pattern — `color-mix(in oklab, <color> N%, transparent)` — where the
// result is simply <color> at alpha N% in ANY rectangular space, and a
// close-enough approximation for opaque-opaque mixes (the detector only
// consumes these values for contrast/chroma thresholds, not for display).
function parseColorMix(str) {
const m = String(str).trim().match(/^color-mix\(/i);
if (!m) return null;
// Balanced-paren capture of the arguments.
let depth = 0, end = -1;
const open = str.indexOf('(');
for (let i = open; i < str.length; i++) {
if (str[i] === '(') depth++;
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
}
if (end < 0) return null;
const args = splitTopLevelCommas(str.slice(open + 1, end));
if (args.length !== 3 || !/^in\s/i.test(args[0])) return null;
const parseComponent = (component) => {
// Percentage may lead or trail the color per spec.
let pct = null;
let colorStr = component;
const trail = component.match(/\s+([\d.]+)%$/);
const lead = component.match(/^([\d.]+)%\s+/);
if (trail) { pct = parseFloat(trail[1]); colorStr = component.slice(0, trail.index).trim(); }
else if (lead) { pct = parseFloat(lead[1]); colorStr = component.slice(lead[0].length).trim(); }
let color;
if (/^transparent$/i.test(colorStr)) color = { r: 0, g: 0, b: 0, a: 0 };
else color = parseAnyColor(colorStr);
if (!color) return null;
return { color, pct };
};
const c1 = parseComponent(args[1]);
const c2 = parseComponent(args[2]);
if (!c1 || !c2) return null;
let p1 = c1.pct, p2 = c2.pct;
if (p1 == null && p2 == null) { p1 = 50; p2 = 50; }
else if (p1 == null) p1 = 100 - p2;
else if (p2 == null) p2 = 100 - p1;
const sum = p1 + p2;
if (sum <= 0) return null;
// Per spec: weights normalize to sum; when sum < 100 the result alpha is
// additionally scaled by sum/100.
const w1 = p1 / sum, w2 = p2 / sum;
const alphaScale = sum < 100 ? sum / 100 : 1;
const a1 = c1.color.a ?? 1, a2 = c2.color.a ?? 1;
const a = (a1 * w1 + a2 * w2) * alphaScale;
if (a <= 0) return { r: 0, g: 0, b: 0, a: 0 };
const mix = (ch) => Math.round((c1.color[ch] * a1 * w1 + c2.color[ch] * a2 * w2) / (a1 * w1 + a2 * w2));
return { r: mix('r'), g: mix('g'), b: mix('b'), a: Math.min(1, a) };
}
// Composite a translucent color over an opaque(ish) base (simple
// source-over in sRGB). Returns an opaque {r,g,b,a:1}.
function compositeColorOver(top, base) {
const a = top.a ?? 1;
return {
r: Math.round(top.r * a + base.r * (1 - a)),
g: Math.round(top.g * a + base.g * (1 - a)),
b: Math.round(top.b * a + base.b * (1 - a)),
a: 1,
};
}
// Extended color parser: rgb/rgba/hex/oklch/oklab/hsl/hwb/color-mix/common
// named colors. Returns null on no match. Use this when the input might be
// any CSS color form; use plain parseRgb when you only expect computed rgb()
// values from real browsers.
function parseAnyColor(s) {
if (!s || typeof s !== 'string') return null;
const str = s.trim();
if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
if (/^color-mix\(/i.test(str)) return parseColorMix(str);
let m;
m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+))?\s*\)/);
if (m) return { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: m[4] !== undefined ? +m[4] : 1 };
m = str.match(/^#([0-9a-f]{3,8})$/i);
if (m) {
const h = m[1];
if (h.length === 3 || h.length === 4) {
return {
r: parseInt(h[0] + h[0], 16),
g: parseInt(h[1] + h[1], 16),
b: parseInt(h[2] + h[2], 16),
a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
};
}
if (h.length === 6 || h.length === 8) {
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
};
}
}
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
// Match L (with optional %), then C and H separated permissively.
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const Lnum = parseFloat(m[1]);
const L = m[2] === '%' ? Lnum / 100 : Lnum;
const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
if (m[5] !== undefined) {
const alpha = parseFloat(m[5]);
rgb.a = m[6] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// OKLAB — a/b are signed axes; percentages map 100% → 0.4.
m = str.match(/oklab\(\s*([\d.]+)(%?)\s+(-?[\d.]+)(%?)\s+(-?[\d.]+)(%?)(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const L = m[2] === '%' ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
const a = m[4] === '%' ? parseFloat(m[3]) * 0.004 : parseFloat(m[3]);
const b = m[6] === '%' ? parseFloat(m[5]) * 0.004 : parseFloat(m[5]);
const rgb = oklabToRgb(L, a, b);
if (m[7] !== undefined) {
const alpha = parseFloat(m[7]);
rgb.a = m[8] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// HSL/HSLA — comma or space syntax, optional deg on hue.
m = str.match(/hsla?\(\s*(-?[\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%\s*[,\s]\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hslToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// HWB — hue whiteness% blackness%.
m = str.match(/hwb\(\s*(-?[\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hwbToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
const named = CSS_NAMED_COLORS[str.toLowerCase()];
if (named) return { ...named, a: 1 };
return null;
}
// Resolve var() refs in a color string (via customPropMap), then parse.
// Returns null on any failure. Used in jsdom-mode paths where
@@ -2923,15 +2696,24 @@ function checkElementGlowDOM(el) {
if (!boxShadow && !textShadow) return [];
// Use parent's background — glow radiates outward, so the surrounding context matters
// If resolveBackground returns null (gradient), try to infer from the gradient colors
let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el);
if (!parentBg) {
const parentBgInfo = resolveBackgroundInfo(el.parentElement || el);
// Unknown surface (an unreadable layer on the way up): skip only the
// gradient hunt below, which would walk PAST that layer and score the
// glow against a background the visitor never sees. checkGlow still runs
// with a null surface: the zero-offset chromatic halo tell holds on ANY
// background, and the static loop already passes the unresolved walk's
// null color straight through (detect-html.mjs uses resolveBackground).
let parentBg = parentBgInfo.color;
if (!parentBg && !parentBgInfo.unresolved) {
// Gradient background — sample its colors to determine if it's dark.
// Modern-syntax parsing matters here: body-level gradients now reach this
// fallback in browser mode, and their stops usually serialize as oklch.
// fallback in browser mode, and their stops usually serialize as oklch
// which the shared parseGradientColors reads via its color-function
// token capture.
let cur = el.parentElement;
while (cur && cur.nodeType === 1) {
const bgImage = getComputedStyle(cur).backgroundImage || '';
const gradColors = parseGradientColorsModern(bgImage);
const gradColors = parseGradientColors(bgImage);
if (gradColors.length > 0) {
// Average the gradient colors
const avg = { r: 0, g: 0, b: 0 };
@@ -2975,10 +2757,13 @@ function checkElementAIPaletteDOM(el) {
const hue = getHue(textColor);
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
if (isAIPalette) {
const parentBg = el.parentElement ? resolveBackground(el.parentElement) : null;
// Also check gradient parents
let effectiveBg = parentBg;
if (!effectiveBg) {
const parentBgInfo = el.parentElement
? resolveBackgroundInfo(el.parentElement)
: { color: null, unresolved: false };
// Unknown surface: leave effectiveBg null (no finding) rather than
// hunting gradient ancestors past a layer we could not read.
let effectiveBg = parentBgInfo.color;
if (!effectiveBg && !parentBgInfo.unresolved) {
let cur = el.parentElement;
while (cur && cur.nodeType === 1) {
const gi = getComputedStyle(cur).backgroundImage || '';
@@ -3784,7 +3569,8 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
const effectiveBg = resolveBackground(el, window, customPropMap);
const bgInfo = resolveBackgroundInfo(el, window, customPropMap);
const effectiveBg = bgInfo.color;
// jsdom returns literal "var(--X)" / "oklch(...)" for color, so plain
// parseRgb misses Tailwind-tokenized text colors. Resolve through the
// customPropMap first; fall back to parseRgb for vanilla rgb() pages.
@@ -3830,11 +3616,13 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
// element itself has no usable own background, that pseudo is the real
// surface for contrast purposes.
let finalEffectiveBg = effectiveBg;
let surfaceUnresolved = bgInfo.unresolved;
if ((!ownBg || (ownBg.a ?? 1) <= 0.5) && typeof window.getPseudoSurface === 'function') {
const pseudoSurface = window.getPseudoSurface(el);
if (pseudoSurface) {
ownBg = pseudoSurface;
finalEffectiveBg = pseudoSurface;
surfaceUnresolved = false;
}
}
@@ -3842,8 +3630,9 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
tag,
textColor,
bgColor: ownBg,
effectiveBg: finalEffectiveBg,
effectiveBgStops: finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
// Unknown surface: hand the checks nothing rather than a guess.
effectiveBg: surfaceUnresolved ? null : finalEffectiveBg,
effectiveBgStops: surfaceUnresolved || finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
fontSize: parseFloat(style.fontSize) || 16,
fontWeight: parseInt(style.fontWeight) || 400,
hasDirectText,
@@ -5663,6 +5452,7 @@ export {
checkHtmlPatterns,
readOwnBackgroundColor,
resolveBackground,
resolveBackgroundInfo,
resolveGradientStops,
parseRadiusToPx,
resolveBorderRadiusPx,
@@ -71,11 +71,43 @@ function contrastRatio(c1, c2) {
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
// The CSS color functions worth pulling out of a longer declaration. The set
// is deliberately closed: `linear-gradient(` and `url(` also look like
// `name(` and must not be read as colors.
const COLOR_FUNCTION_NAMES = new Set([
'rgb', 'rgba', 'hsl', 'hsla', 'hwb', 'oklch', 'oklab', 'lch', 'lab', 'color', 'color-mix',
]);
// Pull every color-function token out of a value, with balanced-paren capture
// so nested forms (`color-mix(in oklab, oklch(...) 20%, transparent)`) survive
// whole. Returns the raw substrings in source order.
function extractColorFunctionTokens(value) {
const str = String(value || '');
const tokens = [];
const re = /([a-z][a-z-]*)\(/gi;
let m;
while ((m = re.exec(str)) !== null) {
if (!COLOR_FUNCTION_NAMES.has(m[1].toLowerCase())) continue;
let depth = 0, end = -1;
for (let i = m.index + m[0].length - 1; i < str.length; i++) {
if (str[i] === '(') depth++;
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
}
if (end < 0) break;
tokens.push(str.slice(m.index, end + 1));
re.lastIndex = end + 1;
}
return tokens;
}
function parseGradientColors(bgImage) {
if (!bgImage || !bgImage.includes('gradient')) return [];
const colors = [];
for (const m of bgImage.matchAll(/rgba?\([^)]+\)/g)) {
const c = parseRgb(m[0]);
// Stops arrive in whatever syntax the author wrote and the browser kept.
// A dark ground painted as `linear-gradient(oklch(...), oklch(...))` used
// to read as a gradient with no stops at all.
for (const token of extractColorFunctionTokens(bgImage)) {
const c = parseAnyColor(token);
if (c) colors.push(c);
}
for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
@@ -112,13 +144,445 @@ function colorToHex(c) {
return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
}
// ─── Color-space conversions ────────────────────────────────────────────────
//
// Every function here lands on 8-bit sRGB, clamped to gamut. Chrome, Safari,
// and Firefox all keep the authored color space in getComputedStyle output
// (`oklch(0.84 0.19 80.46)`, `lch(20 5 60)`, `color(srgb 1.04 0.72 -0.21)`),
// so a detector that only reads rgb() is blind on any modern palette. The
// expected outputs are pinned in tests/detect-antipatterns.test.js against
// what Chrome itself paints for the same strings.
function clamp01(x) {
return Number.isFinite(x) ? Math.max(0, Math.min(1, x)) : 0;
}
// Linear-light sRGB channel to the encoded 0-255 value.
function encodeSrgbChannel(x) {
const c = clamp01(x);
return Math.round((c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055) * 255);
}
function decodeSrgbChannel(x) {
const c = Number.isFinite(x) ? x : 0;
const sign = c < 0 ? -1 : 1;
const abs = Math.abs(c);
return sign * (abs <= 0.04045 ? abs / 12.92 : Math.pow((abs + 0.055) / 1.055, 2.4));
}
function linearSrgbToColor(r, g, b, a = 1) {
return { r: encodeSrgbChannel(r), g: encodeSrgbChannel(g), b: encodeSrgbChannel(b), a };
}
// OKLab to sRGB (Björn Ottosson's matrices). L in 0..1, a/b are signed axes.
function oklabToRgb(L, a, b) {
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
return linearSrgbToColor(
4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc,
-1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc,
-0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc,
);
}
// OKLCH to sRGB. L in 0..1, C in 0..~0.4 typical, H in degrees. Chroma past
// the sRGB gamut clamps per channel rather than producing NaN.
function oklchToRgb(L, C, H) {
const hRad = (H * Math.PI) / 180;
return oklabToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
}
// CIE Lab to sRGB. CSS lab()/lch() use the D50 white point; the matrix below
// is the Bradford-adapted XYZ-D50 to linear-sRGB transform from CSS Color 4.
function labToRgb(L, a, b) {
const kappa = 24389 / 27, epsilon = 216 / 24389;
const fy = (L + 16) / 116, fx = fy + a / 500, fz = fy - b / 200;
const invert = (t) => (t * t * t > epsilon ? t * t * t : (116 * t - 16) / kappa);
const yr = L > kappa * epsilon ? Math.pow((L + 16) / 116, 3) : L / kappa;
const Xn = 0.3457 / 0.3585, Zn = (1 - 0.3457 - 0.3585) / 0.3585;
const x = invert(fx) * Xn, y = yr, z = invert(fz) * Zn;
return linearSrgbToColor(
3.1341359569958707 * x - 1.6173863321612538 * y - 0.4906619460083532 * z,
-0.9787955029120890 * x + 1.9162545672595240 * y + 0.0334427311613195 * z,
0.0719553798841168 * x - 0.2289768264158322 * y + 1.4053860583241250 * z,
);
}
function lchToRgb(L, C, H) {
const hRad = (H * Math.PI) / 180;
return labToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
}
// color(<space> c1 c2 c3) for the spaces that turn up in real stylesheets.
// `srgb` is what Chrome serializes most color-mix() results into, routinely
// with channels outside 0..1. Spaces we do not model return null so callers
// abstain instead of measuring against a color we invented.
function colorFunctionToRgb(space, c1, c2, c3) {
switch (space) {
case 'srgb':
return { r: Math.round(clamp01(c1) * 255), g: Math.round(clamp01(c2) * 255), b: Math.round(clamp01(c3) * 255), a: 1 };
case 'srgb-linear':
return linearSrgbToColor(c1, c2, c3);
case 'display-p3': {
const [R, G, B] = [decodeSrgbChannel(c1), decodeSrgbChannel(c2), decodeSrgbChannel(c3)];
return linearSrgbToColor(
1.2249401762805587 * R - 0.2249404646817506 * G + 0.0000002884022551 * B,
-0.0420569547096138 * R + 1.0420571661298634 * G - 0.0000002113202247 * B,
-0.0196375587040044 * R - 0.0786360772174755 * G + 1.0982736359214800 * B,
);
}
default:
return null;
}
}
function hslToRgb(h, s, l) {
h = ((h % 360) + 360) % 360;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m0 = l - c / 2;
const [r, g, b] =
h < 60 ? [c, x, 0] :
h < 120 ? [x, c, 0] :
h < 180 ? [0, c, x] :
h < 240 ? [0, x, c] :
h < 300 ? [x, 0, c] : [c, 0, x];
return {
r: Math.round((r + m0) * 255),
g: Math.round((g + m0) * 255),
b: Math.round((b + m0) * 255),
a: 1,
};
}
function hwbToRgb(h, w, bl) {
if (w + bl >= 1) {
const g = Math.round((w / (w + bl)) * 255);
return { r: g, g, b: g, a: 1 };
}
const base = hslToRgb(h, 1, 0.5);
const mix = (c) => Math.round(((c / 255) * (1 - w - bl) + w) * 255);
return { r: mix(base.r), g: mix(base.g), b: mix(base.b), a: 1 };
}
// Common CSS named colors — the handful that actually show up in generated
// UIs, not the full 148-name spec list. Includes the achromatic names so a
// named gray parses (and correctly reads as no-chroma) instead of being
// treated as an unknown color.
const CSS_NAMED_COLORS = {
black: { r: 0, g: 0, b: 0 },
white: { r: 255, g: 255, b: 255 },
gray: { r: 128, g: 128, b: 128 },
grey: { r: 128, g: 128, b: 128 },
silver: { r: 192, g: 192, b: 192 },
dimgray: { r: 105, g: 105, b: 105 },
darkgray: { r: 169, g: 169, b: 169 },
lightgray: { r: 211, g: 211, b: 211 },
gainsboro: { r: 220, g: 220, b: 220 },
whitesmoke: { r: 245, g: 245, b: 245 },
red: { r: 255, g: 0, b: 0 },
crimson: { r: 220, g: 20, b: 60 },
tomato: { r: 255, g: 99, b: 71 },
coral: { r: 255, g: 127, b: 80 },
salmon: { r: 250, g: 128, b: 114 },
orange: { r: 255, g: 165, b: 0 },
gold: { r: 255, g: 215, b: 0 },
yellow: { r: 255, g: 255, b: 0 },
olive: { r: 128, g: 128, b: 0 },
lime: { r: 0, g: 255, b: 0 },
green: { r: 0, g: 128, b: 0 },
teal: { r: 0, g: 128, b: 128 },
turquoise: { r: 64, g: 224, b: 208 },
cyan: { r: 0, g: 255, b: 255 },
aqua: { r: 0, g: 255, b: 255 },
skyblue: { r: 135, g: 206, b: 235 },
dodgerblue: { r: 30, g: 144, b: 255 },
blue: { r: 0, g: 0, b: 255 },
navy: { r: 0, g: 0, b: 128 },
indigo: { r: 75, g: 0, b: 130 },
rebeccapurple: { r: 102, g: 51, b: 153 },
purple: { r: 128, g: 0, b: 128 },
violet: { r: 238, g: 130, b: 238 },
orchid: { r: 218, g: 112, b: 214 },
magenta: { r: 255, g: 0, b: 255 },
fuchsia: { r: 255, g: 0, b: 255 },
hotpink: { r: 255, g: 105, b: 180 },
pink: { r: 255, g: 192, b: 203 },
maroon: { r: 128, g: 0, b: 0 },
};
// Split a string on top-level commas (ignoring commas nested in parens).
function splitTopLevelCommas(str) {
const parts = [];
let depth = 0, start = 0;
for (let i = 0; i < str.length; i++) {
const ch = str[i];
if (ch === '(') depth++;
else if (ch === ')') depth = Math.max(0, depth - 1);
else if (ch === ',' && depth === 0) {
parts.push(str.slice(start, i).trim());
start = i + 1;
}
}
const tail = str.slice(start).trim();
if (tail) parts.push(tail);
return parts;
}
// Evaluate a CSS color-mix() expression to {r,g,b,a}. Returns null when
// the expression can't be resolved (unresolved var(), unknown colors).
//
// Mixing is done with premultiplied alpha in sRGB regardless of the
// declared interpolation space. That is exact for the dominant generated-UI
// pattern — `color-mix(in oklab, <color> N%, transparent)` — where the
// result is simply <color> at alpha N% in ANY rectangular space, and a
// close-enough approximation for opaque-opaque mixes (the detector only
// consumes these values for contrast/chroma thresholds, not for display).
function parseColorMix(str) {
const m = String(str).trim().match(/^color-mix\(/i);
if (!m) return null;
// Balanced-paren capture of the arguments.
let depth = 0, end = -1;
const open = str.indexOf('(');
for (let i = open; i < str.length; i++) {
if (str[i] === '(') depth++;
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
}
if (end < 0) return null;
const args = splitTopLevelCommas(str.slice(open + 1, end));
if (args.length !== 3 || !/^in\s/i.test(args[0])) return null;
const parseComponent = (component) => {
// Percentage may lead or trail the color per spec.
let pct = null;
let colorStr = component;
const trail = component.match(/\s+([\d.]+)%$/);
const lead = component.match(/^([\d.]+)%\s+/);
if (trail) { pct = parseFloat(trail[1]); colorStr = component.slice(0, trail.index).trim(); }
else if (lead) { pct = parseFloat(lead[1]); colorStr = component.slice(lead[0].length).trim(); }
let color;
if (/^transparent$/i.test(colorStr)) color = { r: 0, g: 0, b: 0, a: 0 };
else color = parseAnyColor(colorStr);
if (!color) return null;
return { color, pct };
};
const c1 = parseComponent(args[1]);
const c2 = parseComponent(args[2]);
if (!c1 || !c2) return null;
let p1 = c1.pct, p2 = c2.pct;
if (p1 == null && p2 == null) { p1 = 50; p2 = 50; }
else if (p1 == null) p1 = 100 - p2;
else if (p2 == null) p2 = 100 - p1;
const sum = p1 + p2;
if (sum <= 0) return null;
// Per spec: weights normalize to sum; when sum < 100 the result alpha is
// additionally scaled by sum/100.
const w1 = p1 / sum, w2 = p2 / sum;
const alphaScale = sum < 100 ? sum / 100 : 1;
const a1 = c1.color.a ?? 1, a2 = c2.color.a ?? 1;
const a = (a1 * w1 + a2 * w2) * alphaScale;
if (a <= 0) return { r: 0, g: 0, b: 0, a: 0 };
const mix = (ch) => Math.round((c1.color[ch] * a1 * w1 + c2.color[ch] * a2 * w2) / (a1 * w1 + a2 * w2));
return { r: mix('r'), g: mix('g'), b: mix('b'), a: Math.min(1, a) };
}
// Composite a translucent color over an opaque(ish) base (simple
// source-over in sRGB). Returns an opaque {r,g,b,a:1}.
function compositeColorOver(top, base) {
const a = top.a ?? 1;
return {
r: Math.round(top.r * a + base.r * (1 - a)),
g: Math.round(top.g * a + base.g * (1 - a)),
b: Math.round(top.b * a + base.b * (1 - a)),
a: 1,
};
}
// A color() / lab() / lch() component: a bare number, a percentage against
// `scale`, or the `none` keyword (which resolves to zero for our purposes).
function parseColorComponent(token, scale = 1) {
if (token == null) return null;
const t = String(token).trim();
if (/^none$/i.test(t)) return 0;
const num = parseFloat(t);
if (!Number.isFinite(num)) return null;
return t.endsWith('%') ? (num / 100) * scale : num;
}
function parseAlphaToken(token) {
if (token == null) return 1;
const t = String(token).trim();
if (/^none$/i.test(t)) return 1;
const num = parseFloat(t);
if (!Number.isFinite(num)) return 1;
return t.endsWith('%') ? num / 100 : num;
}
// Extended color parser: rgb/rgba/hex/oklch/oklab/lch/lab/hsl/hwb/color()/
// color-mix/common named colors. Returns null on no match. Use this when the
// input might be any CSS color form; use plain parseRgb when you only expect
// computed rgb() values from real browsers.
function parseAnyColor(s) {
if (!s || typeof s !== 'string') return null;
const str = s.trim();
if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
if (/^color-mix\(/i.test(str)) return parseColorMix(str);
let m;
m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/);
if (m) {
const c = { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: 1 };
if (m[4] !== undefined) c.a = m[5] === '%' ? parseFloat(m[4]) / 100 : +m[4];
return c;
}
m = str.match(/^#([0-9a-f]{3,8})$/i);
if (m) {
const h = m[1];
if (h.length === 3 || h.length === 4) {
return {
r: parseInt(h[0] + h[0], 16),
g: parseInt(h[1] + h[1], 16),
b: parseInt(h[2] + h[2], 16),
a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
};
}
if (h.length === 6 || h.length === 8) {
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
};
}
}
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
// Match L (with optional %), then C and H separated permissively.
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const Lnum = parseFloat(m[1]);
const L = m[2] === '%' ? Lnum / 100 : Lnum;
const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
if (m[5] !== undefined) {
const alpha = parseFloat(m[5]);
rgb.a = m[6] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// OKLAB — a/b are signed axes; percentages map 100% → 0.4.
m = str.match(/oklab\(\s*([\d.]+)(%?)\s+(-?[\d.]+)(%?)\s+(-?[\d.]+)(%?)(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const L = m[2] === '%' ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
const a = m[4] === '%' ? parseFloat(m[3]) * 0.004 : parseFloat(m[3]);
const b = m[6] === '%' ? parseFloat(m[5]) * 0.004 : parseFloat(m[5]);
const rgb = oklabToRgb(L, a, b);
if (m[7] !== undefined) {
const alpha = parseFloat(m[7]);
rgb.a = m[8] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// LCH / LAB — CIE, D50 white point. Chrome serializes lch(20% 5 60) as
// `lch(20 5 60)`, so L arrives with or without its percent sign. In both
// spaces L runs 0..100 and 100% means 100.
m = str.match(/^lch\(\s*([\d.]+%?|none)\s+([\d.]+%?|none)\s+(-?[\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
if (m) {
const L = parseColorComponent(m[1], 100);
const C = parseColorComponent(m[2], 150);
const H = parseFloat(m[3]);
if (L == null || C == null || !Number.isFinite(H)) return null;
const rgb = lchToRgb(L, C, H);
rgb.a = parseAlphaToken(m[4]);
return rgb;
}
m = str.match(/^lab\(\s*([\d.]+%?|none)\s+(-?[\d.]+%?|none)\s+(-?[\d.]+%?|none)(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
if (m) {
const L = parseColorComponent(m[1], 100);
const a = parseColorComponent(m[2], 125);
const b = parseColorComponent(m[3], 125);
if (L == null || a == null || b == null) return null;
const rgb = labToRgb(L, a, b);
rgb.a = parseAlphaToken(m[4]);
return rgb;
}
// color(<space> c1 c2 c3 [/ alpha]) — what Chrome hands back for most
// color-mix() results and for any wide-gamut color an author wrote.
m = str.match(/^color\(\s*([a-z0-9-]+)\s+(-?[\d.eE+-]+%?|none)\s+(-?[\d.eE+-]+%?|none)\s+(-?[\d.eE+-]+%?|none)(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
if (m) {
const c1 = parseColorComponent(m[2]);
const c2 = parseColorComponent(m[3]);
const c3 = parseColorComponent(m[4]);
if (c1 == null || c2 == null || c3 == null) return null;
const rgb = colorFunctionToRgb(m[1].toLowerCase(), c1, c2, c3);
if (!rgb) return null;
rgb.a = parseAlphaToken(m[5]);
return rgb;
}
// HSL/HSLA — comma or space syntax, optional deg on hue.
m = str.match(/hsla?\(\s*(-?[\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%\s*[,\s]\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hslToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// HWB — hue whiteness% blackness%.
m = str.match(/hwb\(\s*(-?[\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hwbToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
const named = CSS_NAMED_COLORS[str.toLowerCase()];
if (named) return { ...named, a: 1 };
return null;
}
// True when a computed background-color string names no paint at all. Used to
// tell "this layer is see-through" (walk on to the ancestor) apart from "this
// layer has a color we could not read" (stop and abstain).
//
// `inherit` belongs here even though it is not literally see-through: it means
// "paint with the parent's background-color", and walking on to the parent IS
// that resolution. Real browsers resolve the keyword before getComputedStyle
// output; only jsdom's partial cascade hands it through verbatim, and treating
// it as unreadable would make the walk abstain on a surface it can know.
// (`currentcolor` is NOT here — it is real paint in the element's own text
// color; resolveBackgroundInfo substitutes the computed color for it.)
function isNoPaintColorValue(value) {
const v = String(value || '').trim().toLowerCase();
if (!v) return true;
return v === 'transparent' || v === 'none' || v === 'initial' || v === 'inherit' || v === 'unset' || v === 'revert' || v === 'revert-layer';
}
export {
isNeutralColor,
parseRgb,
relativeLuminance,
contrastRatio,
parseGradientColors,
extractColorFunctionTokens,
hasChroma,
getHue,
colorToHex,
oklabToRgb,
oklchToRgb,
labToRgb,
lchToRgb,
colorFunctionToRgb,
hslToRgb,
hwbToRgb,
CSS_NAMED_COLORS,
splitTopLevelCommas,
parseColorMix,
parseAnyColor,
compositeColorOver,
isNoPaintColorValue,
};
File diff suppressed because it is too large Load Diff
@@ -11,14 +11,21 @@ import {
isBrandFontOnOwnDomain,
} from '../shared/constants.mjs';
import {
CSS_NAMED_COLORS,
colorToHex,
compositeColorOver,
contrastRatio,
getHue,
hasChroma,
isNeutralColor,
isNoPaintColorValue,
oklchToRgb,
parseAnyColor,
parseColorMix,
parseGradientColors,
parseRgb,
relativeLuminance,
splitTopLevelCommas,
} from '../shared/color.mjs';
import { extractGoogleFontFamilies } from '../shared/fonts.mjs';
@@ -1731,7 +1738,7 @@ function readOwnBackgroundColor(el, computedStyle) {
// One element's background-color as the cascade walk sees it: computed style
// first (with the modern-color fallback), then, in static mode only,
// custom-prop resolution and the inline-shorthand peek. Shared by
// resolveBackground and resolveGradientStops so both walks read the same
// resolveBackgroundInfo and resolveGradientStops so both walks read the same
// surfaces.
function readCascadeBackgroundColor(current, style, customPropMap) {
let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
@@ -1755,7 +1762,19 @@ function readCascadeBackgroundColor(current, style, customPropMap) {
return bg;
}
function resolveBackground(el, win, customPropMap) {
// Walk up for the surface the element's text is painted on.
//
// Returns { color, unresolved }:
// • color set — the effective surface, overlays composited in.
// • unresolved: true — a layer on the way up paints a color this parser
// cannot read, so the surface is unknown. Callers
// must SKIP their contrast checks. Guessing white
// here is what flooded dark themes with false
// "on #ffffff" findings: one abstention costs a
// single finding, one wrong guess costs a hundred.
// • both null/false — no solid color, but a gradient or image is in
// play; callers fall back to its color stops.
function resolveBackgroundInfo(el, win, customPropMap) {
let current = el;
// Translucent layers (0.1 < a < 1) found on the way down to an opaque
// base. A browser composites these over the base; the old behavior
@@ -1783,59 +1802,81 @@ function resolveBackground(el, win, customPropMap) {
// body backgrounds.
// Real browsers serialize wide-gamut computed values as oklab()/oklch()
// (e.g. any color-mix() result), which plain parseRgb misses.
const bg = readCascadeBackgroundColor(current, style, customPropMap);
let bg = readCascadeBackgroundColor(current, style, customPropMap);
// `background-color: currentcolor` paints with the element's own text
// color — real paint whose value we know. Real browsers resolve the
// keyword before getComputedStyle output; jsdom hands it through
// verbatim, and without this substitution the layer would read as
// unparseable and force a needless abstention.
if ((!bg || bg.a < 0.1) && /^currentcolor$/i.test(String(style.backgroundColor || '').trim())) {
// The static cascade resolves var() text tokens before checks run, so
// style.color is normally already an rgb string here; parseColorResolved
// is defense in depth for any future caller that passes a live
// customPropMap (it matches the text-color path in checkElementColors
// and reduces to parseAnyColor when the map is null or absent).
bg = parseRgb(style.color) || parseColorResolved(style.color, customPropMap);
}
if (bg && bg.a > 0.1) {
if (bg.a >= 0.99) return flatten(bg);
if (bg.a >= 0.99) return { color: flatten(bg), unresolved: false };
overlays.push(bg);
} else if (!bg && !isNoPaintColorValue(style.backgroundColor)) {
// This layer names a color we could not parse (a color space we do not
// model, an unresolved var(), a syntax newer than the parser). It may
// well be opaque, which would make every ancestor below it invisible —
// so the surface is unknown and the walk stops here rather than
// reporting an ancestor the visitor never sees.
return { color: null, unresolved: true };
}
// No solid bg-color at this level, but this level paints an image. CSS
// stacks background-image layers first-on-top, so which layer leads
// decides what the visitor sees:
// • gradient on top — the gradient is the surface. Hand the caller a
// null color so it falls back to the gradient's own stops (body
// grounds, gradient buttons, hero sections).
// • url() on top — the surface is an image whose pixels this engine
// cannot read, and it may fully cover every layer and ancestor
// beneath it. Same contract as an unparseable color: abstain, so
// the gradient-stop fallback never measures a gradient the image
// hides (the shipped miss: `url(photo), linear-gradient(...)`
// reported low-contrast against the invisible gradient's stops).
if (hasGradientOrUrl) {
const layers = splitTopLevelCommas(bgImage);
const topPaintLayer = layers.find(
(layer) => /gradient\s*\(/i.test(layer) || /url\s*\(/i.test(layer),
);
const gradientOnTop = !!topPaintLayer
&& /gradient\s*\(/i.test(topPaintLayer)
&& !/^\s*url\s*\(/i.test(topPaintLayer);
if (!gradientOnTop) return { color: null, unresolved: true };
// Gradient on top of a url() layer: the image shows through wherever
// the gradient is not fully opaque, so a translucent wash like
// `linear-gradient(rgba(0,0,0,.2), rgba(0,0,0,.2)), url(photo)` paints
// a blend with pixels this engine cannot read. Only a gradient whose
// every readable stop is opaque provably covers the image; otherwise
// the surface is unknown — abstain rather than hand callers gradient
// stops (or a stop average) the visitor never sees unmixed.
const urlBeneath = layers.some(
(layer) => layer !== topPaintLayer && /url\s*\(/i.test(layer),
);
if (urlBeneath) {
const topStops = parseGradientColors(topPaintLayer);
const provablyOpaque = topStops.length > 0 && topStops.every((s) => (s.a ?? 1) >= 0.99);
if (!provablyOpaque) return { color: null, unresolved: true };
}
return { color: null, unresolved: false };
}
// No solid bg-color at this level. If THIS level has a gradient/url
// with no underlying solid color we can read:
// • on body/html: assume white. Body-level gradients are almost
// always decorative texture (paper grain, noise) on top of a
// solid bg-color the page set via `background: var(--paper)`
// shorthand — which jsdom can't decompose into bg-color. The
// downstream gradient-stops fallback path produces catastrophic
// false positives in this case (gradient noise stops have
// accidental browns/blacks that look like card backgrounds).
// • on other elements: bail to null and let the caller fall back
// to gradient stops (gradient buttons / hero sections are real
// bgs worth checking against).
// A gradient or image with no solid color under it, at any level
// including body/html, means the visible ground is that layer itself.
// Return null so the caller measures against the actual gradient stops,
// or skips when nothing is parseable — skipping beats a wrong ratio.
//
// Body/html used to assume white here, a guard written for jsdom, which
// never decomposed the `background:` shorthand and so could not see the
// solid paper color a texture gradient usually sits on. It turned every
// light-on-dark page into a wall of low-contrast false positives (a dark
// oklch body gradient produced ~120 "on #ffffff" findings on one site).
// Both engines can see shorthand solids now — the browser natively, the
// static cascade via expandStaticDeclaration + var() resolution — so a
// missing solid is real, and the old failure case cannot recur: opaque
// stops fully cover any hidden solid (they ARE the ground), alpha stops
// composite over the resolved base or the white canvas default, and
// unresolvable stops drop rather than guess.
if (hasGradientOrUrl) return null;
current = current.parentElement;
}
return flatten({ r: 255, g: 255, b: 255, a: 1 });
// Every layer up to the document root was genuinely see-through, so the
// browser paints its default canvas. This is the ONLY case that earns the
// white assumption.
return { color: flatten({ r: 255, g: 255, b: 255, a: 1 }), unresolved: false };
}
// parseGradientColors (shared) reads only the legacy serializations: rgb()
// and hex stops. Browsers keep modern-space stops in computed backgroundImage
// exactly as authored — `linear-gradient(oklch(7% 0.006 95), …)` stays oklch —
// which is what every token-driven page produces. Route those through
// parseAnyColor so a gradient ground is measurable rather than invisible.
function parseGradientColorsModern(bgImage) {
if (!bgImage || !/gradient/i.test(bgImage)) return [];
const colors = parseGradientColors(bgImage);
for (const m of bgImage.matchAll(/(?:oklch|oklab|hsla?|hwb)\(\s*[^()]*\)/gi)) {
const c = parseAnyColor(m[0]);
if (c) colors.push(c);
}
return colors;
function resolveBackground(el, win, customPropMap) {
return resolveBackgroundInfo(el, win, customPropMap).color;
}
// Walk parents looking for a gradient background and return its color stops.
@@ -1861,7 +1902,10 @@ function resolveGradientStops(el, win, customPropMap) {
if (bgImage && bgImage !== 'none' && /url\s*\(/i.test(bgImage)) return null;
let stops = null;
if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
const parsed = parseGradientColorsModern(bgImage);
// parseGradientColors (shared) reads modern-space stops too — oklch,
// color-mix and friends via balanced-paren token capture — so browser
// computed values that keep the authored syntax stay measurable.
const parsed = parseGradientColors(bgImage);
if (parsed.length > 0) stops = parsed;
}
if (!stops && !DETECTOR_IS_BROWSER) {
@@ -1869,7 +1913,7 @@ function resolveGradientStops(el, win, customPropMap) {
const rawStyle = current.getAttribute?.('style') || '';
const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
if (bgMatch && /gradient/i.test(bgMatch[1])) {
const parsed = parseGradientColorsModern(bgMatch[1]);
const parsed = parseGradientColors(bgMatch[1]);
if (parsed.length > 0) stops = parsed;
}
}
@@ -2115,13 +2159,19 @@ function checkElementColorsDOM(el) {
if (style.visibility === 'hidden' || effectiveOpacityDOM(el) <= 0.02) return [];
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
let effectiveBg = resolveBackground(el);
const bgInfo = resolveBackgroundInfo(el);
let effectiveBg = bgInfo.color;
// An unreadable surface anywhere up the chain: skip the gradient-stop
// fallback too, so nothing downstream measures against a ground we never
// resolved.
let surfaceUnresolved = bgInfo.unresolved;
let ownBg = readOwnBackgroundColor(el, style);
if (!ownBg || (ownBg.a ?? 1) <= 0.5) {
const pseudoSurface = readPseudoSurfaceDOM(el, rect);
if (pseudoSurface) {
ownBg = pseudoSurface;
effectiveBg = pseudoSurface;
surfaceUnresolved = false;
}
}
return checkColors({
@@ -2133,8 +2183,8 @@ function checkElementColorsDOM(el) {
// an oklch token near its own oklch background).
textColor: parseRgb(style.color) || parseAnyColor(style.color),
bgColor: ownBg,
effectiveBg,
effectiveBgStops: effectiveBg ? null : resolveGradientStops(el),
effectiveBg: surfaceUnresolved ? null : effectiveBg,
effectiveBgStops: surfaceUnresolved || effectiveBg ? null : resolveGradientStops(el),
fontSize: parseFloat(style.fontSize) || 16,
fontWeight: parseInt(style.fontWeight) || 400,
hasDirectText,
@@ -2284,283 +2334,6 @@ function resolveVarRefs(raw, customPropMap, depth = 0) {
});
}
// OKLCH → sRGB conversion (Björn Ottosson's matrices). L in 0..1 (or %),
// C in 0..~0.4 typical, H in degrees. Returns clamped {r,g,b,a:1} in 0..255.
// Needed because jsdom doesn't compute oklch() values — getComputedStyle
// returns the literal "oklch(...)" string. Without this, the entire
// Tailwind v4 color palette (which is OKLCH-based) is invisible to the
// detector's contrast / color checks.
function oklchToRgb(L, C, H) {
const hRad = (H * Math.PI) / 180;
return oklabToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
}
function oklabToRgb(L, a, b) {
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
const rLin = 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc;
const gLin = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc;
const bLin = -0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc;
const enc = (x) => {
const c = Math.max(0, Math.min(1, x));
return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
};
return {
r: Math.round(enc(rLin) * 255),
g: Math.round(enc(gLin) * 255),
b: Math.round(enc(bLin) * 255),
a: 1,
};
}
function hslToRgb(h, s, l) {
h = ((h % 360) + 360) % 360;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m0 = l - c / 2;
const [r, g, b] =
h < 60 ? [c, x, 0] :
h < 120 ? [x, c, 0] :
h < 180 ? [0, c, x] :
h < 240 ? [0, x, c] :
h < 300 ? [x, 0, c] : [c, 0, x];
return {
r: Math.round((r + m0) * 255),
g: Math.round((g + m0) * 255),
b: Math.round((b + m0) * 255),
a: 1,
};
}
function hwbToRgb(h, w, bl) {
if (w + bl >= 1) {
const g = Math.round((w / (w + bl)) * 255);
return { r: g, g, b: g, a: 1 };
}
const base = hslToRgb(h, 1, 0.5);
const mix = (c) => Math.round(((c / 255) * (1 - w - bl) + w) * 255);
return { r: mix(base.r), g: mix(base.g), b: mix(base.b), a: 1 };
}
// Common CSS named colors — the handful that actually show up in generated
// UIs, not the full 148-name spec list. Includes the achromatic names so a
// named gray parses (and correctly reads as no-chroma) instead of being
// treated as an unknown color.
const CSS_NAMED_COLORS = {
black: { r: 0, g: 0, b: 0 },
white: { r: 255, g: 255, b: 255 },
gray: { r: 128, g: 128, b: 128 },
grey: { r: 128, g: 128, b: 128 },
silver: { r: 192, g: 192, b: 192 },
dimgray: { r: 105, g: 105, b: 105 },
darkgray: { r: 169, g: 169, b: 169 },
lightgray: { r: 211, g: 211, b: 211 },
gainsboro: { r: 220, g: 220, b: 220 },
whitesmoke: { r: 245, g: 245, b: 245 },
red: { r: 255, g: 0, b: 0 },
crimson: { r: 220, g: 20, b: 60 },
tomato: { r: 255, g: 99, b: 71 },
coral: { r: 255, g: 127, b: 80 },
salmon: { r: 250, g: 128, b: 114 },
orange: { r: 255, g: 165, b: 0 },
gold: { r: 255, g: 215, b: 0 },
yellow: { r: 255, g: 255, b: 0 },
olive: { r: 128, g: 128, b: 0 },
lime: { r: 0, g: 255, b: 0 },
green: { r: 0, g: 128, b: 0 },
teal: { r: 0, g: 128, b: 128 },
turquoise: { r: 64, g: 224, b: 208 },
cyan: { r: 0, g: 255, b: 255 },
aqua: { r: 0, g: 255, b: 255 },
skyblue: { r: 135, g: 206, b: 235 },
dodgerblue: { r: 30, g: 144, b: 255 },
blue: { r: 0, g: 0, b: 255 },
navy: { r: 0, g: 0, b: 128 },
indigo: { r: 75, g: 0, b: 130 },
rebeccapurple: { r: 102, g: 51, b: 153 },
purple: { r: 128, g: 0, b: 128 },
violet: { r: 238, g: 130, b: 238 },
orchid: { r: 218, g: 112, b: 214 },
magenta: { r: 255, g: 0, b: 255 },
fuchsia: { r: 255, g: 0, b: 255 },
hotpink: { r: 255, g: 105, b: 180 },
pink: { r: 255, g: 192, b: 203 },
maroon: { r: 128, g: 0, b: 0 },
};
// Split a string on top-level commas (ignoring commas nested in parens).
function splitTopLevelCommas(str) {
const parts = [];
let depth = 0, start = 0;
for (let i = 0; i < str.length; i++) {
const ch = str[i];
if (ch === '(') depth++;
else if (ch === ')') depth = Math.max(0, depth - 1);
else if (ch === ',' && depth === 0) {
parts.push(str.slice(start, i).trim());
start = i + 1;
}
}
const tail = str.slice(start).trim();
if (tail) parts.push(tail);
return parts;
}
// Evaluate a CSS color-mix() expression to {r,g,b,a}. Returns null when
// the expression can't be resolved (unresolved var(), unknown colors).
//
// Mixing is done with premultiplied alpha in sRGB regardless of the
// declared interpolation space. That is exact for the dominant generated-UI
// pattern — `color-mix(in oklab, <color> N%, transparent)` — where the
// result is simply <color> at alpha N% in ANY rectangular space, and a
// close-enough approximation for opaque-opaque mixes (the detector only
// consumes these values for contrast/chroma thresholds, not for display).
function parseColorMix(str) {
const m = String(str).trim().match(/^color-mix\(/i);
if (!m) return null;
// Balanced-paren capture of the arguments.
let depth = 0, end = -1;
const open = str.indexOf('(');
for (let i = open; i < str.length; i++) {
if (str[i] === '(') depth++;
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
}
if (end < 0) return null;
const args = splitTopLevelCommas(str.slice(open + 1, end));
if (args.length !== 3 || !/^in\s/i.test(args[0])) return null;
const parseComponent = (component) => {
// Percentage may lead or trail the color per spec.
let pct = null;
let colorStr = component;
const trail = component.match(/\s+([\d.]+)%$/);
const lead = component.match(/^([\d.]+)%\s+/);
if (trail) { pct = parseFloat(trail[1]); colorStr = component.slice(0, trail.index).trim(); }
else if (lead) { pct = parseFloat(lead[1]); colorStr = component.slice(lead[0].length).trim(); }
let color;
if (/^transparent$/i.test(colorStr)) color = { r: 0, g: 0, b: 0, a: 0 };
else color = parseAnyColor(colorStr);
if (!color) return null;
return { color, pct };
};
const c1 = parseComponent(args[1]);
const c2 = parseComponent(args[2]);
if (!c1 || !c2) return null;
let p1 = c1.pct, p2 = c2.pct;
if (p1 == null && p2 == null) { p1 = 50; p2 = 50; }
else if (p1 == null) p1 = 100 - p2;
else if (p2 == null) p2 = 100 - p1;
const sum = p1 + p2;
if (sum <= 0) return null;
// Per spec: weights normalize to sum; when sum < 100 the result alpha is
// additionally scaled by sum/100.
const w1 = p1 / sum, w2 = p2 / sum;
const alphaScale = sum < 100 ? sum / 100 : 1;
const a1 = c1.color.a ?? 1, a2 = c2.color.a ?? 1;
const a = (a1 * w1 + a2 * w2) * alphaScale;
if (a <= 0) return { r: 0, g: 0, b: 0, a: 0 };
const mix = (ch) => Math.round((c1.color[ch] * a1 * w1 + c2.color[ch] * a2 * w2) / (a1 * w1 + a2 * w2));
return { r: mix('r'), g: mix('g'), b: mix('b'), a: Math.min(1, a) };
}
// Composite a translucent color over an opaque(ish) base (simple
// source-over in sRGB). Returns an opaque {r,g,b,a:1}.
function compositeColorOver(top, base) {
const a = top.a ?? 1;
return {
r: Math.round(top.r * a + base.r * (1 - a)),
g: Math.round(top.g * a + base.g * (1 - a)),
b: Math.round(top.b * a + base.b * (1 - a)),
a: 1,
};
}
// Extended color parser: rgb/rgba/hex/oklch/oklab/hsl/hwb/color-mix/common
// named colors. Returns null on no match. Use this when the input might be
// any CSS color form; use plain parseRgb when you only expect computed rgb()
// values from real browsers.
function parseAnyColor(s) {
if (!s || typeof s !== 'string') return null;
const str = s.trim();
if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
if (/^color-mix\(/i.test(str)) return parseColorMix(str);
let m;
m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+))?\s*\)/);
if (m) return { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: m[4] !== undefined ? +m[4] : 1 };
m = str.match(/^#([0-9a-f]{3,8})$/i);
if (m) {
const h = m[1];
if (h.length === 3 || h.length === 4) {
return {
r: parseInt(h[0] + h[0], 16),
g: parseInt(h[1] + h[1], 16),
b: parseInt(h[2] + h[2], 16),
a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
};
}
if (h.length === 6 || h.length === 8) {
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
};
}
}
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
// Match L (with optional %), then C and H separated permissively.
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const Lnum = parseFloat(m[1]);
const L = m[2] === '%' ? Lnum / 100 : Lnum;
const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
if (m[5] !== undefined) {
const alpha = parseFloat(m[5]);
rgb.a = m[6] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// OKLAB — a/b are signed axes; percentages map 100% → 0.4.
m = str.match(/oklab\(\s*([\d.]+)(%?)\s+(-?[\d.]+)(%?)\s+(-?[\d.]+)(%?)(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const L = m[2] === '%' ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
const a = m[4] === '%' ? parseFloat(m[3]) * 0.004 : parseFloat(m[3]);
const b = m[6] === '%' ? parseFloat(m[5]) * 0.004 : parseFloat(m[5]);
const rgb = oklabToRgb(L, a, b);
if (m[7] !== undefined) {
const alpha = parseFloat(m[7]);
rgb.a = m[8] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// HSL/HSLA — comma or space syntax, optional deg on hue.
m = str.match(/hsla?\(\s*(-?[\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%\s*[,\s]\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hslToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// HWB — hue whiteness% blackness%.
m = str.match(/hwb\(\s*(-?[\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hwbToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
const named = CSS_NAMED_COLORS[str.toLowerCase()];
if (named) return { ...named, a: 1 };
return null;
}
// Resolve var() refs in a color string (via customPropMap), then parse.
// Returns null on any failure. Used in jsdom-mode paths where
@@ -2923,15 +2696,24 @@ function checkElementGlowDOM(el) {
if (!boxShadow && !textShadow) return [];
// Use parent's background — glow radiates outward, so the surrounding context matters
// If resolveBackground returns null (gradient), try to infer from the gradient colors
let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el);
if (!parentBg) {
const parentBgInfo = resolveBackgroundInfo(el.parentElement || el);
// Unknown surface (an unreadable layer on the way up): skip only the
// gradient hunt below, which would walk PAST that layer and score the
// glow against a background the visitor never sees. checkGlow still runs
// with a null surface: the zero-offset chromatic halo tell holds on ANY
// background, and the static loop already passes the unresolved walk's
// null color straight through (detect-html.mjs uses resolveBackground).
let parentBg = parentBgInfo.color;
if (!parentBg && !parentBgInfo.unresolved) {
// Gradient background — sample its colors to determine if it's dark.
// Modern-syntax parsing matters here: body-level gradients now reach this
// fallback in browser mode, and their stops usually serialize as oklch.
// fallback in browser mode, and their stops usually serialize as oklch
// which the shared parseGradientColors reads via its color-function
// token capture.
let cur = el.parentElement;
while (cur && cur.nodeType === 1) {
const bgImage = getComputedStyle(cur).backgroundImage || '';
const gradColors = parseGradientColorsModern(bgImage);
const gradColors = parseGradientColors(bgImage);
if (gradColors.length > 0) {
// Average the gradient colors
const avg = { r: 0, g: 0, b: 0 };
@@ -2975,10 +2757,13 @@ function checkElementAIPaletteDOM(el) {
const hue = getHue(textColor);
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
if (isAIPalette) {
const parentBg = el.parentElement ? resolveBackground(el.parentElement) : null;
// Also check gradient parents
let effectiveBg = parentBg;
if (!effectiveBg) {
const parentBgInfo = el.parentElement
? resolveBackgroundInfo(el.parentElement)
: { color: null, unresolved: false };
// Unknown surface: leave effectiveBg null (no finding) rather than
// hunting gradient ancestors past a layer we could not read.
let effectiveBg = parentBgInfo.color;
if (!effectiveBg && !parentBgInfo.unresolved) {
let cur = el.parentElement;
while (cur && cur.nodeType === 1) {
const gi = getComputedStyle(cur).backgroundImage || '';
@@ -3784,7 +3569,8 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
const effectiveBg = resolveBackground(el, window, customPropMap);
const bgInfo = resolveBackgroundInfo(el, window, customPropMap);
const effectiveBg = bgInfo.color;
// jsdom returns literal "var(--X)" / "oklch(...)" for color, so plain
// parseRgb misses Tailwind-tokenized text colors. Resolve through the
// customPropMap first; fall back to parseRgb for vanilla rgb() pages.
@@ -3830,11 +3616,13 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
// element itself has no usable own background, that pseudo is the real
// surface for contrast purposes.
let finalEffectiveBg = effectiveBg;
let surfaceUnresolved = bgInfo.unresolved;
if ((!ownBg || (ownBg.a ?? 1) <= 0.5) && typeof window.getPseudoSurface === 'function') {
const pseudoSurface = window.getPseudoSurface(el);
if (pseudoSurface) {
ownBg = pseudoSurface;
finalEffectiveBg = pseudoSurface;
surfaceUnresolved = false;
}
}
@@ -3842,8 +3630,9 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
tag,
textColor,
bgColor: ownBg,
effectiveBg: finalEffectiveBg,
effectiveBgStops: finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
// Unknown surface: hand the checks nothing rather than a guess.
effectiveBg: surfaceUnresolved ? null : finalEffectiveBg,
effectiveBgStops: surfaceUnresolved || finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
fontSize: parseFloat(style.fontSize) || 16,
fontWeight: parseInt(style.fontWeight) || 400,
hasDirectText,
@@ -5663,6 +5452,7 @@ export {
checkHtmlPatterns,
readOwnBackgroundColor,
resolveBackground,
resolveBackgroundInfo,
resolveGradientStops,
parseRadiusToPx,
resolveBorderRadiusPx,
@@ -71,11 +71,43 @@ function contrastRatio(c1, c2) {
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
// The CSS color functions worth pulling out of a longer declaration. The set
// is deliberately closed: `linear-gradient(` and `url(` also look like
// `name(` and must not be read as colors.
const COLOR_FUNCTION_NAMES = new Set([
'rgb', 'rgba', 'hsl', 'hsla', 'hwb', 'oklch', 'oklab', 'lch', 'lab', 'color', 'color-mix',
]);
// Pull every color-function token out of a value, with balanced-paren capture
// so nested forms (`color-mix(in oklab, oklch(...) 20%, transparent)`) survive
// whole. Returns the raw substrings in source order.
function extractColorFunctionTokens(value) {
const str = String(value || '');
const tokens = [];
const re = /([a-z][a-z-]*)\(/gi;
let m;
while ((m = re.exec(str)) !== null) {
if (!COLOR_FUNCTION_NAMES.has(m[1].toLowerCase())) continue;
let depth = 0, end = -1;
for (let i = m.index + m[0].length - 1; i < str.length; i++) {
if (str[i] === '(') depth++;
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
}
if (end < 0) break;
tokens.push(str.slice(m.index, end + 1));
re.lastIndex = end + 1;
}
return tokens;
}
function parseGradientColors(bgImage) {
if (!bgImage || !bgImage.includes('gradient')) return [];
const colors = [];
for (const m of bgImage.matchAll(/rgba?\([^)]+\)/g)) {
const c = parseRgb(m[0]);
// Stops arrive in whatever syntax the author wrote and the browser kept.
// A dark ground painted as `linear-gradient(oklch(...), oklch(...))` used
// to read as a gradient with no stops at all.
for (const token of extractColorFunctionTokens(bgImage)) {
const c = parseAnyColor(token);
if (c) colors.push(c);
}
for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
@@ -112,13 +144,445 @@ function colorToHex(c) {
return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
}
// ─── Color-space conversions ────────────────────────────────────────────────
//
// Every function here lands on 8-bit sRGB, clamped to gamut. Chrome, Safari,
// and Firefox all keep the authored color space in getComputedStyle output
// (`oklch(0.84 0.19 80.46)`, `lch(20 5 60)`, `color(srgb 1.04 0.72 -0.21)`),
// so a detector that only reads rgb() is blind on any modern palette. The
// expected outputs are pinned in tests/detect-antipatterns.test.js against
// what Chrome itself paints for the same strings.
function clamp01(x) {
return Number.isFinite(x) ? Math.max(0, Math.min(1, x)) : 0;
}
// Linear-light sRGB channel to the encoded 0-255 value.
function encodeSrgbChannel(x) {
const c = clamp01(x);
return Math.round((c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055) * 255);
}
function decodeSrgbChannel(x) {
const c = Number.isFinite(x) ? x : 0;
const sign = c < 0 ? -1 : 1;
const abs = Math.abs(c);
return sign * (abs <= 0.04045 ? abs / 12.92 : Math.pow((abs + 0.055) / 1.055, 2.4));
}
function linearSrgbToColor(r, g, b, a = 1) {
return { r: encodeSrgbChannel(r), g: encodeSrgbChannel(g), b: encodeSrgbChannel(b), a };
}
// OKLab to sRGB (Björn Ottosson's matrices). L in 0..1, a/b are signed axes.
function oklabToRgb(L, a, b) {
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
return linearSrgbToColor(
4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc,
-1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc,
-0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc,
);
}
// OKLCH to sRGB. L in 0..1, C in 0..~0.4 typical, H in degrees. Chroma past
// the sRGB gamut clamps per channel rather than producing NaN.
function oklchToRgb(L, C, H) {
const hRad = (H * Math.PI) / 180;
return oklabToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
}
// CIE Lab to sRGB. CSS lab()/lch() use the D50 white point; the matrix below
// is the Bradford-adapted XYZ-D50 to linear-sRGB transform from CSS Color 4.
function labToRgb(L, a, b) {
const kappa = 24389 / 27, epsilon = 216 / 24389;
const fy = (L + 16) / 116, fx = fy + a / 500, fz = fy - b / 200;
const invert = (t) => (t * t * t > epsilon ? t * t * t : (116 * t - 16) / kappa);
const yr = L > kappa * epsilon ? Math.pow((L + 16) / 116, 3) : L / kappa;
const Xn = 0.3457 / 0.3585, Zn = (1 - 0.3457 - 0.3585) / 0.3585;
const x = invert(fx) * Xn, y = yr, z = invert(fz) * Zn;
return linearSrgbToColor(
3.1341359569958707 * x - 1.6173863321612538 * y - 0.4906619460083532 * z,
-0.9787955029120890 * x + 1.9162545672595240 * y + 0.0334427311613195 * z,
0.0719553798841168 * x - 0.2289768264158322 * y + 1.4053860583241250 * z,
);
}
function lchToRgb(L, C, H) {
const hRad = (H * Math.PI) / 180;
return labToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
}
// color(<space> c1 c2 c3) for the spaces that turn up in real stylesheets.
// `srgb` is what Chrome serializes most color-mix() results into, routinely
// with channels outside 0..1. Spaces we do not model return null so callers
// abstain instead of measuring against a color we invented.
function colorFunctionToRgb(space, c1, c2, c3) {
switch (space) {
case 'srgb':
return { r: Math.round(clamp01(c1) * 255), g: Math.round(clamp01(c2) * 255), b: Math.round(clamp01(c3) * 255), a: 1 };
case 'srgb-linear':
return linearSrgbToColor(c1, c2, c3);
case 'display-p3': {
const [R, G, B] = [decodeSrgbChannel(c1), decodeSrgbChannel(c2), decodeSrgbChannel(c3)];
return linearSrgbToColor(
1.2249401762805587 * R - 0.2249404646817506 * G + 0.0000002884022551 * B,
-0.0420569547096138 * R + 1.0420571661298634 * G - 0.0000002113202247 * B,
-0.0196375587040044 * R - 0.0786360772174755 * G + 1.0982736359214800 * B,
);
}
default:
return null;
}
}
function hslToRgb(h, s, l) {
h = ((h % 360) + 360) % 360;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m0 = l - c / 2;
const [r, g, b] =
h < 60 ? [c, x, 0] :
h < 120 ? [x, c, 0] :
h < 180 ? [0, c, x] :
h < 240 ? [0, x, c] :
h < 300 ? [x, 0, c] : [c, 0, x];
return {
r: Math.round((r + m0) * 255),
g: Math.round((g + m0) * 255),
b: Math.round((b + m0) * 255),
a: 1,
};
}
function hwbToRgb(h, w, bl) {
if (w + bl >= 1) {
const g = Math.round((w / (w + bl)) * 255);
return { r: g, g, b: g, a: 1 };
}
const base = hslToRgb(h, 1, 0.5);
const mix = (c) => Math.round(((c / 255) * (1 - w - bl) + w) * 255);
return { r: mix(base.r), g: mix(base.g), b: mix(base.b), a: 1 };
}
// Common CSS named colors — the handful that actually show up in generated
// UIs, not the full 148-name spec list. Includes the achromatic names so a
// named gray parses (and correctly reads as no-chroma) instead of being
// treated as an unknown color.
const CSS_NAMED_COLORS = {
black: { r: 0, g: 0, b: 0 },
white: { r: 255, g: 255, b: 255 },
gray: { r: 128, g: 128, b: 128 },
grey: { r: 128, g: 128, b: 128 },
silver: { r: 192, g: 192, b: 192 },
dimgray: { r: 105, g: 105, b: 105 },
darkgray: { r: 169, g: 169, b: 169 },
lightgray: { r: 211, g: 211, b: 211 },
gainsboro: { r: 220, g: 220, b: 220 },
whitesmoke: { r: 245, g: 245, b: 245 },
red: { r: 255, g: 0, b: 0 },
crimson: { r: 220, g: 20, b: 60 },
tomato: { r: 255, g: 99, b: 71 },
coral: { r: 255, g: 127, b: 80 },
salmon: { r: 250, g: 128, b: 114 },
orange: { r: 255, g: 165, b: 0 },
gold: { r: 255, g: 215, b: 0 },
yellow: { r: 255, g: 255, b: 0 },
olive: { r: 128, g: 128, b: 0 },
lime: { r: 0, g: 255, b: 0 },
green: { r: 0, g: 128, b: 0 },
teal: { r: 0, g: 128, b: 128 },
turquoise: { r: 64, g: 224, b: 208 },
cyan: { r: 0, g: 255, b: 255 },
aqua: { r: 0, g: 255, b: 255 },
skyblue: { r: 135, g: 206, b: 235 },
dodgerblue: { r: 30, g: 144, b: 255 },
blue: { r: 0, g: 0, b: 255 },
navy: { r: 0, g: 0, b: 128 },
indigo: { r: 75, g: 0, b: 130 },
rebeccapurple: { r: 102, g: 51, b: 153 },
purple: { r: 128, g: 0, b: 128 },
violet: { r: 238, g: 130, b: 238 },
orchid: { r: 218, g: 112, b: 214 },
magenta: { r: 255, g: 0, b: 255 },
fuchsia: { r: 255, g: 0, b: 255 },
hotpink: { r: 255, g: 105, b: 180 },
pink: { r: 255, g: 192, b: 203 },
maroon: { r: 128, g: 0, b: 0 },
};
// Split a string on top-level commas (ignoring commas nested in parens).
function splitTopLevelCommas(str) {
const parts = [];
let depth = 0, start = 0;
for (let i = 0; i < str.length; i++) {
const ch = str[i];
if (ch === '(') depth++;
else if (ch === ')') depth = Math.max(0, depth - 1);
else if (ch === ',' && depth === 0) {
parts.push(str.slice(start, i).trim());
start = i + 1;
}
}
const tail = str.slice(start).trim();
if (tail) parts.push(tail);
return parts;
}
// Evaluate a CSS color-mix() expression to {r,g,b,a}. Returns null when
// the expression can't be resolved (unresolved var(), unknown colors).
//
// Mixing is done with premultiplied alpha in sRGB regardless of the
// declared interpolation space. That is exact for the dominant generated-UI
// pattern — `color-mix(in oklab, <color> N%, transparent)` — where the
// result is simply <color> at alpha N% in ANY rectangular space, and a
// close-enough approximation for opaque-opaque mixes (the detector only
// consumes these values for contrast/chroma thresholds, not for display).
function parseColorMix(str) {
const m = String(str).trim().match(/^color-mix\(/i);
if (!m) return null;
// Balanced-paren capture of the arguments.
let depth = 0, end = -1;
const open = str.indexOf('(');
for (let i = open; i < str.length; i++) {
if (str[i] === '(') depth++;
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
}
if (end < 0) return null;
const args = splitTopLevelCommas(str.slice(open + 1, end));
if (args.length !== 3 || !/^in\s/i.test(args[0])) return null;
const parseComponent = (component) => {
// Percentage may lead or trail the color per spec.
let pct = null;
let colorStr = component;
const trail = component.match(/\s+([\d.]+)%$/);
const lead = component.match(/^([\d.]+)%\s+/);
if (trail) { pct = parseFloat(trail[1]); colorStr = component.slice(0, trail.index).trim(); }
else if (lead) { pct = parseFloat(lead[1]); colorStr = component.slice(lead[0].length).trim(); }
let color;
if (/^transparent$/i.test(colorStr)) color = { r: 0, g: 0, b: 0, a: 0 };
else color = parseAnyColor(colorStr);
if (!color) return null;
return { color, pct };
};
const c1 = parseComponent(args[1]);
const c2 = parseComponent(args[2]);
if (!c1 || !c2) return null;
let p1 = c1.pct, p2 = c2.pct;
if (p1 == null && p2 == null) { p1 = 50; p2 = 50; }
else if (p1 == null) p1 = 100 - p2;
else if (p2 == null) p2 = 100 - p1;
const sum = p1 + p2;
if (sum <= 0) return null;
// Per spec: weights normalize to sum; when sum < 100 the result alpha is
// additionally scaled by sum/100.
const w1 = p1 / sum, w2 = p2 / sum;
const alphaScale = sum < 100 ? sum / 100 : 1;
const a1 = c1.color.a ?? 1, a2 = c2.color.a ?? 1;
const a = (a1 * w1 + a2 * w2) * alphaScale;
if (a <= 0) return { r: 0, g: 0, b: 0, a: 0 };
const mix = (ch) => Math.round((c1.color[ch] * a1 * w1 + c2.color[ch] * a2 * w2) / (a1 * w1 + a2 * w2));
return { r: mix('r'), g: mix('g'), b: mix('b'), a: Math.min(1, a) };
}
// Composite a translucent color over an opaque(ish) base (simple
// source-over in sRGB). Returns an opaque {r,g,b,a:1}.
function compositeColorOver(top, base) {
const a = top.a ?? 1;
return {
r: Math.round(top.r * a + base.r * (1 - a)),
g: Math.round(top.g * a + base.g * (1 - a)),
b: Math.round(top.b * a + base.b * (1 - a)),
a: 1,
};
}
// A color() / lab() / lch() component: a bare number, a percentage against
// `scale`, or the `none` keyword (which resolves to zero for our purposes).
function parseColorComponent(token, scale = 1) {
if (token == null) return null;
const t = String(token).trim();
if (/^none$/i.test(t)) return 0;
const num = parseFloat(t);
if (!Number.isFinite(num)) return null;
return t.endsWith('%') ? (num / 100) * scale : num;
}
function parseAlphaToken(token) {
if (token == null) return 1;
const t = String(token).trim();
if (/^none$/i.test(t)) return 1;
const num = parseFloat(t);
if (!Number.isFinite(num)) return 1;
return t.endsWith('%') ? num / 100 : num;
}
// Extended color parser: rgb/rgba/hex/oklch/oklab/lch/lab/hsl/hwb/color()/
// color-mix/common named colors. Returns null on no match. Use this when the
// input might be any CSS color form; use plain parseRgb when you only expect
// computed rgb() values from real browsers.
function parseAnyColor(s) {
if (!s || typeof s !== 'string') return null;
const str = s.trim();
if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
if (/^color-mix\(/i.test(str)) return parseColorMix(str);
let m;
m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/);
if (m) {
const c = { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: 1 };
if (m[4] !== undefined) c.a = m[5] === '%' ? parseFloat(m[4]) / 100 : +m[4];
return c;
}
m = str.match(/^#([0-9a-f]{3,8})$/i);
if (m) {
const h = m[1];
if (h.length === 3 || h.length === 4) {
return {
r: parseInt(h[0] + h[0], 16),
g: parseInt(h[1] + h[1], 16),
b: parseInt(h[2] + h[2], 16),
a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
};
}
if (h.length === 6 || h.length === 8) {
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
};
}
}
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
// Match L (with optional %), then C and H separated permissively.
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const Lnum = parseFloat(m[1]);
const L = m[2] === '%' ? Lnum / 100 : Lnum;
const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
if (m[5] !== undefined) {
const alpha = parseFloat(m[5]);
rgb.a = m[6] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// OKLAB — a/b are signed axes; percentages map 100% → 0.4.
m = str.match(/oklab\(\s*([\d.]+)(%?)\s+(-?[\d.]+)(%?)\s+(-?[\d.]+)(%?)(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const L = m[2] === '%' ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
const a = m[4] === '%' ? parseFloat(m[3]) * 0.004 : parseFloat(m[3]);
const b = m[6] === '%' ? parseFloat(m[5]) * 0.004 : parseFloat(m[5]);
const rgb = oklabToRgb(L, a, b);
if (m[7] !== undefined) {
const alpha = parseFloat(m[7]);
rgb.a = m[8] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// LCH / LAB — CIE, D50 white point. Chrome serializes lch(20% 5 60) as
// `lch(20 5 60)`, so L arrives with or without its percent sign. In both
// spaces L runs 0..100 and 100% means 100.
m = str.match(/^lch\(\s*([\d.]+%?|none)\s+([\d.]+%?|none)\s+(-?[\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
if (m) {
const L = parseColorComponent(m[1], 100);
const C = parseColorComponent(m[2], 150);
const H = parseFloat(m[3]);
if (L == null || C == null || !Number.isFinite(H)) return null;
const rgb = lchToRgb(L, C, H);
rgb.a = parseAlphaToken(m[4]);
return rgb;
}
m = str.match(/^lab\(\s*([\d.]+%?|none)\s+(-?[\d.]+%?|none)\s+(-?[\d.]+%?|none)(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
if (m) {
const L = parseColorComponent(m[1], 100);
const a = parseColorComponent(m[2], 125);
const b = parseColorComponent(m[3], 125);
if (L == null || a == null || b == null) return null;
const rgb = labToRgb(L, a, b);
rgb.a = parseAlphaToken(m[4]);
return rgb;
}
// color(<space> c1 c2 c3 [/ alpha]) — what Chrome hands back for most
// color-mix() results and for any wide-gamut color an author wrote.
m = str.match(/^color\(\s*([a-z0-9-]+)\s+(-?[\d.eE+-]+%?|none)\s+(-?[\d.eE+-]+%?|none)\s+(-?[\d.eE+-]+%?|none)(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
if (m) {
const c1 = parseColorComponent(m[2]);
const c2 = parseColorComponent(m[3]);
const c3 = parseColorComponent(m[4]);
if (c1 == null || c2 == null || c3 == null) return null;
const rgb = colorFunctionToRgb(m[1].toLowerCase(), c1, c2, c3);
if (!rgb) return null;
rgb.a = parseAlphaToken(m[5]);
return rgb;
}
// HSL/HSLA — comma or space syntax, optional deg on hue.
m = str.match(/hsla?\(\s*(-?[\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%\s*[,\s]\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hslToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// HWB — hue whiteness% blackness%.
m = str.match(/hwb\(\s*(-?[\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hwbToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
const named = CSS_NAMED_COLORS[str.toLowerCase()];
if (named) return { ...named, a: 1 };
return null;
}
// True when a computed background-color string names no paint at all. Used to
// tell "this layer is see-through" (walk on to the ancestor) apart from "this
// layer has a color we could not read" (stop and abstain).
//
// `inherit` belongs here even though it is not literally see-through: it means
// "paint with the parent's background-color", and walking on to the parent IS
// that resolution. Real browsers resolve the keyword before getComputedStyle
// output; only jsdom's partial cascade hands it through verbatim, and treating
// it as unreadable would make the walk abstain on a surface it can know.
// (`currentcolor` is NOT here — it is real paint in the element's own text
// color; resolveBackgroundInfo substitutes the computed color for it.)
function isNoPaintColorValue(value) {
const v = String(value || '').trim().toLowerCase();
if (!v) return true;
return v === 'transparent' || v === 'none' || v === 'initial' || v === 'inherit' || v === 'unset' || v === 'revert' || v === 'revert-layer';
}
export {
isNeutralColor,
parseRgb,
relativeLuminance,
contrastRatio,
parseGradientColors,
extractColorFunctionTokens,
hasChroma,
getHue,
colorToHex,
oklabToRgb,
oklchToRgb,
labToRgb,
lchToRgb,
colorFunctionToRgb,
hslToRgb,
hwbToRgb,
CSS_NAMED_COLORS,
splitTopLevelCommas,
parseColorMix,
parseAnyColor,
compositeColorOver,
isNoPaintColorValue,
};
File diff suppressed because it is too large Load Diff
@@ -11,14 +11,21 @@ import {
isBrandFontOnOwnDomain,
} from '../shared/constants.mjs';
import {
CSS_NAMED_COLORS,
colorToHex,
compositeColorOver,
contrastRatio,
getHue,
hasChroma,
isNeutralColor,
isNoPaintColorValue,
oklchToRgb,
parseAnyColor,
parseColorMix,
parseGradientColors,
parseRgb,
relativeLuminance,
splitTopLevelCommas,
} from '../shared/color.mjs';
import { extractGoogleFontFamilies } from '../shared/fonts.mjs';
@@ -1731,7 +1738,7 @@ function readOwnBackgroundColor(el, computedStyle) {
// One element's background-color as the cascade walk sees it: computed style
// first (with the modern-color fallback), then, in static mode only,
// custom-prop resolution and the inline-shorthand peek. Shared by
// resolveBackground and resolveGradientStops so both walks read the same
// resolveBackgroundInfo and resolveGradientStops so both walks read the same
// surfaces.
function readCascadeBackgroundColor(current, style, customPropMap) {
let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
@@ -1755,7 +1762,19 @@ function readCascadeBackgroundColor(current, style, customPropMap) {
return bg;
}
function resolveBackground(el, win, customPropMap) {
// Walk up for the surface the element's text is painted on.
//
// Returns { color, unresolved }:
// • color set — the effective surface, overlays composited in.
// • unresolved: true — a layer on the way up paints a color this parser
// cannot read, so the surface is unknown. Callers
// must SKIP their contrast checks. Guessing white
// here is what flooded dark themes with false
// "on #ffffff" findings: one abstention costs a
// single finding, one wrong guess costs a hundred.
// • both null/false — no solid color, but a gradient or image is in
// play; callers fall back to its color stops.
function resolveBackgroundInfo(el, win, customPropMap) {
let current = el;
// Translucent layers (0.1 < a < 1) found on the way down to an opaque
// base. A browser composites these over the base; the old behavior
@@ -1783,59 +1802,81 @@ function resolveBackground(el, win, customPropMap) {
// body backgrounds.
// Real browsers serialize wide-gamut computed values as oklab()/oklch()
// (e.g. any color-mix() result), which plain parseRgb misses.
const bg = readCascadeBackgroundColor(current, style, customPropMap);
let bg = readCascadeBackgroundColor(current, style, customPropMap);
// `background-color: currentcolor` paints with the element's own text
// color — real paint whose value we know. Real browsers resolve the
// keyword before getComputedStyle output; jsdom hands it through
// verbatim, and without this substitution the layer would read as
// unparseable and force a needless abstention.
if ((!bg || bg.a < 0.1) && /^currentcolor$/i.test(String(style.backgroundColor || '').trim())) {
// The static cascade resolves var() text tokens before checks run, so
// style.color is normally already an rgb string here; parseColorResolved
// is defense in depth for any future caller that passes a live
// customPropMap (it matches the text-color path in checkElementColors
// and reduces to parseAnyColor when the map is null or absent).
bg = parseRgb(style.color) || parseColorResolved(style.color, customPropMap);
}
if (bg && bg.a > 0.1) {
if (bg.a >= 0.99) return flatten(bg);
if (bg.a >= 0.99) return { color: flatten(bg), unresolved: false };
overlays.push(bg);
} else if (!bg && !isNoPaintColorValue(style.backgroundColor)) {
// This layer names a color we could not parse (a color space we do not
// model, an unresolved var(), a syntax newer than the parser). It may
// well be opaque, which would make every ancestor below it invisible —
// so the surface is unknown and the walk stops here rather than
// reporting an ancestor the visitor never sees.
return { color: null, unresolved: true };
}
// No solid bg-color at this level, but this level paints an image. CSS
// stacks background-image layers first-on-top, so which layer leads
// decides what the visitor sees:
// • gradient on top — the gradient is the surface. Hand the caller a
// null color so it falls back to the gradient's own stops (body
// grounds, gradient buttons, hero sections).
// • url() on top — the surface is an image whose pixels this engine
// cannot read, and it may fully cover every layer and ancestor
// beneath it. Same contract as an unparseable color: abstain, so
// the gradient-stop fallback never measures a gradient the image
// hides (the shipped miss: `url(photo), linear-gradient(...)`
// reported low-contrast against the invisible gradient's stops).
if (hasGradientOrUrl) {
const layers = splitTopLevelCommas(bgImage);
const topPaintLayer = layers.find(
(layer) => /gradient\s*\(/i.test(layer) || /url\s*\(/i.test(layer),
);
const gradientOnTop = !!topPaintLayer
&& /gradient\s*\(/i.test(topPaintLayer)
&& !/^\s*url\s*\(/i.test(topPaintLayer);
if (!gradientOnTop) return { color: null, unresolved: true };
// Gradient on top of a url() layer: the image shows through wherever
// the gradient is not fully opaque, so a translucent wash like
// `linear-gradient(rgba(0,0,0,.2), rgba(0,0,0,.2)), url(photo)` paints
// a blend with pixels this engine cannot read. Only a gradient whose
// every readable stop is opaque provably covers the image; otherwise
// the surface is unknown — abstain rather than hand callers gradient
// stops (or a stop average) the visitor never sees unmixed.
const urlBeneath = layers.some(
(layer) => layer !== topPaintLayer && /url\s*\(/i.test(layer),
);
if (urlBeneath) {
const topStops = parseGradientColors(topPaintLayer);
const provablyOpaque = topStops.length > 0 && topStops.every((s) => (s.a ?? 1) >= 0.99);
if (!provablyOpaque) return { color: null, unresolved: true };
}
return { color: null, unresolved: false };
}
// No solid bg-color at this level. If THIS level has a gradient/url
// with no underlying solid color we can read:
// • on body/html: assume white. Body-level gradients are almost
// always decorative texture (paper grain, noise) on top of a
// solid bg-color the page set via `background: var(--paper)`
// shorthand — which jsdom can't decompose into bg-color. The
// downstream gradient-stops fallback path produces catastrophic
// false positives in this case (gradient noise stops have
// accidental browns/blacks that look like card backgrounds).
// • on other elements: bail to null and let the caller fall back
// to gradient stops (gradient buttons / hero sections are real
// bgs worth checking against).
// A gradient or image with no solid color under it, at any level
// including body/html, means the visible ground is that layer itself.
// Return null so the caller measures against the actual gradient stops,
// or skips when nothing is parseable — skipping beats a wrong ratio.
//
// Body/html used to assume white here, a guard written for jsdom, which
// never decomposed the `background:` shorthand and so could not see the
// solid paper color a texture gradient usually sits on. It turned every
// light-on-dark page into a wall of low-contrast false positives (a dark
// oklch body gradient produced ~120 "on #ffffff" findings on one site).
// Both engines can see shorthand solids now — the browser natively, the
// static cascade via expandStaticDeclaration + var() resolution — so a
// missing solid is real, and the old failure case cannot recur: opaque
// stops fully cover any hidden solid (they ARE the ground), alpha stops
// composite over the resolved base or the white canvas default, and
// unresolvable stops drop rather than guess.
if (hasGradientOrUrl) return null;
current = current.parentElement;
}
return flatten({ r: 255, g: 255, b: 255, a: 1 });
// Every layer up to the document root was genuinely see-through, so the
// browser paints its default canvas. This is the ONLY case that earns the
// white assumption.
return { color: flatten({ r: 255, g: 255, b: 255, a: 1 }), unresolved: false };
}
// parseGradientColors (shared) reads only the legacy serializations: rgb()
// and hex stops. Browsers keep modern-space stops in computed backgroundImage
// exactly as authored — `linear-gradient(oklch(7% 0.006 95), …)` stays oklch —
// which is what every token-driven page produces. Route those through
// parseAnyColor so a gradient ground is measurable rather than invisible.
function parseGradientColorsModern(bgImage) {
if (!bgImage || !/gradient/i.test(bgImage)) return [];
const colors = parseGradientColors(bgImage);
for (const m of bgImage.matchAll(/(?:oklch|oklab|hsla?|hwb)\(\s*[^()]*\)/gi)) {
const c = parseAnyColor(m[0]);
if (c) colors.push(c);
}
return colors;
function resolveBackground(el, win, customPropMap) {
return resolveBackgroundInfo(el, win, customPropMap).color;
}
// Walk parents looking for a gradient background and return its color stops.
@@ -1861,7 +1902,10 @@ function resolveGradientStops(el, win, customPropMap) {
if (bgImage && bgImage !== 'none' && /url\s*\(/i.test(bgImage)) return null;
let stops = null;
if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
const parsed = parseGradientColorsModern(bgImage);
// parseGradientColors (shared) reads modern-space stops too — oklch,
// color-mix and friends via balanced-paren token capture — so browser
// computed values that keep the authored syntax stay measurable.
const parsed = parseGradientColors(bgImage);
if (parsed.length > 0) stops = parsed;
}
if (!stops && !DETECTOR_IS_BROWSER) {
@@ -1869,7 +1913,7 @@ function resolveGradientStops(el, win, customPropMap) {
const rawStyle = current.getAttribute?.('style') || '';
const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
if (bgMatch && /gradient/i.test(bgMatch[1])) {
const parsed = parseGradientColorsModern(bgMatch[1]);
const parsed = parseGradientColors(bgMatch[1]);
if (parsed.length > 0) stops = parsed;
}
}
@@ -2115,13 +2159,19 @@ function checkElementColorsDOM(el) {
if (style.visibility === 'hidden' || effectiveOpacityDOM(el) <= 0.02) return [];
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
let effectiveBg = resolveBackground(el);
const bgInfo = resolveBackgroundInfo(el);
let effectiveBg = bgInfo.color;
// An unreadable surface anywhere up the chain: skip the gradient-stop
// fallback too, so nothing downstream measures against a ground we never
// resolved.
let surfaceUnresolved = bgInfo.unresolved;
let ownBg = readOwnBackgroundColor(el, style);
if (!ownBg || (ownBg.a ?? 1) <= 0.5) {
const pseudoSurface = readPseudoSurfaceDOM(el, rect);
if (pseudoSurface) {
ownBg = pseudoSurface;
effectiveBg = pseudoSurface;
surfaceUnresolved = false;
}
}
return checkColors({
@@ -2133,8 +2183,8 @@ function checkElementColorsDOM(el) {
// an oklch token near its own oklch background).
textColor: parseRgb(style.color) || parseAnyColor(style.color),
bgColor: ownBg,
effectiveBg,
effectiveBgStops: effectiveBg ? null : resolveGradientStops(el),
effectiveBg: surfaceUnresolved ? null : effectiveBg,
effectiveBgStops: surfaceUnresolved || effectiveBg ? null : resolveGradientStops(el),
fontSize: parseFloat(style.fontSize) || 16,
fontWeight: parseInt(style.fontWeight) || 400,
hasDirectText,
@@ -2284,283 +2334,6 @@ function resolveVarRefs(raw, customPropMap, depth = 0) {
});
}
// OKLCH → sRGB conversion (Björn Ottosson's matrices). L in 0..1 (or %),
// C in 0..~0.4 typical, H in degrees. Returns clamped {r,g,b,a:1} in 0..255.
// Needed because jsdom doesn't compute oklch() values — getComputedStyle
// returns the literal "oklch(...)" string. Without this, the entire
// Tailwind v4 color palette (which is OKLCH-based) is invisible to the
// detector's contrast / color checks.
function oklchToRgb(L, C, H) {
const hRad = (H * Math.PI) / 180;
return oklabToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
}
function oklabToRgb(L, a, b) {
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
const rLin = 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc;
const gLin = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc;
const bLin = -0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc;
const enc = (x) => {
const c = Math.max(0, Math.min(1, x));
return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
};
return {
r: Math.round(enc(rLin) * 255),
g: Math.round(enc(gLin) * 255),
b: Math.round(enc(bLin) * 255),
a: 1,
};
}
function hslToRgb(h, s, l) {
h = ((h % 360) + 360) % 360;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m0 = l - c / 2;
const [r, g, b] =
h < 60 ? [c, x, 0] :
h < 120 ? [x, c, 0] :
h < 180 ? [0, c, x] :
h < 240 ? [0, x, c] :
h < 300 ? [x, 0, c] : [c, 0, x];
return {
r: Math.round((r + m0) * 255),
g: Math.round((g + m0) * 255),
b: Math.round((b + m0) * 255),
a: 1,
};
}
function hwbToRgb(h, w, bl) {
if (w + bl >= 1) {
const g = Math.round((w / (w + bl)) * 255);
return { r: g, g, b: g, a: 1 };
}
const base = hslToRgb(h, 1, 0.5);
const mix = (c) => Math.round(((c / 255) * (1 - w - bl) + w) * 255);
return { r: mix(base.r), g: mix(base.g), b: mix(base.b), a: 1 };
}
// Common CSS named colors — the handful that actually show up in generated
// UIs, not the full 148-name spec list. Includes the achromatic names so a
// named gray parses (and correctly reads as no-chroma) instead of being
// treated as an unknown color.
const CSS_NAMED_COLORS = {
black: { r: 0, g: 0, b: 0 },
white: { r: 255, g: 255, b: 255 },
gray: { r: 128, g: 128, b: 128 },
grey: { r: 128, g: 128, b: 128 },
silver: { r: 192, g: 192, b: 192 },
dimgray: { r: 105, g: 105, b: 105 },
darkgray: { r: 169, g: 169, b: 169 },
lightgray: { r: 211, g: 211, b: 211 },
gainsboro: { r: 220, g: 220, b: 220 },
whitesmoke: { r: 245, g: 245, b: 245 },
red: { r: 255, g: 0, b: 0 },
crimson: { r: 220, g: 20, b: 60 },
tomato: { r: 255, g: 99, b: 71 },
coral: { r: 255, g: 127, b: 80 },
salmon: { r: 250, g: 128, b: 114 },
orange: { r: 255, g: 165, b: 0 },
gold: { r: 255, g: 215, b: 0 },
yellow: { r: 255, g: 255, b: 0 },
olive: { r: 128, g: 128, b: 0 },
lime: { r: 0, g: 255, b: 0 },
green: { r: 0, g: 128, b: 0 },
teal: { r: 0, g: 128, b: 128 },
turquoise: { r: 64, g: 224, b: 208 },
cyan: { r: 0, g: 255, b: 255 },
aqua: { r: 0, g: 255, b: 255 },
skyblue: { r: 135, g: 206, b: 235 },
dodgerblue: { r: 30, g: 144, b: 255 },
blue: { r: 0, g: 0, b: 255 },
navy: { r: 0, g: 0, b: 128 },
indigo: { r: 75, g: 0, b: 130 },
rebeccapurple: { r: 102, g: 51, b: 153 },
purple: { r: 128, g: 0, b: 128 },
violet: { r: 238, g: 130, b: 238 },
orchid: { r: 218, g: 112, b: 214 },
magenta: { r: 255, g: 0, b: 255 },
fuchsia: { r: 255, g: 0, b: 255 },
hotpink: { r: 255, g: 105, b: 180 },
pink: { r: 255, g: 192, b: 203 },
maroon: { r: 128, g: 0, b: 0 },
};
// Split a string on top-level commas (ignoring commas nested in parens).
function splitTopLevelCommas(str) {
const parts = [];
let depth = 0, start = 0;
for (let i = 0; i < str.length; i++) {
const ch = str[i];
if (ch === '(') depth++;
else if (ch === ')') depth = Math.max(0, depth - 1);
else if (ch === ',' && depth === 0) {
parts.push(str.slice(start, i).trim());
start = i + 1;
}
}
const tail = str.slice(start).trim();
if (tail) parts.push(tail);
return parts;
}
// Evaluate a CSS color-mix() expression to {r,g,b,a}. Returns null when
// the expression can't be resolved (unresolved var(), unknown colors).
//
// Mixing is done with premultiplied alpha in sRGB regardless of the
// declared interpolation space. That is exact for the dominant generated-UI
// pattern — `color-mix(in oklab, <color> N%, transparent)` — where the
// result is simply <color> at alpha N% in ANY rectangular space, and a
// close-enough approximation for opaque-opaque mixes (the detector only
// consumes these values for contrast/chroma thresholds, not for display).
function parseColorMix(str) {
const m = String(str).trim().match(/^color-mix\(/i);
if (!m) return null;
// Balanced-paren capture of the arguments.
let depth = 0, end = -1;
const open = str.indexOf('(');
for (let i = open; i < str.length; i++) {
if (str[i] === '(') depth++;
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
}
if (end < 0) return null;
const args = splitTopLevelCommas(str.slice(open + 1, end));
if (args.length !== 3 || !/^in\s/i.test(args[0])) return null;
const parseComponent = (component) => {
// Percentage may lead or trail the color per spec.
let pct = null;
let colorStr = component;
const trail = component.match(/\s+([\d.]+)%$/);
const lead = component.match(/^([\d.]+)%\s+/);
if (trail) { pct = parseFloat(trail[1]); colorStr = component.slice(0, trail.index).trim(); }
else if (lead) { pct = parseFloat(lead[1]); colorStr = component.slice(lead[0].length).trim(); }
let color;
if (/^transparent$/i.test(colorStr)) color = { r: 0, g: 0, b: 0, a: 0 };
else color = parseAnyColor(colorStr);
if (!color) return null;
return { color, pct };
};
const c1 = parseComponent(args[1]);
const c2 = parseComponent(args[2]);
if (!c1 || !c2) return null;
let p1 = c1.pct, p2 = c2.pct;
if (p1 == null && p2 == null) { p1 = 50; p2 = 50; }
else if (p1 == null) p1 = 100 - p2;
else if (p2 == null) p2 = 100 - p1;
const sum = p1 + p2;
if (sum <= 0) return null;
// Per spec: weights normalize to sum; when sum < 100 the result alpha is
// additionally scaled by sum/100.
const w1 = p1 / sum, w2 = p2 / sum;
const alphaScale = sum < 100 ? sum / 100 : 1;
const a1 = c1.color.a ?? 1, a2 = c2.color.a ?? 1;
const a = (a1 * w1 + a2 * w2) * alphaScale;
if (a <= 0) return { r: 0, g: 0, b: 0, a: 0 };
const mix = (ch) => Math.round((c1.color[ch] * a1 * w1 + c2.color[ch] * a2 * w2) / (a1 * w1 + a2 * w2));
return { r: mix('r'), g: mix('g'), b: mix('b'), a: Math.min(1, a) };
}
// Composite a translucent color over an opaque(ish) base (simple
// source-over in sRGB). Returns an opaque {r,g,b,a:1}.
function compositeColorOver(top, base) {
const a = top.a ?? 1;
return {
r: Math.round(top.r * a + base.r * (1 - a)),
g: Math.round(top.g * a + base.g * (1 - a)),
b: Math.round(top.b * a + base.b * (1 - a)),
a: 1,
};
}
// Extended color parser: rgb/rgba/hex/oklch/oklab/hsl/hwb/color-mix/common
// named colors. Returns null on no match. Use this when the input might be
// any CSS color form; use plain parseRgb when you only expect computed rgb()
// values from real browsers.
function parseAnyColor(s) {
if (!s || typeof s !== 'string') return null;
const str = s.trim();
if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
if (/^color-mix\(/i.test(str)) return parseColorMix(str);
let m;
m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+))?\s*\)/);
if (m) return { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: m[4] !== undefined ? +m[4] : 1 };
m = str.match(/^#([0-9a-f]{3,8})$/i);
if (m) {
const h = m[1];
if (h.length === 3 || h.length === 4) {
return {
r: parseInt(h[0] + h[0], 16),
g: parseInt(h[1] + h[1], 16),
b: parseInt(h[2] + h[2], 16),
a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
};
}
if (h.length === 6 || h.length === 8) {
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
};
}
}
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
// Match L (with optional %), then C and H separated permissively.
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const Lnum = parseFloat(m[1]);
const L = m[2] === '%' ? Lnum / 100 : Lnum;
const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
if (m[5] !== undefined) {
const alpha = parseFloat(m[5]);
rgb.a = m[6] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// OKLAB — a/b are signed axes; percentages map 100% → 0.4.
m = str.match(/oklab\(\s*([\d.]+)(%?)\s+(-?[\d.]+)(%?)\s+(-?[\d.]+)(%?)(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const L = m[2] === '%' ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
const a = m[4] === '%' ? parseFloat(m[3]) * 0.004 : parseFloat(m[3]);
const b = m[6] === '%' ? parseFloat(m[5]) * 0.004 : parseFloat(m[5]);
const rgb = oklabToRgb(L, a, b);
if (m[7] !== undefined) {
const alpha = parseFloat(m[7]);
rgb.a = m[8] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// HSL/HSLA — comma or space syntax, optional deg on hue.
m = str.match(/hsla?\(\s*(-?[\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%\s*[,\s]\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hslToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// HWB — hue whiteness% blackness%.
m = str.match(/hwb\(\s*(-?[\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hwbToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
const named = CSS_NAMED_COLORS[str.toLowerCase()];
if (named) return { ...named, a: 1 };
return null;
}
// Resolve var() refs in a color string (via customPropMap), then parse.
// Returns null on any failure. Used in jsdom-mode paths where
@@ -2923,15 +2696,24 @@ function checkElementGlowDOM(el) {
if (!boxShadow && !textShadow) return [];
// Use parent's background — glow radiates outward, so the surrounding context matters
// If resolveBackground returns null (gradient), try to infer from the gradient colors
let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el);
if (!parentBg) {
const parentBgInfo = resolveBackgroundInfo(el.parentElement || el);
// Unknown surface (an unreadable layer on the way up): skip only the
// gradient hunt below, which would walk PAST that layer and score the
// glow against a background the visitor never sees. checkGlow still runs
// with a null surface: the zero-offset chromatic halo tell holds on ANY
// background, and the static loop already passes the unresolved walk's
// null color straight through (detect-html.mjs uses resolveBackground).
let parentBg = parentBgInfo.color;
if (!parentBg && !parentBgInfo.unresolved) {
// Gradient background — sample its colors to determine if it's dark.
// Modern-syntax parsing matters here: body-level gradients now reach this
// fallback in browser mode, and their stops usually serialize as oklch.
// fallback in browser mode, and their stops usually serialize as oklch
// which the shared parseGradientColors reads via its color-function
// token capture.
let cur = el.parentElement;
while (cur && cur.nodeType === 1) {
const bgImage = getComputedStyle(cur).backgroundImage || '';
const gradColors = parseGradientColorsModern(bgImage);
const gradColors = parseGradientColors(bgImage);
if (gradColors.length > 0) {
// Average the gradient colors
const avg = { r: 0, g: 0, b: 0 };
@@ -2975,10 +2757,13 @@ function checkElementAIPaletteDOM(el) {
const hue = getHue(textColor);
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
if (isAIPalette) {
const parentBg = el.parentElement ? resolveBackground(el.parentElement) : null;
// Also check gradient parents
let effectiveBg = parentBg;
if (!effectiveBg) {
const parentBgInfo = el.parentElement
? resolveBackgroundInfo(el.parentElement)
: { color: null, unresolved: false };
// Unknown surface: leave effectiveBg null (no finding) rather than
// hunting gradient ancestors past a layer we could not read.
let effectiveBg = parentBgInfo.color;
if (!effectiveBg && !parentBgInfo.unresolved) {
let cur = el.parentElement;
while (cur && cur.nodeType === 1) {
const gi = getComputedStyle(cur).backgroundImage || '';
@@ -3784,7 +3569,8 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
const effectiveBg = resolveBackground(el, window, customPropMap);
const bgInfo = resolveBackgroundInfo(el, window, customPropMap);
const effectiveBg = bgInfo.color;
// jsdom returns literal "var(--X)" / "oklch(...)" for color, so plain
// parseRgb misses Tailwind-tokenized text colors. Resolve through the
// customPropMap first; fall back to parseRgb for vanilla rgb() pages.
@@ -3830,11 +3616,13 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
// element itself has no usable own background, that pseudo is the real
// surface for contrast purposes.
let finalEffectiveBg = effectiveBg;
let surfaceUnresolved = bgInfo.unresolved;
if ((!ownBg || (ownBg.a ?? 1) <= 0.5) && typeof window.getPseudoSurface === 'function') {
const pseudoSurface = window.getPseudoSurface(el);
if (pseudoSurface) {
ownBg = pseudoSurface;
finalEffectiveBg = pseudoSurface;
surfaceUnresolved = false;
}
}
@@ -3842,8 +3630,9 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
tag,
textColor,
bgColor: ownBg,
effectiveBg: finalEffectiveBg,
effectiveBgStops: finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
// Unknown surface: hand the checks nothing rather than a guess.
effectiveBg: surfaceUnresolved ? null : finalEffectiveBg,
effectiveBgStops: surfaceUnresolved || finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
fontSize: parseFloat(style.fontSize) || 16,
fontWeight: parseInt(style.fontWeight) || 400,
hasDirectText,
@@ -5663,6 +5452,7 @@ export {
checkHtmlPatterns,
readOwnBackgroundColor,
resolveBackground,
resolveBackgroundInfo,
resolveGradientStops,
parseRadiusToPx,
resolveBorderRadiusPx,
@@ -71,11 +71,43 @@ function contrastRatio(c1, c2) {
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
// The CSS color functions worth pulling out of a longer declaration. The set
// is deliberately closed: `linear-gradient(` and `url(` also look like
// `name(` and must not be read as colors.
const COLOR_FUNCTION_NAMES = new Set([
'rgb', 'rgba', 'hsl', 'hsla', 'hwb', 'oklch', 'oklab', 'lch', 'lab', 'color', 'color-mix',
]);
// Pull every color-function token out of a value, with balanced-paren capture
// so nested forms (`color-mix(in oklab, oklch(...) 20%, transparent)`) survive
// whole. Returns the raw substrings in source order.
function extractColorFunctionTokens(value) {
const str = String(value || '');
const tokens = [];
const re = /([a-z][a-z-]*)\(/gi;
let m;
while ((m = re.exec(str)) !== null) {
if (!COLOR_FUNCTION_NAMES.has(m[1].toLowerCase())) continue;
let depth = 0, end = -1;
for (let i = m.index + m[0].length - 1; i < str.length; i++) {
if (str[i] === '(') depth++;
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
}
if (end < 0) break;
tokens.push(str.slice(m.index, end + 1));
re.lastIndex = end + 1;
}
return tokens;
}
function parseGradientColors(bgImage) {
if (!bgImage || !bgImage.includes('gradient')) return [];
const colors = [];
for (const m of bgImage.matchAll(/rgba?\([^)]+\)/g)) {
const c = parseRgb(m[0]);
// Stops arrive in whatever syntax the author wrote and the browser kept.
// A dark ground painted as `linear-gradient(oklch(...), oklch(...))` used
// to read as a gradient with no stops at all.
for (const token of extractColorFunctionTokens(bgImage)) {
const c = parseAnyColor(token);
if (c) colors.push(c);
}
for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
@@ -112,13 +144,445 @@ function colorToHex(c) {
return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
}
// ─── Color-space conversions ────────────────────────────────────────────────
//
// Every function here lands on 8-bit sRGB, clamped to gamut. Chrome, Safari,
// and Firefox all keep the authored color space in getComputedStyle output
// (`oklch(0.84 0.19 80.46)`, `lch(20 5 60)`, `color(srgb 1.04 0.72 -0.21)`),
// so a detector that only reads rgb() is blind on any modern palette. The
// expected outputs are pinned in tests/detect-antipatterns.test.js against
// what Chrome itself paints for the same strings.
function clamp01(x) {
return Number.isFinite(x) ? Math.max(0, Math.min(1, x)) : 0;
}
// Linear-light sRGB channel to the encoded 0-255 value.
function encodeSrgbChannel(x) {
const c = clamp01(x);
return Math.round((c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055) * 255);
}
function decodeSrgbChannel(x) {
const c = Number.isFinite(x) ? x : 0;
const sign = c < 0 ? -1 : 1;
const abs = Math.abs(c);
return sign * (abs <= 0.04045 ? abs / 12.92 : Math.pow((abs + 0.055) / 1.055, 2.4));
}
function linearSrgbToColor(r, g, b, a = 1) {
return { r: encodeSrgbChannel(r), g: encodeSrgbChannel(g), b: encodeSrgbChannel(b), a };
}
// OKLab to sRGB (Björn Ottosson's matrices). L in 0..1, a/b are signed axes.
function oklabToRgb(L, a, b) {
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
return linearSrgbToColor(
4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc,
-1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc,
-0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc,
);
}
// OKLCH to sRGB. L in 0..1, C in 0..~0.4 typical, H in degrees. Chroma past
// the sRGB gamut clamps per channel rather than producing NaN.
function oklchToRgb(L, C, H) {
const hRad = (H * Math.PI) / 180;
return oklabToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
}
// CIE Lab to sRGB. CSS lab()/lch() use the D50 white point; the matrix below
// is the Bradford-adapted XYZ-D50 to linear-sRGB transform from CSS Color 4.
function labToRgb(L, a, b) {
const kappa = 24389 / 27, epsilon = 216 / 24389;
const fy = (L + 16) / 116, fx = fy + a / 500, fz = fy - b / 200;
const invert = (t) => (t * t * t > epsilon ? t * t * t : (116 * t - 16) / kappa);
const yr = L > kappa * epsilon ? Math.pow((L + 16) / 116, 3) : L / kappa;
const Xn = 0.3457 / 0.3585, Zn = (1 - 0.3457 - 0.3585) / 0.3585;
const x = invert(fx) * Xn, y = yr, z = invert(fz) * Zn;
return linearSrgbToColor(
3.1341359569958707 * x - 1.6173863321612538 * y - 0.4906619460083532 * z,
-0.9787955029120890 * x + 1.9162545672595240 * y + 0.0334427311613195 * z,
0.0719553798841168 * x - 0.2289768264158322 * y + 1.4053860583241250 * z,
);
}
function lchToRgb(L, C, H) {
const hRad = (H * Math.PI) / 180;
return labToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
}
// color(<space> c1 c2 c3) for the spaces that turn up in real stylesheets.
// `srgb` is what Chrome serializes most color-mix() results into, routinely
// with channels outside 0..1. Spaces we do not model return null so callers
// abstain instead of measuring against a color we invented.
function colorFunctionToRgb(space, c1, c2, c3) {
switch (space) {
case 'srgb':
return { r: Math.round(clamp01(c1) * 255), g: Math.round(clamp01(c2) * 255), b: Math.round(clamp01(c3) * 255), a: 1 };
case 'srgb-linear':
return linearSrgbToColor(c1, c2, c3);
case 'display-p3': {
const [R, G, B] = [decodeSrgbChannel(c1), decodeSrgbChannel(c2), decodeSrgbChannel(c3)];
return linearSrgbToColor(
1.2249401762805587 * R - 0.2249404646817506 * G + 0.0000002884022551 * B,
-0.0420569547096138 * R + 1.0420571661298634 * G - 0.0000002113202247 * B,
-0.0196375587040044 * R - 0.0786360772174755 * G + 1.0982736359214800 * B,
);
}
default:
return null;
}
}
function hslToRgb(h, s, l) {
h = ((h % 360) + 360) % 360;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m0 = l - c / 2;
const [r, g, b] =
h < 60 ? [c, x, 0] :
h < 120 ? [x, c, 0] :
h < 180 ? [0, c, x] :
h < 240 ? [0, x, c] :
h < 300 ? [x, 0, c] : [c, 0, x];
return {
r: Math.round((r + m0) * 255),
g: Math.round((g + m0) * 255),
b: Math.round((b + m0) * 255),
a: 1,
};
}
function hwbToRgb(h, w, bl) {
if (w + bl >= 1) {
const g = Math.round((w / (w + bl)) * 255);
return { r: g, g, b: g, a: 1 };
}
const base = hslToRgb(h, 1, 0.5);
const mix = (c) => Math.round(((c / 255) * (1 - w - bl) + w) * 255);
return { r: mix(base.r), g: mix(base.g), b: mix(base.b), a: 1 };
}
// Common CSS named colors — the handful that actually show up in generated
// UIs, not the full 148-name spec list. Includes the achromatic names so a
// named gray parses (and correctly reads as no-chroma) instead of being
// treated as an unknown color.
const CSS_NAMED_COLORS = {
black: { r: 0, g: 0, b: 0 },
white: { r: 255, g: 255, b: 255 },
gray: { r: 128, g: 128, b: 128 },
grey: { r: 128, g: 128, b: 128 },
silver: { r: 192, g: 192, b: 192 },
dimgray: { r: 105, g: 105, b: 105 },
darkgray: { r: 169, g: 169, b: 169 },
lightgray: { r: 211, g: 211, b: 211 },
gainsboro: { r: 220, g: 220, b: 220 },
whitesmoke: { r: 245, g: 245, b: 245 },
red: { r: 255, g: 0, b: 0 },
crimson: { r: 220, g: 20, b: 60 },
tomato: { r: 255, g: 99, b: 71 },
coral: { r: 255, g: 127, b: 80 },
salmon: { r: 250, g: 128, b: 114 },
orange: { r: 255, g: 165, b: 0 },
gold: { r: 255, g: 215, b: 0 },
yellow: { r: 255, g: 255, b: 0 },
olive: { r: 128, g: 128, b: 0 },
lime: { r: 0, g: 255, b: 0 },
green: { r: 0, g: 128, b: 0 },
teal: { r: 0, g: 128, b: 128 },
turquoise: { r: 64, g: 224, b: 208 },
cyan: { r: 0, g: 255, b: 255 },
aqua: { r: 0, g: 255, b: 255 },
skyblue: { r: 135, g: 206, b: 235 },
dodgerblue: { r: 30, g: 144, b: 255 },
blue: { r: 0, g: 0, b: 255 },
navy: { r: 0, g: 0, b: 128 },
indigo: { r: 75, g: 0, b: 130 },
rebeccapurple: { r: 102, g: 51, b: 153 },
purple: { r: 128, g: 0, b: 128 },
violet: { r: 238, g: 130, b: 238 },
orchid: { r: 218, g: 112, b: 214 },
magenta: { r: 255, g: 0, b: 255 },
fuchsia: { r: 255, g: 0, b: 255 },
hotpink: { r: 255, g: 105, b: 180 },
pink: { r: 255, g: 192, b: 203 },
maroon: { r: 128, g: 0, b: 0 },
};
// Split a string on top-level commas (ignoring commas nested in parens).
function splitTopLevelCommas(str) {
const parts = [];
let depth = 0, start = 0;
for (let i = 0; i < str.length; i++) {
const ch = str[i];
if (ch === '(') depth++;
else if (ch === ')') depth = Math.max(0, depth - 1);
else if (ch === ',' && depth === 0) {
parts.push(str.slice(start, i).trim());
start = i + 1;
}
}
const tail = str.slice(start).trim();
if (tail) parts.push(tail);
return parts;
}
// Evaluate a CSS color-mix() expression to {r,g,b,a}. Returns null when
// the expression can't be resolved (unresolved var(), unknown colors).
//
// Mixing is done with premultiplied alpha in sRGB regardless of the
// declared interpolation space. That is exact for the dominant generated-UI
// pattern — `color-mix(in oklab, <color> N%, transparent)` — where the
// result is simply <color> at alpha N% in ANY rectangular space, and a
// close-enough approximation for opaque-opaque mixes (the detector only
// consumes these values for contrast/chroma thresholds, not for display).
function parseColorMix(str) {
const m = String(str).trim().match(/^color-mix\(/i);
if (!m) return null;
// Balanced-paren capture of the arguments.
let depth = 0, end = -1;
const open = str.indexOf('(');
for (let i = open; i < str.length; i++) {
if (str[i] === '(') depth++;
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
}
if (end < 0) return null;
const args = splitTopLevelCommas(str.slice(open + 1, end));
if (args.length !== 3 || !/^in\s/i.test(args[0])) return null;
const parseComponent = (component) => {
// Percentage may lead or trail the color per spec.
let pct = null;
let colorStr = component;
const trail = component.match(/\s+([\d.]+)%$/);
const lead = component.match(/^([\d.]+)%\s+/);
if (trail) { pct = parseFloat(trail[1]); colorStr = component.slice(0, trail.index).trim(); }
else if (lead) { pct = parseFloat(lead[1]); colorStr = component.slice(lead[0].length).trim(); }
let color;
if (/^transparent$/i.test(colorStr)) color = { r: 0, g: 0, b: 0, a: 0 };
else color = parseAnyColor(colorStr);
if (!color) return null;
return { color, pct };
};
const c1 = parseComponent(args[1]);
const c2 = parseComponent(args[2]);
if (!c1 || !c2) return null;
let p1 = c1.pct, p2 = c2.pct;
if (p1 == null && p2 == null) { p1 = 50; p2 = 50; }
else if (p1 == null) p1 = 100 - p2;
else if (p2 == null) p2 = 100 - p1;
const sum = p1 + p2;
if (sum <= 0) return null;
// Per spec: weights normalize to sum; when sum < 100 the result alpha is
// additionally scaled by sum/100.
const w1 = p1 / sum, w2 = p2 / sum;
const alphaScale = sum < 100 ? sum / 100 : 1;
const a1 = c1.color.a ?? 1, a2 = c2.color.a ?? 1;
const a = (a1 * w1 + a2 * w2) * alphaScale;
if (a <= 0) return { r: 0, g: 0, b: 0, a: 0 };
const mix = (ch) => Math.round((c1.color[ch] * a1 * w1 + c2.color[ch] * a2 * w2) / (a1 * w1 + a2 * w2));
return { r: mix('r'), g: mix('g'), b: mix('b'), a: Math.min(1, a) };
}
// Composite a translucent color over an opaque(ish) base (simple
// source-over in sRGB). Returns an opaque {r,g,b,a:1}.
function compositeColorOver(top, base) {
const a = top.a ?? 1;
return {
r: Math.round(top.r * a + base.r * (1 - a)),
g: Math.round(top.g * a + base.g * (1 - a)),
b: Math.round(top.b * a + base.b * (1 - a)),
a: 1,
};
}
// A color() / lab() / lch() component: a bare number, a percentage against
// `scale`, or the `none` keyword (which resolves to zero for our purposes).
function parseColorComponent(token, scale = 1) {
if (token == null) return null;
const t = String(token).trim();
if (/^none$/i.test(t)) return 0;
const num = parseFloat(t);
if (!Number.isFinite(num)) return null;
return t.endsWith('%') ? (num / 100) * scale : num;
}
function parseAlphaToken(token) {
if (token == null) return 1;
const t = String(token).trim();
if (/^none$/i.test(t)) return 1;
const num = parseFloat(t);
if (!Number.isFinite(num)) return 1;
return t.endsWith('%') ? num / 100 : num;
}
// Extended color parser: rgb/rgba/hex/oklch/oklab/lch/lab/hsl/hwb/color()/
// color-mix/common named colors. Returns null on no match. Use this when the
// input might be any CSS color form; use plain parseRgb when you only expect
// computed rgb() values from real browsers.
function parseAnyColor(s) {
if (!s || typeof s !== 'string') return null;
const str = s.trim();
if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
if (/^color-mix\(/i.test(str)) return parseColorMix(str);
let m;
m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/);
if (m) {
const c = { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: 1 };
if (m[4] !== undefined) c.a = m[5] === '%' ? parseFloat(m[4]) / 100 : +m[4];
return c;
}
m = str.match(/^#([0-9a-f]{3,8})$/i);
if (m) {
const h = m[1];
if (h.length === 3 || h.length === 4) {
return {
r: parseInt(h[0] + h[0], 16),
g: parseInt(h[1] + h[1], 16),
b: parseInt(h[2] + h[2], 16),
a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
};
}
if (h.length === 6 || h.length === 8) {
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
};
}
}
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
// Match L (with optional %), then C and H separated permissively.
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const Lnum = parseFloat(m[1]);
const L = m[2] === '%' ? Lnum / 100 : Lnum;
const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
if (m[5] !== undefined) {
const alpha = parseFloat(m[5]);
rgb.a = m[6] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// OKLAB — a/b are signed axes; percentages map 100% → 0.4.
m = str.match(/oklab\(\s*([\d.]+)(%?)\s+(-?[\d.]+)(%?)\s+(-?[\d.]+)(%?)(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const L = m[2] === '%' ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
const a = m[4] === '%' ? parseFloat(m[3]) * 0.004 : parseFloat(m[3]);
const b = m[6] === '%' ? parseFloat(m[5]) * 0.004 : parseFloat(m[5]);
const rgb = oklabToRgb(L, a, b);
if (m[7] !== undefined) {
const alpha = parseFloat(m[7]);
rgb.a = m[8] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// LCH / LAB — CIE, D50 white point. Chrome serializes lch(20% 5 60) as
// `lch(20 5 60)`, so L arrives with or without its percent sign. In both
// spaces L runs 0..100 and 100% means 100.
m = str.match(/^lch\(\s*([\d.]+%?|none)\s+([\d.]+%?|none)\s+(-?[\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
if (m) {
const L = parseColorComponent(m[1], 100);
const C = parseColorComponent(m[2], 150);
const H = parseFloat(m[3]);
if (L == null || C == null || !Number.isFinite(H)) return null;
const rgb = lchToRgb(L, C, H);
rgb.a = parseAlphaToken(m[4]);
return rgb;
}
m = str.match(/^lab\(\s*([\d.]+%?|none)\s+(-?[\d.]+%?|none)\s+(-?[\d.]+%?|none)(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
if (m) {
const L = parseColorComponent(m[1], 100);
const a = parseColorComponent(m[2], 125);
const b = parseColorComponent(m[3], 125);
if (L == null || a == null || b == null) return null;
const rgb = labToRgb(L, a, b);
rgb.a = parseAlphaToken(m[4]);
return rgb;
}
// color(<space> c1 c2 c3 [/ alpha]) — what Chrome hands back for most
// color-mix() results and for any wide-gamut color an author wrote.
m = str.match(/^color\(\s*([a-z0-9-]+)\s+(-?[\d.eE+-]+%?|none)\s+(-?[\d.eE+-]+%?|none)\s+(-?[\d.eE+-]+%?|none)(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
if (m) {
const c1 = parseColorComponent(m[2]);
const c2 = parseColorComponent(m[3]);
const c3 = parseColorComponent(m[4]);
if (c1 == null || c2 == null || c3 == null) return null;
const rgb = colorFunctionToRgb(m[1].toLowerCase(), c1, c2, c3);
if (!rgb) return null;
rgb.a = parseAlphaToken(m[5]);
return rgb;
}
// HSL/HSLA — comma or space syntax, optional deg on hue.
m = str.match(/hsla?\(\s*(-?[\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%\s*[,\s]\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hslToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// HWB — hue whiteness% blackness%.
m = str.match(/hwb\(\s*(-?[\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hwbToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
const named = CSS_NAMED_COLORS[str.toLowerCase()];
if (named) return { ...named, a: 1 };
return null;
}
// True when a computed background-color string names no paint at all. Used to
// tell "this layer is see-through" (walk on to the ancestor) apart from "this
// layer has a color we could not read" (stop and abstain).
//
// `inherit` belongs here even though it is not literally see-through: it means
// "paint with the parent's background-color", and walking on to the parent IS
// that resolution. Real browsers resolve the keyword before getComputedStyle
// output; only jsdom's partial cascade hands it through verbatim, and treating
// it as unreadable would make the walk abstain on a surface it can know.
// (`currentcolor` is NOT here — it is real paint in the element's own text
// color; resolveBackgroundInfo substitutes the computed color for it.)
function isNoPaintColorValue(value) {
const v = String(value || '').trim().toLowerCase();
if (!v) return true;
return v === 'transparent' || v === 'none' || v === 'initial' || v === 'inherit' || v === 'unset' || v === 'revert' || v === 'revert-layer';
}
export {
isNeutralColor,
parseRgb,
relativeLuminance,
contrastRatio,
parseGradientColors,
extractColorFunctionTokens,
hasChroma,
getHue,
colorToHex,
oklabToRgb,
oklchToRgb,
labToRgb,
lchToRgb,
colorFunctionToRgb,
hslToRgb,
hwbToRgb,
CSS_NAMED_COLORS,
splitTopLevelCommas,
parseColorMix,
parseAnyColor,
compositeColorOver,
isNoPaintColorValue,
};
File diff suppressed because it is too large Load Diff
@@ -11,14 +11,21 @@ import {
isBrandFontOnOwnDomain,
} from '../shared/constants.mjs';
import {
CSS_NAMED_COLORS,
colorToHex,
compositeColorOver,
contrastRatio,
getHue,
hasChroma,
isNeutralColor,
isNoPaintColorValue,
oklchToRgb,
parseAnyColor,
parseColorMix,
parseGradientColors,
parseRgb,
relativeLuminance,
splitTopLevelCommas,
} from '../shared/color.mjs';
import { extractGoogleFontFamilies } from '../shared/fonts.mjs';
@@ -1731,7 +1738,7 @@ function readOwnBackgroundColor(el, computedStyle) {
// One element's background-color as the cascade walk sees it: computed style
// first (with the modern-color fallback), then, in static mode only,
// custom-prop resolution and the inline-shorthand peek. Shared by
// resolveBackground and resolveGradientStops so both walks read the same
// resolveBackgroundInfo and resolveGradientStops so both walks read the same
// surfaces.
function readCascadeBackgroundColor(current, style, customPropMap) {
let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
@@ -1755,7 +1762,19 @@ function readCascadeBackgroundColor(current, style, customPropMap) {
return bg;
}
function resolveBackground(el, win, customPropMap) {
// Walk up for the surface the element's text is painted on.
//
// Returns { color, unresolved }:
// • color set — the effective surface, overlays composited in.
// • unresolved: true — a layer on the way up paints a color this parser
// cannot read, so the surface is unknown. Callers
// must SKIP their contrast checks. Guessing white
// here is what flooded dark themes with false
// "on #ffffff" findings: one abstention costs a
// single finding, one wrong guess costs a hundred.
// • both null/false — no solid color, but a gradient or image is in
// play; callers fall back to its color stops.
function resolveBackgroundInfo(el, win, customPropMap) {
let current = el;
// Translucent layers (0.1 < a < 1) found on the way down to an opaque
// base. A browser composites these over the base; the old behavior
@@ -1783,59 +1802,81 @@ function resolveBackground(el, win, customPropMap) {
// body backgrounds.
// Real browsers serialize wide-gamut computed values as oklab()/oklch()
// (e.g. any color-mix() result), which plain parseRgb misses.
const bg = readCascadeBackgroundColor(current, style, customPropMap);
let bg = readCascadeBackgroundColor(current, style, customPropMap);
// `background-color: currentcolor` paints with the element's own text
// color — real paint whose value we know. Real browsers resolve the
// keyword before getComputedStyle output; jsdom hands it through
// verbatim, and without this substitution the layer would read as
// unparseable and force a needless abstention.
if ((!bg || bg.a < 0.1) && /^currentcolor$/i.test(String(style.backgroundColor || '').trim())) {
// The static cascade resolves var() text tokens before checks run, so
// style.color is normally already an rgb string here; parseColorResolved
// is defense in depth for any future caller that passes a live
// customPropMap (it matches the text-color path in checkElementColors
// and reduces to parseAnyColor when the map is null or absent).
bg = parseRgb(style.color) || parseColorResolved(style.color, customPropMap);
}
if (bg && bg.a > 0.1) {
if (bg.a >= 0.99) return flatten(bg);
if (bg.a >= 0.99) return { color: flatten(bg), unresolved: false };
overlays.push(bg);
} else if (!bg && !isNoPaintColorValue(style.backgroundColor)) {
// This layer names a color we could not parse (a color space we do not
// model, an unresolved var(), a syntax newer than the parser). It may
// well be opaque, which would make every ancestor below it invisible —
// so the surface is unknown and the walk stops here rather than
// reporting an ancestor the visitor never sees.
return { color: null, unresolved: true };
}
// No solid bg-color at this level, but this level paints an image. CSS
// stacks background-image layers first-on-top, so which layer leads
// decides what the visitor sees:
// • gradient on top — the gradient is the surface. Hand the caller a
// null color so it falls back to the gradient's own stops (body
// grounds, gradient buttons, hero sections).
// • url() on top — the surface is an image whose pixels this engine
// cannot read, and it may fully cover every layer and ancestor
// beneath it. Same contract as an unparseable color: abstain, so
// the gradient-stop fallback never measures a gradient the image
// hides (the shipped miss: `url(photo), linear-gradient(...)`
// reported low-contrast against the invisible gradient's stops).
if (hasGradientOrUrl) {
const layers = splitTopLevelCommas(bgImage);
const topPaintLayer = layers.find(
(layer) => /gradient\s*\(/i.test(layer) || /url\s*\(/i.test(layer),
);
const gradientOnTop = !!topPaintLayer
&& /gradient\s*\(/i.test(topPaintLayer)
&& !/^\s*url\s*\(/i.test(topPaintLayer);
if (!gradientOnTop) return { color: null, unresolved: true };
// Gradient on top of a url() layer: the image shows through wherever
// the gradient is not fully opaque, so a translucent wash like
// `linear-gradient(rgba(0,0,0,.2), rgba(0,0,0,.2)), url(photo)` paints
// a blend with pixels this engine cannot read. Only a gradient whose
// every readable stop is opaque provably covers the image; otherwise
// the surface is unknown — abstain rather than hand callers gradient
// stops (or a stop average) the visitor never sees unmixed.
const urlBeneath = layers.some(
(layer) => layer !== topPaintLayer && /url\s*\(/i.test(layer),
);
if (urlBeneath) {
const topStops = parseGradientColors(topPaintLayer);
const provablyOpaque = topStops.length > 0 && topStops.every((s) => (s.a ?? 1) >= 0.99);
if (!provablyOpaque) return { color: null, unresolved: true };
}
return { color: null, unresolved: false };
}
// No solid bg-color at this level. If THIS level has a gradient/url
// with no underlying solid color we can read:
// • on body/html: assume white. Body-level gradients are almost
// always decorative texture (paper grain, noise) on top of a
// solid bg-color the page set via `background: var(--paper)`
// shorthand — which jsdom can't decompose into bg-color. The
// downstream gradient-stops fallback path produces catastrophic
// false positives in this case (gradient noise stops have
// accidental browns/blacks that look like card backgrounds).
// • on other elements: bail to null and let the caller fall back
// to gradient stops (gradient buttons / hero sections are real
// bgs worth checking against).
// A gradient or image with no solid color under it, at any level
// including body/html, means the visible ground is that layer itself.
// Return null so the caller measures against the actual gradient stops,
// or skips when nothing is parseable — skipping beats a wrong ratio.
//
// Body/html used to assume white here, a guard written for jsdom, which
// never decomposed the `background:` shorthand and so could not see the
// solid paper color a texture gradient usually sits on. It turned every
// light-on-dark page into a wall of low-contrast false positives (a dark
// oklch body gradient produced ~120 "on #ffffff" findings on one site).
// Both engines can see shorthand solids now — the browser natively, the
// static cascade via expandStaticDeclaration + var() resolution — so a
// missing solid is real, and the old failure case cannot recur: opaque
// stops fully cover any hidden solid (they ARE the ground), alpha stops
// composite over the resolved base or the white canvas default, and
// unresolvable stops drop rather than guess.
if (hasGradientOrUrl) return null;
current = current.parentElement;
}
return flatten({ r: 255, g: 255, b: 255, a: 1 });
// Every layer up to the document root was genuinely see-through, so the
// browser paints its default canvas. This is the ONLY case that earns the
// white assumption.
return { color: flatten({ r: 255, g: 255, b: 255, a: 1 }), unresolved: false };
}
// parseGradientColors (shared) reads only the legacy serializations: rgb()
// and hex stops. Browsers keep modern-space stops in computed backgroundImage
// exactly as authored — `linear-gradient(oklch(7% 0.006 95), …)` stays oklch —
// which is what every token-driven page produces. Route those through
// parseAnyColor so a gradient ground is measurable rather than invisible.
function parseGradientColorsModern(bgImage) {
if (!bgImage || !/gradient/i.test(bgImage)) return [];
const colors = parseGradientColors(bgImage);
for (const m of bgImage.matchAll(/(?:oklch|oklab|hsla?|hwb)\(\s*[^()]*\)/gi)) {
const c = parseAnyColor(m[0]);
if (c) colors.push(c);
}
return colors;
function resolveBackground(el, win, customPropMap) {
return resolveBackgroundInfo(el, win, customPropMap).color;
}
// Walk parents looking for a gradient background and return its color stops.
@@ -1861,7 +1902,10 @@ function resolveGradientStops(el, win, customPropMap) {
if (bgImage && bgImage !== 'none' && /url\s*\(/i.test(bgImage)) return null;
let stops = null;
if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
const parsed = parseGradientColorsModern(bgImage);
// parseGradientColors (shared) reads modern-space stops too — oklch,
// color-mix and friends via balanced-paren token capture — so browser
// computed values that keep the authored syntax stay measurable.
const parsed = parseGradientColors(bgImage);
if (parsed.length > 0) stops = parsed;
}
if (!stops && !DETECTOR_IS_BROWSER) {
@@ -1869,7 +1913,7 @@ function resolveGradientStops(el, win, customPropMap) {
const rawStyle = current.getAttribute?.('style') || '';
const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
if (bgMatch && /gradient/i.test(bgMatch[1])) {
const parsed = parseGradientColorsModern(bgMatch[1]);
const parsed = parseGradientColors(bgMatch[1]);
if (parsed.length > 0) stops = parsed;
}
}
@@ -2115,13 +2159,19 @@ function checkElementColorsDOM(el) {
if (style.visibility === 'hidden' || effectiveOpacityDOM(el) <= 0.02) return [];
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
let effectiveBg = resolveBackground(el);
const bgInfo = resolveBackgroundInfo(el);
let effectiveBg = bgInfo.color;
// An unreadable surface anywhere up the chain: skip the gradient-stop
// fallback too, so nothing downstream measures against a ground we never
// resolved.
let surfaceUnresolved = bgInfo.unresolved;
let ownBg = readOwnBackgroundColor(el, style);
if (!ownBg || (ownBg.a ?? 1) <= 0.5) {
const pseudoSurface = readPseudoSurfaceDOM(el, rect);
if (pseudoSurface) {
ownBg = pseudoSurface;
effectiveBg = pseudoSurface;
surfaceUnresolved = false;
}
}
return checkColors({
@@ -2133,8 +2183,8 @@ function checkElementColorsDOM(el) {
// an oklch token near its own oklch background).
textColor: parseRgb(style.color) || parseAnyColor(style.color),
bgColor: ownBg,
effectiveBg,
effectiveBgStops: effectiveBg ? null : resolveGradientStops(el),
effectiveBg: surfaceUnresolved ? null : effectiveBg,
effectiveBgStops: surfaceUnresolved || effectiveBg ? null : resolveGradientStops(el),
fontSize: parseFloat(style.fontSize) || 16,
fontWeight: parseInt(style.fontWeight) || 400,
hasDirectText,
@@ -2284,283 +2334,6 @@ function resolveVarRefs(raw, customPropMap, depth = 0) {
});
}
// OKLCH → sRGB conversion (Björn Ottosson's matrices). L in 0..1 (or %),
// C in 0..~0.4 typical, H in degrees. Returns clamped {r,g,b,a:1} in 0..255.
// Needed because jsdom doesn't compute oklch() values — getComputedStyle
// returns the literal "oklch(...)" string. Without this, the entire
// Tailwind v4 color palette (which is OKLCH-based) is invisible to the
// detector's contrast / color checks.
function oklchToRgb(L, C, H) {
const hRad = (H * Math.PI) / 180;
return oklabToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
}
function oklabToRgb(L, a, b) {
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
const rLin = 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc;
const gLin = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc;
const bLin = -0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc;
const enc = (x) => {
const c = Math.max(0, Math.min(1, x));
return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
};
return {
r: Math.round(enc(rLin) * 255),
g: Math.round(enc(gLin) * 255),
b: Math.round(enc(bLin) * 255),
a: 1,
};
}
function hslToRgb(h, s, l) {
h = ((h % 360) + 360) % 360;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m0 = l - c / 2;
const [r, g, b] =
h < 60 ? [c, x, 0] :
h < 120 ? [x, c, 0] :
h < 180 ? [0, c, x] :
h < 240 ? [0, x, c] :
h < 300 ? [x, 0, c] : [c, 0, x];
return {
r: Math.round((r + m0) * 255),
g: Math.round((g + m0) * 255),
b: Math.round((b + m0) * 255),
a: 1,
};
}
function hwbToRgb(h, w, bl) {
if (w + bl >= 1) {
const g = Math.round((w / (w + bl)) * 255);
return { r: g, g, b: g, a: 1 };
}
const base = hslToRgb(h, 1, 0.5);
const mix = (c) => Math.round(((c / 255) * (1 - w - bl) + w) * 255);
return { r: mix(base.r), g: mix(base.g), b: mix(base.b), a: 1 };
}
// Common CSS named colors — the handful that actually show up in generated
// UIs, not the full 148-name spec list. Includes the achromatic names so a
// named gray parses (and correctly reads as no-chroma) instead of being
// treated as an unknown color.
const CSS_NAMED_COLORS = {
black: { r: 0, g: 0, b: 0 },
white: { r: 255, g: 255, b: 255 },
gray: { r: 128, g: 128, b: 128 },
grey: { r: 128, g: 128, b: 128 },
silver: { r: 192, g: 192, b: 192 },
dimgray: { r: 105, g: 105, b: 105 },
darkgray: { r: 169, g: 169, b: 169 },
lightgray: { r: 211, g: 211, b: 211 },
gainsboro: { r: 220, g: 220, b: 220 },
whitesmoke: { r: 245, g: 245, b: 245 },
red: { r: 255, g: 0, b: 0 },
crimson: { r: 220, g: 20, b: 60 },
tomato: { r: 255, g: 99, b: 71 },
coral: { r: 255, g: 127, b: 80 },
salmon: { r: 250, g: 128, b: 114 },
orange: { r: 255, g: 165, b: 0 },
gold: { r: 255, g: 215, b: 0 },
yellow: { r: 255, g: 255, b: 0 },
olive: { r: 128, g: 128, b: 0 },
lime: { r: 0, g: 255, b: 0 },
green: { r: 0, g: 128, b: 0 },
teal: { r: 0, g: 128, b: 128 },
turquoise: { r: 64, g: 224, b: 208 },
cyan: { r: 0, g: 255, b: 255 },
aqua: { r: 0, g: 255, b: 255 },
skyblue: { r: 135, g: 206, b: 235 },
dodgerblue: { r: 30, g: 144, b: 255 },
blue: { r: 0, g: 0, b: 255 },
navy: { r: 0, g: 0, b: 128 },
indigo: { r: 75, g: 0, b: 130 },
rebeccapurple: { r: 102, g: 51, b: 153 },
purple: { r: 128, g: 0, b: 128 },
violet: { r: 238, g: 130, b: 238 },
orchid: { r: 218, g: 112, b: 214 },
magenta: { r: 255, g: 0, b: 255 },
fuchsia: { r: 255, g: 0, b: 255 },
hotpink: { r: 255, g: 105, b: 180 },
pink: { r: 255, g: 192, b: 203 },
maroon: { r: 128, g: 0, b: 0 },
};
// Split a string on top-level commas (ignoring commas nested in parens).
function splitTopLevelCommas(str) {
const parts = [];
let depth = 0, start = 0;
for (let i = 0; i < str.length; i++) {
const ch = str[i];
if (ch === '(') depth++;
else if (ch === ')') depth = Math.max(0, depth - 1);
else if (ch === ',' && depth === 0) {
parts.push(str.slice(start, i).trim());
start = i + 1;
}
}
const tail = str.slice(start).trim();
if (tail) parts.push(tail);
return parts;
}
// Evaluate a CSS color-mix() expression to {r,g,b,a}. Returns null when
// the expression can't be resolved (unresolved var(), unknown colors).
//
// Mixing is done with premultiplied alpha in sRGB regardless of the
// declared interpolation space. That is exact for the dominant generated-UI
// pattern — `color-mix(in oklab, <color> N%, transparent)` — where the
// result is simply <color> at alpha N% in ANY rectangular space, and a
// close-enough approximation for opaque-opaque mixes (the detector only
// consumes these values for contrast/chroma thresholds, not for display).
function parseColorMix(str) {
const m = String(str).trim().match(/^color-mix\(/i);
if (!m) return null;
// Balanced-paren capture of the arguments.
let depth = 0, end = -1;
const open = str.indexOf('(');
for (let i = open; i < str.length; i++) {
if (str[i] === '(') depth++;
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
}
if (end < 0) return null;
const args = splitTopLevelCommas(str.slice(open + 1, end));
if (args.length !== 3 || !/^in\s/i.test(args[0])) return null;
const parseComponent = (component) => {
// Percentage may lead or trail the color per spec.
let pct = null;
let colorStr = component;
const trail = component.match(/\s+([\d.]+)%$/);
const lead = component.match(/^([\d.]+)%\s+/);
if (trail) { pct = parseFloat(trail[1]); colorStr = component.slice(0, trail.index).trim(); }
else if (lead) { pct = parseFloat(lead[1]); colorStr = component.slice(lead[0].length).trim(); }
let color;
if (/^transparent$/i.test(colorStr)) color = { r: 0, g: 0, b: 0, a: 0 };
else color = parseAnyColor(colorStr);
if (!color) return null;
return { color, pct };
};
const c1 = parseComponent(args[1]);
const c2 = parseComponent(args[2]);
if (!c1 || !c2) return null;
let p1 = c1.pct, p2 = c2.pct;
if (p1 == null && p2 == null) { p1 = 50; p2 = 50; }
else if (p1 == null) p1 = 100 - p2;
else if (p2 == null) p2 = 100 - p1;
const sum = p1 + p2;
if (sum <= 0) return null;
// Per spec: weights normalize to sum; when sum < 100 the result alpha is
// additionally scaled by sum/100.
const w1 = p1 / sum, w2 = p2 / sum;
const alphaScale = sum < 100 ? sum / 100 : 1;
const a1 = c1.color.a ?? 1, a2 = c2.color.a ?? 1;
const a = (a1 * w1 + a2 * w2) * alphaScale;
if (a <= 0) return { r: 0, g: 0, b: 0, a: 0 };
const mix = (ch) => Math.round((c1.color[ch] * a1 * w1 + c2.color[ch] * a2 * w2) / (a1 * w1 + a2 * w2));
return { r: mix('r'), g: mix('g'), b: mix('b'), a: Math.min(1, a) };
}
// Composite a translucent color over an opaque(ish) base (simple
// source-over in sRGB). Returns an opaque {r,g,b,a:1}.
function compositeColorOver(top, base) {
const a = top.a ?? 1;
return {
r: Math.round(top.r * a + base.r * (1 - a)),
g: Math.round(top.g * a + base.g * (1 - a)),
b: Math.round(top.b * a + base.b * (1 - a)),
a: 1,
};
}
// Extended color parser: rgb/rgba/hex/oklch/oklab/hsl/hwb/color-mix/common
// named colors. Returns null on no match. Use this when the input might be
// any CSS color form; use plain parseRgb when you only expect computed rgb()
// values from real browsers.
function parseAnyColor(s) {
if (!s || typeof s !== 'string') return null;
const str = s.trim();
if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
if (/^color-mix\(/i.test(str)) return parseColorMix(str);
let m;
m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+))?\s*\)/);
if (m) return { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: m[4] !== undefined ? +m[4] : 1 };
m = str.match(/^#([0-9a-f]{3,8})$/i);
if (m) {
const h = m[1];
if (h.length === 3 || h.length === 4) {
return {
r: parseInt(h[0] + h[0], 16),
g: parseInt(h[1] + h[1], 16),
b: parseInt(h[2] + h[2], 16),
a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
};
}
if (h.length === 6 || h.length === 8) {
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
};
}
}
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
// Match L (with optional %), then C and H separated permissively.
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const Lnum = parseFloat(m[1]);
const L = m[2] === '%' ? Lnum / 100 : Lnum;
const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
if (m[5] !== undefined) {
const alpha = parseFloat(m[5]);
rgb.a = m[6] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// OKLAB — a/b are signed axes; percentages map 100% → 0.4.
m = str.match(/oklab\(\s*([\d.]+)(%?)\s+(-?[\d.]+)(%?)\s+(-?[\d.]+)(%?)(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const L = m[2] === '%' ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
const a = m[4] === '%' ? parseFloat(m[3]) * 0.004 : parseFloat(m[3]);
const b = m[6] === '%' ? parseFloat(m[5]) * 0.004 : parseFloat(m[5]);
const rgb = oklabToRgb(L, a, b);
if (m[7] !== undefined) {
const alpha = parseFloat(m[7]);
rgb.a = m[8] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// HSL/HSLA — comma or space syntax, optional deg on hue.
m = str.match(/hsla?\(\s*(-?[\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%\s*[,\s]\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hslToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// HWB — hue whiteness% blackness%.
m = str.match(/hwb\(\s*(-?[\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hwbToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
const named = CSS_NAMED_COLORS[str.toLowerCase()];
if (named) return { ...named, a: 1 };
return null;
}
// Resolve var() refs in a color string (via customPropMap), then parse.
// Returns null on any failure. Used in jsdom-mode paths where
@@ -2923,15 +2696,24 @@ function checkElementGlowDOM(el) {
if (!boxShadow && !textShadow) return [];
// Use parent's background — glow radiates outward, so the surrounding context matters
// If resolveBackground returns null (gradient), try to infer from the gradient colors
let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el);
if (!parentBg) {
const parentBgInfo = resolveBackgroundInfo(el.parentElement || el);
// Unknown surface (an unreadable layer on the way up): skip only the
// gradient hunt below, which would walk PAST that layer and score the
// glow against a background the visitor never sees. checkGlow still runs
// with a null surface: the zero-offset chromatic halo tell holds on ANY
// background, and the static loop already passes the unresolved walk's
// null color straight through (detect-html.mjs uses resolveBackground).
let parentBg = parentBgInfo.color;
if (!parentBg && !parentBgInfo.unresolved) {
// Gradient background — sample its colors to determine if it's dark.
// Modern-syntax parsing matters here: body-level gradients now reach this
// fallback in browser mode, and their stops usually serialize as oklch.
// fallback in browser mode, and their stops usually serialize as oklch
// which the shared parseGradientColors reads via its color-function
// token capture.
let cur = el.parentElement;
while (cur && cur.nodeType === 1) {
const bgImage = getComputedStyle(cur).backgroundImage || '';
const gradColors = parseGradientColorsModern(bgImage);
const gradColors = parseGradientColors(bgImage);
if (gradColors.length > 0) {
// Average the gradient colors
const avg = { r: 0, g: 0, b: 0 };
@@ -2975,10 +2757,13 @@ function checkElementAIPaletteDOM(el) {
const hue = getHue(textColor);
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
if (isAIPalette) {
const parentBg = el.parentElement ? resolveBackground(el.parentElement) : null;
// Also check gradient parents
let effectiveBg = parentBg;
if (!effectiveBg) {
const parentBgInfo = el.parentElement
? resolveBackgroundInfo(el.parentElement)
: { color: null, unresolved: false };
// Unknown surface: leave effectiveBg null (no finding) rather than
// hunting gradient ancestors past a layer we could not read.
let effectiveBg = parentBgInfo.color;
if (!effectiveBg && !parentBgInfo.unresolved) {
let cur = el.parentElement;
while (cur && cur.nodeType === 1) {
const gi = getComputedStyle(cur).backgroundImage || '';
@@ -3784,7 +3569,8 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
const effectiveBg = resolveBackground(el, window, customPropMap);
const bgInfo = resolveBackgroundInfo(el, window, customPropMap);
const effectiveBg = bgInfo.color;
// jsdom returns literal "var(--X)" / "oklch(...)" for color, so plain
// parseRgb misses Tailwind-tokenized text colors. Resolve through the
// customPropMap first; fall back to parseRgb for vanilla rgb() pages.
@@ -3830,11 +3616,13 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
// element itself has no usable own background, that pseudo is the real
// surface for contrast purposes.
let finalEffectiveBg = effectiveBg;
let surfaceUnresolved = bgInfo.unresolved;
if ((!ownBg || (ownBg.a ?? 1) <= 0.5) && typeof window.getPseudoSurface === 'function') {
const pseudoSurface = window.getPseudoSurface(el);
if (pseudoSurface) {
ownBg = pseudoSurface;
finalEffectiveBg = pseudoSurface;
surfaceUnresolved = false;
}
}
@@ -3842,8 +3630,9 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
tag,
textColor,
bgColor: ownBg,
effectiveBg: finalEffectiveBg,
effectiveBgStops: finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
// Unknown surface: hand the checks nothing rather than a guess.
effectiveBg: surfaceUnresolved ? null : finalEffectiveBg,
effectiveBgStops: surfaceUnresolved || finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
fontSize: parseFloat(style.fontSize) || 16,
fontWeight: parseInt(style.fontWeight) || 400,
hasDirectText,
@@ -5663,6 +5452,7 @@ export {
checkHtmlPatterns,
readOwnBackgroundColor,
resolveBackground,
resolveBackgroundInfo,
resolveGradientStops,
parseRadiusToPx,
resolveBorderRadiusPx,
@@ -71,11 +71,43 @@ function contrastRatio(c1, c2) {
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
// The CSS color functions worth pulling out of a longer declaration. The set
// is deliberately closed: `linear-gradient(` and `url(` also look like
// `name(` and must not be read as colors.
const COLOR_FUNCTION_NAMES = new Set([
'rgb', 'rgba', 'hsl', 'hsla', 'hwb', 'oklch', 'oklab', 'lch', 'lab', 'color', 'color-mix',
]);
// Pull every color-function token out of a value, with balanced-paren capture
// so nested forms (`color-mix(in oklab, oklch(...) 20%, transparent)`) survive
// whole. Returns the raw substrings in source order.
function extractColorFunctionTokens(value) {
const str = String(value || '');
const tokens = [];
const re = /([a-z][a-z-]*)\(/gi;
let m;
while ((m = re.exec(str)) !== null) {
if (!COLOR_FUNCTION_NAMES.has(m[1].toLowerCase())) continue;
let depth = 0, end = -1;
for (let i = m.index + m[0].length - 1; i < str.length; i++) {
if (str[i] === '(') depth++;
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
}
if (end < 0) break;
tokens.push(str.slice(m.index, end + 1));
re.lastIndex = end + 1;
}
return tokens;
}
function parseGradientColors(bgImage) {
if (!bgImage || !bgImage.includes('gradient')) return [];
const colors = [];
for (const m of bgImage.matchAll(/rgba?\([^)]+\)/g)) {
const c = parseRgb(m[0]);
// Stops arrive in whatever syntax the author wrote and the browser kept.
// A dark ground painted as `linear-gradient(oklch(...), oklch(...))` used
// to read as a gradient with no stops at all.
for (const token of extractColorFunctionTokens(bgImage)) {
const c = parseAnyColor(token);
if (c) colors.push(c);
}
for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
@@ -112,13 +144,445 @@ function colorToHex(c) {
return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
}
// ─── Color-space conversions ────────────────────────────────────────────────
//
// Every function here lands on 8-bit sRGB, clamped to gamut. Chrome, Safari,
// and Firefox all keep the authored color space in getComputedStyle output
// (`oklch(0.84 0.19 80.46)`, `lch(20 5 60)`, `color(srgb 1.04 0.72 -0.21)`),
// so a detector that only reads rgb() is blind on any modern palette. The
// expected outputs are pinned in tests/detect-antipatterns.test.js against
// what Chrome itself paints for the same strings.
function clamp01(x) {
return Number.isFinite(x) ? Math.max(0, Math.min(1, x)) : 0;
}
// Linear-light sRGB channel to the encoded 0-255 value.
function encodeSrgbChannel(x) {
const c = clamp01(x);
return Math.round((c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055) * 255);
}
function decodeSrgbChannel(x) {
const c = Number.isFinite(x) ? x : 0;
const sign = c < 0 ? -1 : 1;
const abs = Math.abs(c);
return sign * (abs <= 0.04045 ? abs / 12.92 : Math.pow((abs + 0.055) / 1.055, 2.4));
}
function linearSrgbToColor(r, g, b, a = 1) {
return { r: encodeSrgbChannel(r), g: encodeSrgbChannel(g), b: encodeSrgbChannel(b), a };
}
// OKLab to sRGB (Björn Ottosson's matrices). L in 0..1, a/b are signed axes.
function oklabToRgb(L, a, b) {
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
return linearSrgbToColor(
4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc,
-1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc,
-0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc,
);
}
// OKLCH to sRGB. L in 0..1, C in 0..~0.4 typical, H in degrees. Chroma past
// the sRGB gamut clamps per channel rather than producing NaN.
function oklchToRgb(L, C, H) {
const hRad = (H * Math.PI) / 180;
return oklabToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
}
// CIE Lab to sRGB. CSS lab()/lch() use the D50 white point; the matrix below
// is the Bradford-adapted XYZ-D50 to linear-sRGB transform from CSS Color 4.
function labToRgb(L, a, b) {
const kappa = 24389 / 27, epsilon = 216 / 24389;
const fy = (L + 16) / 116, fx = fy + a / 500, fz = fy - b / 200;
const invert = (t) => (t * t * t > epsilon ? t * t * t : (116 * t - 16) / kappa);
const yr = L > kappa * epsilon ? Math.pow((L + 16) / 116, 3) : L / kappa;
const Xn = 0.3457 / 0.3585, Zn = (1 - 0.3457 - 0.3585) / 0.3585;
const x = invert(fx) * Xn, y = yr, z = invert(fz) * Zn;
return linearSrgbToColor(
3.1341359569958707 * x - 1.6173863321612538 * y - 0.4906619460083532 * z,
-0.9787955029120890 * x + 1.9162545672595240 * y + 0.0334427311613195 * z,
0.0719553798841168 * x - 0.2289768264158322 * y + 1.4053860583241250 * z,
);
}
function lchToRgb(L, C, H) {
const hRad = (H * Math.PI) / 180;
return labToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
}
// color(<space> c1 c2 c3) for the spaces that turn up in real stylesheets.
// `srgb` is what Chrome serializes most color-mix() results into, routinely
// with channels outside 0..1. Spaces we do not model return null so callers
// abstain instead of measuring against a color we invented.
function colorFunctionToRgb(space, c1, c2, c3) {
switch (space) {
case 'srgb':
return { r: Math.round(clamp01(c1) * 255), g: Math.round(clamp01(c2) * 255), b: Math.round(clamp01(c3) * 255), a: 1 };
case 'srgb-linear':
return linearSrgbToColor(c1, c2, c3);
case 'display-p3': {
const [R, G, B] = [decodeSrgbChannel(c1), decodeSrgbChannel(c2), decodeSrgbChannel(c3)];
return linearSrgbToColor(
1.2249401762805587 * R - 0.2249404646817506 * G + 0.0000002884022551 * B,
-0.0420569547096138 * R + 1.0420571661298634 * G - 0.0000002113202247 * B,
-0.0196375587040044 * R - 0.0786360772174755 * G + 1.0982736359214800 * B,
);
}
default:
return null;
}
}
function hslToRgb(h, s, l) {
h = ((h % 360) + 360) % 360;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m0 = l - c / 2;
const [r, g, b] =
h < 60 ? [c, x, 0] :
h < 120 ? [x, c, 0] :
h < 180 ? [0, c, x] :
h < 240 ? [0, x, c] :
h < 300 ? [x, 0, c] : [c, 0, x];
return {
r: Math.round((r + m0) * 255),
g: Math.round((g + m0) * 255),
b: Math.round((b + m0) * 255),
a: 1,
};
}
function hwbToRgb(h, w, bl) {
if (w + bl >= 1) {
const g = Math.round((w / (w + bl)) * 255);
return { r: g, g, b: g, a: 1 };
}
const base = hslToRgb(h, 1, 0.5);
const mix = (c) => Math.round(((c / 255) * (1 - w - bl) + w) * 255);
return { r: mix(base.r), g: mix(base.g), b: mix(base.b), a: 1 };
}
// Common CSS named colors — the handful that actually show up in generated
// UIs, not the full 148-name spec list. Includes the achromatic names so a
// named gray parses (and correctly reads as no-chroma) instead of being
// treated as an unknown color.
const CSS_NAMED_COLORS = {
black: { r: 0, g: 0, b: 0 },
white: { r: 255, g: 255, b: 255 },
gray: { r: 128, g: 128, b: 128 },
grey: { r: 128, g: 128, b: 128 },
silver: { r: 192, g: 192, b: 192 },
dimgray: { r: 105, g: 105, b: 105 },
darkgray: { r: 169, g: 169, b: 169 },
lightgray: { r: 211, g: 211, b: 211 },
gainsboro: { r: 220, g: 220, b: 220 },
whitesmoke: { r: 245, g: 245, b: 245 },
red: { r: 255, g: 0, b: 0 },
crimson: { r: 220, g: 20, b: 60 },
tomato: { r: 255, g: 99, b: 71 },
coral: { r: 255, g: 127, b: 80 },
salmon: { r: 250, g: 128, b: 114 },
orange: { r: 255, g: 165, b: 0 },
gold: { r: 255, g: 215, b: 0 },
yellow: { r: 255, g: 255, b: 0 },
olive: { r: 128, g: 128, b: 0 },
lime: { r: 0, g: 255, b: 0 },
green: { r: 0, g: 128, b: 0 },
teal: { r: 0, g: 128, b: 128 },
turquoise: { r: 64, g: 224, b: 208 },
cyan: { r: 0, g: 255, b: 255 },
aqua: { r: 0, g: 255, b: 255 },
skyblue: { r: 135, g: 206, b: 235 },
dodgerblue: { r: 30, g: 144, b: 255 },
blue: { r: 0, g: 0, b: 255 },
navy: { r: 0, g: 0, b: 128 },
indigo: { r: 75, g: 0, b: 130 },
rebeccapurple: { r: 102, g: 51, b: 153 },
purple: { r: 128, g: 0, b: 128 },
violet: { r: 238, g: 130, b: 238 },
orchid: { r: 218, g: 112, b: 214 },
magenta: { r: 255, g: 0, b: 255 },
fuchsia: { r: 255, g: 0, b: 255 },
hotpink: { r: 255, g: 105, b: 180 },
pink: { r: 255, g: 192, b: 203 },
maroon: { r: 128, g: 0, b: 0 },
};
// Split a string on top-level commas (ignoring commas nested in parens).
function splitTopLevelCommas(str) {
const parts = [];
let depth = 0, start = 0;
for (let i = 0; i < str.length; i++) {
const ch = str[i];
if (ch === '(') depth++;
else if (ch === ')') depth = Math.max(0, depth - 1);
else if (ch === ',' && depth === 0) {
parts.push(str.slice(start, i).trim());
start = i + 1;
}
}
const tail = str.slice(start).trim();
if (tail) parts.push(tail);
return parts;
}
// Evaluate a CSS color-mix() expression to {r,g,b,a}. Returns null when
// the expression can't be resolved (unresolved var(), unknown colors).
//
// Mixing is done with premultiplied alpha in sRGB regardless of the
// declared interpolation space. That is exact for the dominant generated-UI
// pattern — `color-mix(in oklab, <color> N%, transparent)` — where the
// result is simply <color> at alpha N% in ANY rectangular space, and a
// close-enough approximation for opaque-opaque mixes (the detector only
// consumes these values for contrast/chroma thresholds, not for display).
function parseColorMix(str) {
const m = String(str).trim().match(/^color-mix\(/i);
if (!m) return null;
// Balanced-paren capture of the arguments.
let depth = 0, end = -1;
const open = str.indexOf('(');
for (let i = open; i < str.length; i++) {
if (str[i] === '(') depth++;
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
}
if (end < 0) return null;
const args = splitTopLevelCommas(str.slice(open + 1, end));
if (args.length !== 3 || !/^in\s/i.test(args[0])) return null;
const parseComponent = (component) => {
// Percentage may lead or trail the color per spec.
let pct = null;
let colorStr = component;
const trail = component.match(/\s+([\d.]+)%$/);
const lead = component.match(/^([\d.]+)%\s+/);
if (trail) { pct = parseFloat(trail[1]); colorStr = component.slice(0, trail.index).trim(); }
else if (lead) { pct = parseFloat(lead[1]); colorStr = component.slice(lead[0].length).trim(); }
let color;
if (/^transparent$/i.test(colorStr)) color = { r: 0, g: 0, b: 0, a: 0 };
else color = parseAnyColor(colorStr);
if (!color) return null;
return { color, pct };
};
const c1 = parseComponent(args[1]);
const c2 = parseComponent(args[2]);
if (!c1 || !c2) return null;
let p1 = c1.pct, p2 = c2.pct;
if (p1 == null && p2 == null) { p1 = 50; p2 = 50; }
else if (p1 == null) p1 = 100 - p2;
else if (p2 == null) p2 = 100 - p1;
const sum = p1 + p2;
if (sum <= 0) return null;
// Per spec: weights normalize to sum; when sum < 100 the result alpha is
// additionally scaled by sum/100.
const w1 = p1 / sum, w2 = p2 / sum;
const alphaScale = sum < 100 ? sum / 100 : 1;
const a1 = c1.color.a ?? 1, a2 = c2.color.a ?? 1;
const a = (a1 * w1 + a2 * w2) * alphaScale;
if (a <= 0) return { r: 0, g: 0, b: 0, a: 0 };
const mix = (ch) => Math.round((c1.color[ch] * a1 * w1 + c2.color[ch] * a2 * w2) / (a1 * w1 + a2 * w2));
return { r: mix('r'), g: mix('g'), b: mix('b'), a: Math.min(1, a) };
}
// Composite a translucent color over an opaque(ish) base (simple
// source-over in sRGB). Returns an opaque {r,g,b,a:1}.
function compositeColorOver(top, base) {
const a = top.a ?? 1;
return {
r: Math.round(top.r * a + base.r * (1 - a)),
g: Math.round(top.g * a + base.g * (1 - a)),
b: Math.round(top.b * a + base.b * (1 - a)),
a: 1,
};
}
// A color() / lab() / lch() component: a bare number, a percentage against
// `scale`, or the `none` keyword (which resolves to zero for our purposes).
function parseColorComponent(token, scale = 1) {
if (token == null) return null;
const t = String(token).trim();
if (/^none$/i.test(t)) return 0;
const num = parseFloat(t);
if (!Number.isFinite(num)) return null;
return t.endsWith('%') ? (num / 100) * scale : num;
}
function parseAlphaToken(token) {
if (token == null) return 1;
const t = String(token).trim();
if (/^none$/i.test(t)) return 1;
const num = parseFloat(t);
if (!Number.isFinite(num)) return 1;
return t.endsWith('%') ? num / 100 : num;
}
// Extended color parser: rgb/rgba/hex/oklch/oklab/lch/lab/hsl/hwb/color()/
// color-mix/common named colors. Returns null on no match. Use this when the
// input might be any CSS color form; use plain parseRgb when you only expect
// computed rgb() values from real browsers.
function parseAnyColor(s) {
if (!s || typeof s !== 'string') return null;
const str = s.trim();
if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
if (/^color-mix\(/i.test(str)) return parseColorMix(str);
let m;
m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/);
if (m) {
const c = { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: 1 };
if (m[4] !== undefined) c.a = m[5] === '%' ? parseFloat(m[4]) / 100 : +m[4];
return c;
}
m = str.match(/^#([0-9a-f]{3,8})$/i);
if (m) {
const h = m[1];
if (h.length === 3 || h.length === 4) {
return {
r: parseInt(h[0] + h[0], 16),
g: parseInt(h[1] + h[1], 16),
b: parseInt(h[2] + h[2], 16),
a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
};
}
if (h.length === 6 || h.length === 8) {
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
};
}
}
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
// Match L (with optional %), then C and H separated permissively.
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const Lnum = parseFloat(m[1]);
const L = m[2] === '%' ? Lnum / 100 : Lnum;
const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
if (m[5] !== undefined) {
const alpha = parseFloat(m[5]);
rgb.a = m[6] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// OKLAB — a/b are signed axes; percentages map 100% → 0.4.
m = str.match(/oklab\(\s*([\d.]+)(%?)\s+(-?[\d.]+)(%?)\s+(-?[\d.]+)(%?)(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const L = m[2] === '%' ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
const a = m[4] === '%' ? parseFloat(m[3]) * 0.004 : parseFloat(m[3]);
const b = m[6] === '%' ? parseFloat(m[5]) * 0.004 : parseFloat(m[5]);
const rgb = oklabToRgb(L, a, b);
if (m[7] !== undefined) {
const alpha = parseFloat(m[7]);
rgb.a = m[8] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// LCH / LAB — CIE, D50 white point. Chrome serializes lch(20% 5 60) as
// `lch(20 5 60)`, so L arrives with or without its percent sign. In both
// spaces L runs 0..100 and 100% means 100.
m = str.match(/^lch\(\s*([\d.]+%?|none)\s+([\d.]+%?|none)\s+(-?[\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
if (m) {
const L = parseColorComponent(m[1], 100);
const C = parseColorComponent(m[2], 150);
const H = parseFloat(m[3]);
if (L == null || C == null || !Number.isFinite(H)) return null;
const rgb = lchToRgb(L, C, H);
rgb.a = parseAlphaToken(m[4]);
return rgb;
}
m = str.match(/^lab\(\s*([\d.]+%?|none)\s+(-?[\d.]+%?|none)\s+(-?[\d.]+%?|none)(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
if (m) {
const L = parseColorComponent(m[1], 100);
const a = parseColorComponent(m[2], 125);
const b = parseColorComponent(m[3], 125);
if (L == null || a == null || b == null) return null;
const rgb = labToRgb(L, a, b);
rgb.a = parseAlphaToken(m[4]);
return rgb;
}
// color(<space> c1 c2 c3 [/ alpha]) — what Chrome hands back for most
// color-mix() results and for any wide-gamut color an author wrote.
m = str.match(/^color\(\s*([a-z0-9-]+)\s+(-?[\d.eE+-]+%?|none)\s+(-?[\d.eE+-]+%?|none)\s+(-?[\d.eE+-]+%?|none)(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
if (m) {
const c1 = parseColorComponent(m[2]);
const c2 = parseColorComponent(m[3]);
const c3 = parseColorComponent(m[4]);
if (c1 == null || c2 == null || c3 == null) return null;
const rgb = colorFunctionToRgb(m[1].toLowerCase(), c1, c2, c3);
if (!rgb) return null;
rgb.a = parseAlphaToken(m[5]);
return rgb;
}
// HSL/HSLA — comma or space syntax, optional deg on hue.
m = str.match(/hsla?\(\s*(-?[\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%\s*[,\s]\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hslToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// HWB — hue whiteness% blackness%.
m = str.match(/hwb\(\s*(-?[\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hwbToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
const named = CSS_NAMED_COLORS[str.toLowerCase()];
if (named) return { ...named, a: 1 };
return null;
}
// True when a computed background-color string names no paint at all. Used to
// tell "this layer is see-through" (walk on to the ancestor) apart from "this
// layer has a color we could not read" (stop and abstain).
//
// `inherit` belongs here even though it is not literally see-through: it means
// "paint with the parent's background-color", and walking on to the parent IS
// that resolution. Real browsers resolve the keyword before getComputedStyle
// output; only jsdom's partial cascade hands it through verbatim, and treating
// it as unreadable would make the walk abstain on a surface it can know.
// (`currentcolor` is NOT here — it is real paint in the element's own text
// color; resolveBackgroundInfo substitutes the computed color for it.)
function isNoPaintColorValue(value) {
const v = String(value || '').trim().toLowerCase();
if (!v) return true;
return v === 'transparent' || v === 'none' || v === 'initial' || v === 'inherit' || v === 'unset' || v === 'revert' || v === 'revert-layer';
}
export {
isNeutralColor,
parseRgb,
relativeLuminance,
contrastRatio,
parseGradientColors,
extractColorFunctionTokens,
hasChroma,
getHue,
colorToHex,
oklabToRgb,
oklchToRgb,
labToRgb,
lchToRgb,
colorFunctionToRgb,
hslToRgb,
hwbToRgb,
CSS_NAMED_COLORS,
splitTopLevelCommas,
parseColorMix,
parseAnyColor,
compositeColorOver,
isNoPaintColorValue,
};
File diff suppressed because it is too large Load Diff
@@ -11,14 +11,21 @@ import {
isBrandFontOnOwnDomain,
} from '../shared/constants.mjs';
import {
CSS_NAMED_COLORS,
colorToHex,
compositeColorOver,
contrastRatio,
getHue,
hasChroma,
isNeutralColor,
isNoPaintColorValue,
oklchToRgb,
parseAnyColor,
parseColorMix,
parseGradientColors,
parseRgb,
relativeLuminance,
splitTopLevelCommas,
} from '../shared/color.mjs';
import { extractGoogleFontFamilies } from '../shared/fonts.mjs';
@@ -1731,7 +1738,7 @@ function readOwnBackgroundColor(el, computedStyle) {
// One element's background-color as the cascade walk sees it: computed style
// first (with the modern-color fallback), then, in static mode only,
// custom-prop resolution and the inline-shorthand peek. Shared by
// resolveBackground and resolveGradientStops so both walks read the same
// resolveBackgroundInfo and resolveGradientStops so both walks read the same
// surfaces.
function readCascadeBackgroundColor(current, style, customPropMap) {
let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
@@ -1755,7 +1762,19 @@ function readCascadeBackgroundColor(current, style, customPropMap) {
return bg;
}
function resolveBackground(el, win, customPropMap) {
// Walk up for the surface the element's text is painted on.
//
// Returns { color, unresolved }:
// • color set — the effective surface, overlays composited in.
// • unresolved: true — a layer on the way up paints a color this parser
// cannot read, so the surface is unknown. Callers
// must SKIP their contrast checks. Guessing white
// here is what flooded dark themes with false
// "on #ffffff" findings: one abstention costs a
// single finding, one wrong guess costs a hundred.
// • both null/false — no solid color, but a gradient or image is in
// play; callers fall back to its color stops.
function resolveBackgroundInfo(el, win, customPropMap) {
let current = el;
// Translucent layers (0.1 < a < 1) found on the way down to an opaque
// base. A browser composites these over the base; the old behavior
@@ -1783,59 +1802,81 @@ function resolveBackground(el, win, customPropMap) {
// body backgrounds.
// Real browsers serialize wide-gamut computed values as oklab()/oklch()
// (e.g. any color-mix() result), which plain parseRgb misses.
const bg = readCascadeBackgroundColor(current, style, customPropMap);
let bg = readCascadeBackgroundColor(current, style, customPropMap);
// `background-color: currentcolor` paints with the element's own text
// color — real paint whose value we know. Real browsers resolve the
// keyword before getComputedStyle output; jsdom hands it through
// verbatim, and without this substitution the layer would read as
// unparseable and force a needless abstention.
if ((!bg || bg.a < 0.1) && /^currentcolor$/i.test(String(style.backgroundColor || '').trim())) {
// The static cascade resolves var() text tokens before checks run, so
// style.color is normally already an rgb string here; parseColorResolved
// is defense in depth for any future caller that passes a live
// customPropMap (it matches the text-color path in checkElementColors
// and reduces to parseAnyColor when the map is null or absent).
bg = parseRgb(style.color) || parseColorResolved(style.color, customPropMap);
}
if (bg && bg.a > 0.1) {
if (bg.a >= 0.99) return flatten(bg);
if (bg.a >= 0.99) return { color: flatten(bg), unresolved: false };
overlays.push(bg);
} else if (!bg && !isNoPaintColorValue(style.backgroundColor)) {
// This layer names a color we could not parse (a color space we do not
// model, an unresolved var(), a syntax newer than the parser). It may
// well be opaque, which would make every ancestor below it invisible —
// so the surface is unknown and the walk stops here rather than
// reporting an ancestor the visitor never sees.
return { color: null, unresolved: true };
}
// No solid bg-color at this level, but this level paints an image. CSS
// stacks background-image layers first-on-top, so which layer leads
// decides what the visitor sees:
// • gradient on top — the gradient is the surface. Hand the caller a
// null color so it falls back to the gradient's own stops (body
// grounds, gradient buttons, hero sections).
// • url() on top — the surface is an image whose pixels this engine
// cannot read, and it may fully cover every layer and ancestor
// beneath it. Same contract as an unparseable color: abstain, so
// the gradient-stop fallback never measures a gradient the image
// hides (the shipped miss: `url(photo), linear-gradient(...)`
// reported low-contrast against the invisible gradient's stops).
if (hasGradientOrUrl) {
const layers = splitTopLevelCommas(bgImage);
const topPaintLayer = layers.find(
(layer) => /gradient\s*\(/i.test(layer) || /url\s*\(/i.test(layer),
);
const gradientOnTop = !!topPaintLayer
&& /gradient\s*\(/i.test(topPaintLayer)
&& !/^\s*url\s*\(/i.test(topPaintLayer);
if (!gradientOnTop) return { color: null, unresolved: true };
// Gradient on top of a url() layer: the image shows through wherever
// the gradient is not fully opaque, so a translucent wash like
// `linear-gradient(rgba(0,0,0,.2), rgba(0,0,0,.2)), url(photo)` paints
// a blend with pixels this engine cannot read. Only a gradient whose
// every readable stop is opaque provably covers the image; otherwise
// the surface is unknown — abstain rather than hand callers gradient
// stops (or a stop average) the visitor never sees unmixed.
const urlBeneath = layers.some(
(layer) => layer !== topPaintLayer && /url\s*\(/i.test(layer),
);
if (urlBeneath) {
const topStops = parseGradientColors(topPaintLayer);
const provablyOpaque = topStops.length > 0 && topStops.every((s) => (s.a ?? 1) >= 0.99);
if (!provablyOpaque) return { color: null, unresolved: true };
}
return { color: null, unresolved: false };
}
// No solid bg-color at this level. If THIS level has a gradient/url
// with no underlying solid color we can read:
// • on body/html: assume white. Body-level gradients are almost
// always decorative texture (paper grain, noise) on top of a
// solid bg-color the page set via `background: var(--paper)`
// shorthand — which jsdom can't decompose into bg-color. The
// downstream gradient-stops fallback path produces catastrophic
// false positives in this case (gradient noise stops have
// accidental browns/blacks that look like card backgrounds).
// • on other elements: bail to null and let the caller fall back
// to gradient stops (gradient buttons / hero sections are real
// bgs worth checking against).
// A gradient or image with no solid color under it, at any level
// including body/html, means the visible ground is that layer itself.
// Return null so the caller measures against the actual gradient stops,
// or skips when nothing is parseable — skipping beats a wrong ratio.
//
// Body/html used to assume white here, a guard written for jsdom, which
// never decomposed the `background:` shorthand and so could not see the
// solid paper color a texture gradient usually sits on. It turned every
// light-on-dark page into a wall of low-contrast false positives (a dark
// oklch body gradient produced ~120 "on #ffffff" findings on one site).
// Both engines can see shorthand solids now — the browser natively, the
// static cascade via expandStaticDeclaration + var() resolution — so a
// missing solid is real, and the old failure case cannot recur: opaque
// stops fully cover any hidden solid (they ARE the ground), alpha stops
// composite over the resolved base or the white canvas default, and
// unresolvable stops drop rather than guess.
if (hasGradientOrUrl) return null;
current = current.parentElement;
}
return flatten({ r: 255, g: 255, b: 255, a: 1 });
// Every layer up to the document root was genuinely see-through, so the
// browser paints its default canvas. This is the ONLY case that earns the
// white assumption.
return { color: flatten({ r: 255, g: 255, b: 255, a: 1 }), unresolved: false };
}
// parseGradientColors (shared) reads only the legacy serializations: rgb()
// and hex stops. Browsers keep modern-space stops in computed backgroundImage
// exactly as authored — `linear-gradient(oklch(7% 0.006 95), …)` stays oklch —
// which is what every token-driven page produces. Route those through
// parseAnyColor so a gradient ground is measurable rather than invisible.
function parseGradientColorsModern(bgImage) {
if (!bgImage || !/gradient/i.test(bgImage)) return [];
const colors = parseGradientColors(bgImage);
for (const m of bgImage.matchAll(/(?:oklch|oklab|hsla?|hwb)\(\s*[^()]*\)/gi)) {
const c = parseAnyColor(m[0]);
if (c) colors.push(c);
}
return colors;
function resolveBackground(el, win, customPropMap) {
return resolveBackgroundInfo(el, win, customPropMap).color;
}
// Walk parents looking for a gradient background and return its color stops.
@@ -1861,7 +1902,10 @@ function resolveGradientStops(el, win, customPropMap) {
if (bgImage && bgImage !== 'none' && /url\s*\(/i.test(bgImage)) return null;
let stops = null;
if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
const parsed = parseGradientColorsModern(bgImage);
// parseGradientColors (shared) reads modern-space stops too — oklch,
// color-mix and friends via balanced-paren token capture — so browser
// computed values that keep the authored syntax stay measurable.
const parsed = parseGradientColors(bgImage);
if (parsed.length > 0) stops = parsed;
}
if (!stops && !DETECTOR_IS_BROWSER) {
@@ -1869,7 +1913,7 @@ function resolveGradientStops(el, win, customPropMap) {
const rawStyle = current.getAttribute?.('style') || '';
const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
if (bgMatch && /gradient/i.test(bgMatch[1])) {
const parsed = parseGradientColorsModern(bgMatch[1]);
const parsed = parseGradientColors(bgMatch[1]);
if (parsed.length > 0) stops = parsed;
}
}
@@ -2115,13 +2159,19 @@ function checkElementColorsDOM(el) {
if (style.visibility === 'hidden' || effectiveOpacityDOM(el) <= 0.02) return [];
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
let effectiveBg = resolveBackground(el);
const bgInfo = resolveBackgroundInfo(el);
let effectiveBg = bgInfo.color;
// An unreadable surface anywhere up the chain: skip the gradient-stop
// fallback too, so nothing downstream measures against a ground we never
// resolved.
let surfaceUnresolved = bgInfo.unresolved;
let ownBg = readOwnBackgroundColor(el, style);
if (!ownBg || (ownBg.a ?? 1) <= 0.5) {
const pseudoSurface = readPseudoSurfaceDOM(el, rect);
if (pseudoSurface) {
ownBg = pseudoSurface;
effectiveBg = pseudoSurface;
surfaceUnresolved = false;
}
}
return checkColors({
@@ -2133,8 +2183,8 @@ function checkElementColorsDOM(el) {
// an oklch token near its own oklch background).
textColor: parseRgb(style.color) || parseAnyColor(style.color),
bgColor: ownBg,
effectiveBg,
effectiveBgStops: effectiveBg ? null : resolveGradientStops(el),
effectiveBg: surfaceUnresolved ? null : effectiveBg,
effectiveBgStops: surfaceUnresolved || effectiveBg ? null : resolveGradientStops(el),
fontSize: parseFloat(style.fontSize) || 16,
fontWeight: parseInt(style.fontWeight) || 400,
hasDirectText,
@@ -2284,283 +2334,6 @@ function resolveVarRefs(raw, customPropMap, depth = 0) {
});
}
// OKLCH → sRGB conversion (Björn Ottosson's matrices). L in 0..1 (or %),
// C in 0..~0.4 typical, H in degrees. Returns clamped {r,g,b,a:1} in 0..255.
// Needed because jsdom doesn't compute oklch() values — getComputedStyle
// returns the literal "oklch(...)" string. Without this, the entire
// Tailwind v4 color palette (which is OKLCH-based) is invisible to the
// detector's contrast / color checks.
function oklchToRgb(L, C, H) {
const hRad = (H * Math.PI) / 180;
return oklabToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
}
function oklabToRgb(L, a, b) {
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
const rLin = 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc;
const gLin = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc;
const bLin = -0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc;
const enc = (x) => {
const c = Math.max(0, Math.min(1, x));
return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
};
return {
r: Math.round(enc(rLin) * 255),
g: Math.round(enc(gLin) * 255),
b: Math.round(enc(bLin) * 255),
a: 1,
};
}
function hslToRgb(h, s, l) {
h = ((h % 360) + 360) % 360;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m0 = l - c / 2;
const [r, g, b] =
h < 60 ? [c, x, 0] :
h < 120 ? [x, c, 0] :
h < 180 ? [0, c, x] :
h < 240 ? [0, x, c] :
h < 300 ? [x, 0, c] : [c, 0, x];
return {
r: Math.round((r + m0) * 255),
g: Math.round((g + m0) * 255),
b: Math.round((b + m0) * 255),
a: 1,
};
}
function hwbToRgb(h, w, bl) {
if (w + bl >= 1) {
const g = Math.round((w / (w + bl)) * 255);
return { r: g, g, b: g, a: 1 };
}
const base = hslToRgb(h, 1, 0.5);
const mix = (c) => Math.round(((c / 255) * (1 - w - bl) + w) * 255);
return { r: mix(base.r), g: mix(base.g), b: mix(base.b), a: 1 };
}
// Common CSS named colors — the handful that actually show up in generated
// UIs, not the full 148-name spec list. Includes the achromatic names so a
// named gray parses (and correctly reads as no-chroma) instead of being
// treated as an unknown color.
const CSS_NAMED_COLORS = {
black: { r: 0, g: 0, b: 0 },
white: { r: 255, g: 255, b: 255 },
gray: { r: 128, g: 128, b: 128 },
grey: { r: 128, g: 128, b: 128 },
silver: { r: 192, g: 192, b: 192 },
dimgray: { r: 105, g: 105, b: 105 },
darkgray: { r: 169, g: 169, b: 169 },
lightgray: { r: 211, g: 211, b: 211 },
gainsboro: { r: 220, g: 220, b: 220 },
whitesmoke: { r: 245, g: 245, b: 245 },
red: { r: 255, g: 0, b: 0 },
crimson: { r: 220, g: 20, b: 60 },
tomato: { r: 255, g: 99, b: 71 },
coral: { r: 255, g: 127, b: 80 },
salmon: { r: 250, g: 128, b: 114 },
orange: { r: 255, g: 165, b: 0 },
gold: { r: 255, g: 215, b: 0 },
yellow: { r: 255, g: 255, b: 0 },
olive: { r: 128, g: 128, b: 0 },
lime: { r: 0, g: 255, b: 0 },
green: { r: 0, g: 128, b: 0 },
teal: { r: 0, g: 128, b: 128 },
turquoise: { r: 64, g: 224, b: 208 },
cyan: { r: 0, g: 255, b: 255 },
aqua: { r: 0, g: 255, b: 255 },
skyblue: { r: 135, g: 206, b: 235 },
dodgerblue: { r: 30, g: 144, b: 255 },
blue: { r: 0, g: 0, b: 255 },
navy: { r: 0, g: 0, b: 128 },
indigo: { r: 75, g: 0, b: 130 },
rebeccapurple: { r: 102, g: 51, b: 153 },
purple: { r: 128, g: 0, b: 128 },
violet: { r: 238, g: 130, b: 238 },
orchid: { r: 218, g: 112, b: 214 },
magenta: { r: 255, g: 0, b: 255 },
fuchsia: { r: 255, g: 0, b: 255 },
hotpink: { r: 255, g: 105, b: 180 },
pink: { r: 255, g: 192, b: 203 },
maroon: { r: 128, g: 0, b: 0 },
};
// Split a string on top-level commas (ignoring commas nested in parens).
function splitTopLevelCommas(str) {
const parts = [];
let depth = 0, start = 0;
for (let i = 0; i < str.length; i++) {
const ch = str[i];
if (ch === '(') depth++;
else if (ch === ')') depth = Math.max(0, depth - 1);
else if (ch === ',' && depth === 0) {
parts.push(str.slice(start, i).trim());
start = i + 1;
}
}
const tail = str.slice(start).trim();
if (tail) parts.push(tail);
return parts;
}
// Evaluate a CSS color-mix() expression to {r,g,b,a}. Returns null when
// the expression can't be resolved (unresolved var(), unknown colors).
//
// Mixing is done with premultiplied alpha in sRGB regardless of the
// declared interpolation space. That is exact for the dominant generated-UI
// pattern — `color-mix(in oklab, <color> N%, transparent)` — where the
// result is simply <color> at alpha N% in ANY rectangular space, and a
// close-enough approximation for opaque-opaque mixes (the detector only
// consumes these values for contrast/chroma thresholds, not for display).
function parseColorMix(str) {
const m = String(str).trim().match(/^color-mix\(/i);
if (!m) return null;
// Balanced-paren capture of the arguments.
let depth = 0, end = -1;
const open = str.indexOf('(');
for (let i = open; i < str.length; i++) {
if (str[i] === '(') depth++;
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
}
if (end < 0) return null;
const args = splitTopLevelCommas(str.slice(open + 1, end));
if (args.length !== 3 || !/^in\s/i.test(args[0])) return null;
const parseComponent = (component) => {
// Percentage may lead or trail the color per spec.
let pct = null;
let colorStr = component;
const trail = component.match(/\s+([\d.]+)%$/);
const lead = component.match(/^([\d.]+)%\s+/);
if (trail) { pct = parseFloat(trail[1]); colorStr = component.slice(0, trail.index).trim(); }
else if (lead) { pct = parseFloat(lead[1]); colorStr = component.slice(lead[0].length).trim(); }
let color;
if (/^transparent$/i.test(colorStr)) color = { r: 0, g: 0, b: 0, a: 0 };
else color = parseAnyColor(colorStr);
if (!color) return null;
return { color, pct };
};
const c1 = parseComponent(args[1]);
const c2 = parseComponent(args[2]);
if (!c1 || !c2) return null;
let p1 = c1.pct, p2 = c2.pct;
if (p1 == null && p2 == null) { p1 = 50; p2 = 50; }
else if (p1 == null) p1 = 100 - p2;
else if (p2 == null) p2 = 100 - p1;
const sum = p1 + p2;
if (sum <= 0) return null;
// Per spec: weights normalize to sum; when sum < 100 the result alpha is
// additionally scaled by sum/100.
const w1 = p1 / sum, w2 = p2 / sum;
const alphaScale = sum < 100 ? sum / 100 : 1;
const a1 = c1.color.a ?? 1, a2 = c2.color.a ?? 1;
const a = (a1 * w1 + a2 * w2) * alphaScale;
if (a <= 0) return { r: 0, g: 0, b: 0, a: 0 };
const mix = (ch) => Math.round((c1.color[ch] * a1 * w1 + c2.color[ch] * a2 * w2) / (a1 * w1 + a2 * w2));
return { r: mix('r'), g: mix('g'), b: mix('b'), a: Math.min(1, a) };
}
// Composite a translucent color over an opaque(ish) base (simple
// source-over in sRGB). Returns an opaque {r,g,b,a:1}.
function compositeColorOver(top, base) {
const a = top.a ?? 1;
return {
r: Math.round(top.r * a + base.r * (1 - a)),
g: Math.round(top.g * a + base.g * (1 - a)),
b: Math.round(top.b * a + base.b * (1 - a)),
a: 1,
};
}
// Extended color parser: rgb/rgba/hex/oklch/oklab/hsl/hwb/color-mix/common
// named colors. Returns null on no match. Use this when the input might be
// any CSS color form; use plain parseRgb when you only expect computed rgb()
// values from real browsers.
function parseAnyColor(s) {
if (!s || typeof s !== 'string') return null;
const str = s.trim();
if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
if (/^color-mix\(/i.test(str)) return parseColorMix(str);
let m;
m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+))?\s*\)/);
if (m) return { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: m[4] !== undefined ? +m[4] : 1 };
m = str.match(/^#([0-9a-f]{3,8})$/i);
if (m) {
const h = m[1];
if (h.length === 3 || h.length === 4) {
return {
r: parseInt(h[0] + h[0], 16),
g: parseInt(h[1] + h[1], 16),
b: parseInt(h[2] + h[2], 16),
a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
};
}
if (h.length === 6 || h.length === 8) {
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
};
}
}
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
// Match L (with optional %), then C and H separated permissively.
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const Lnum = parseFloat(m[1]);
const L = m[2] === '%' ? Lnum / 100 : Lnum;
const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
if (m[5] !== undefined) {
const alpha = parseFloat(m[5]);
rgb.a = m[6] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// OKLAB — a/b are signed axes; percentages map 100% → 0.4.
m = str.match(/oklab\(\s*([\d.]+)(%?)\s+(-?[\d.]+)(%?)\s+(-?[\d.]+)(%?)(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const L = m[2] === '%' ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
const a = m[4] === '%' ? parseFloat(m[3]) * 0.004 : parseFloat(m[3]);
const b = m[6] === '%' ? parseFloat(m[5]) * 0.004 : parseFloat(m[5]);
const rgb = oklabToRgb(L, a, b);
if (m[7] !== undefined) {
const alpha = parseFloat(m[7]);
rgb.a = m[8] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// HSL/HSLA — comma or space syntax, optional deg on hue.
m = str.match(/hsla?\(\s*(-?[\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%\s*[,\s]\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hslToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// HWB — hue whiteness% blackness%.
m = str.match(/hwb\(\s*(-?[\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hwbToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
const named = CSS_NAMED_COLORS[str.toLowerCase()];
if (named) return { ...named, a: 1 };
return null;
}
// Resolve var() refs in a color string (via customPropMap), then parse.
// Returns null on any failure. Used in jsdom-mode paths where
@@ -2923,15 +2696,24 @@ function checkElementGlowDOM(el) {
if (!boxShadow && !textShadow) return [];
// Use parent's background — glow radiates outward, so the surrounding context matters
// If resolveBackground returns null (gradient), try to infer from the gradient colors
let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el);
if (!parentBg) {
const parentBgInfo = resolveBackgroundInfo(el.parentElement || el);
// Unknown surface (an unreadable layer on the way up): skip only the
// gradient hunt below, which would walk PAST that layer and score the
// glow against a background the visitor never sees. checkGlow still runs
// with a null surface: the zero-offset chromatic halo tell holds on ANY
// background, and the static loop already passes the unresolved walk's
// null color straight through (detect-html.mjs uses resolveBackground).
let parentBg = parentBgInfo.color;
if (!parentBg && !parentBgInfo.unresolved) {
// Gradient background — sample its colors to determine if it's dark.
// Modern-syntax parsing matters here: body-level gradients now reach this
// fallback in browser mode, and their stops usually serialize as oklch.
// fallback in browser mode, and their stops usually serialize as oklch
// which the shared parseGradientColors reads via its color-function
// token capture.
let cur = el.parentElement;
while (cur && cur.nodeType === 1) {
const bgImage = getComputedStyle(cur).backgroundImage || '';
const gradColors = parseGradientColorsModern(bgImage);
const gradColors = parseGradientColors(bgImage);
if (gradColors.length > 0) {
// Average the gradient colors
const avg = { r: 0, g: 0, b: 0 };
@@ -2975,10 +2757,13 @@ function checkElementAIPaletteDOM(el) {
const hue = getHue(textColor);
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
if (isAIPalette) {
const parentBg = el.parentElement ? resolveBackground(el.parentElement) : null;
// Also check gradient parents
let effectiveBg = parentBg;
if (!effectiveBg) {
const parentBgInfo = el.parentElement
? resolveBackgroundInfo(el.parentElement)
: { color: null, unresolved: false };
// Unknown surface: leave effectiveBg null (no finding) rather than
// hunting gradient ancestors past a layer we could not read.
let effectiveBg = parentBgInfo.color;
if (!effectiveBg && !parentBgInfo.unresolved) {
let cur = el.parentElement;
while (cur && cur.nodeType === 1) {
const gi = getComputedStyle(cur).backgroundImage || '';
@@ -3784,7 +3569,8 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
const effectiveBg = resolveBackground(el, window, customPropMap);
const bgInfo = resolveBackgroundInfo(el, window, customPropMap);
const effectiveBg = bgInfo.color;
// jsdom returns literal "var(--X)" / "oklch(...)" for color, so plain
// parseRgb misses Tailwind-tokenized text colors. Resolve through the
// customPropMap first; fall back to parseRgb for vanilla rgb() pages.
@@ -3830,11 +3616,13 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
// element itself has no usable own background, that pseudo is the real
// surface for contrast purposes.
let finalEffectiveBg = effectiveBg;
let surfaceUnresolved = bgInfo.unresolved;
if ((!ownBg || (ownBg.a ?? 1) <= 0.5) && typeof window.getPseudoSurface === 'function') {
const pseudoSurface = window.getPseudoSurface(el);
if (pseudoSurface) {
ownBg = pseudoSurface;
finalEffectiveBg = pseudoSurface;
surfaceUnresolved = false;
}
}
@@ -3842,8 +3630,9 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
tag,
textColor,
bgColor: ownBg,
effectiveBg: finalEffectiveBg,
effectiveBgStops: finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
// Unknown surface: hand the checks nothing rather than a guess.
effectiveBg: surfaceUnresolved ? null : finalEffectiveBg,
effectiveBgStops: surfaceUnresolved || finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
fontSize: parseFloat(style.fontSize) || 16,
fontWeight: parseInt(style.fontWeight) || 400,
hasDirectText,
@@ -5663,6 +5452,7 @@ export {
checkHtmlPatterns,
readOwnBackgroundColor,
resolveBackground,
resolveBackgroundInfo,
resolveGradientStops,
parseRadiusToPx,
resolveBorderRadiusPx,
@@ -71,11 +71,43 @@ function contrastRatio(c1, c2) {
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
// The CSS color functions worth pulling out of a longer declaration. The set
// is deliberately closed: `linear-gradient(` and `url(` also look like
// `name(` and must not be read as colors.
const COLOR_FUNCTION_NAMES = new Set([
'rgb', 'rgba', 'hsl', 'hsla', 'hwb', 'oklch', 'oklab', 'lch', 'lab', 'color', 'color-mix',
]);
// Pull every color-function token out of a value, with balanced-paren capture
// so nested forms (`color-mix(in oklab, oklch(...) 20%, transparent)`) survive
// whole. Returns the raw substrings in source order.
function extractColorFunctionTokens(value) {
const str = String(value || '');
const tokens = [];
const re = /([a-z][a-z-]*)\(/gi;
let m;
while ((m = re.exec(str)) !== null) {
if (!COLOR_FUNCTION_NAMES.has(m[1].toLowerCase())) continue;
let depth = 0, end = -1;
for (let i = m.index + m[0].length - 1; i < str.length; i++) {
if (str[i] === '(') depth++;
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
}
if (end < 0) break;
tokens.push(str.slice(m.index, end + 1));
re.lastIndex = end + 1;
}
return tokens;
}
function parseGradientColors(bgImage) {
if (!bgImage || !bgImage.includes('gradient')) return [];
const colors = [];
for (const m of bgImage.matchAll(/rgba?\([^)]+\)/g)) {
const c = parseRgb(m[0]);
// Stops arrive in whatever syntax the author wrote and the browser kept.
// A dark ground painted as `linear-gradient(oklch(...), oklch(...))` used
// to read as a gradient with no stops at all.
for (const token of extractColorFunctionTokens(bgImage)) {
const c = parseAnyColor(token);
if (c) colors.push(c);
}
for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
@@ -112,13 +144,445 @@ function colorToHex(c) {
return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
}
// ─── Color-space conversions ────────────────────────────────────────────────
//
// Every function here lands on 8-bit sRGB, clamped to gamut. Chrome, Safari,
// and Firefox all keep the authored color space in getComputedStyle output
// (`oklch(0.84 0.19 80.46)`, `lch(20 5 60)`, `color(srgb 1.04 0.72 -0.21)`),
// so a detector that only reads rgb() is blind on any modern palette. The
// expected outputs are pinned in tests/detect-antipatterns.test.js against
// what Chrome itself paints for the same strings.
function clamp01(x) {
return Number.isFinite(x) ? Math.max(0, Math.min(1, x)) : 0;
}
// Linear-light sRGB channel to the encoded 0-255 value.
function encodeSrgbChannel(x) {
const c = clamp01(x);
return Math.round((c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055) * 255);
}
function decodeSrgbChannel(x) {
const c = Number.isFinite(x) ? x : 0;
const sign = c < 0 ? -1 : 1;
const abs = Math.abs(c);
return sign * (abs <= 0.04045 ? abs / 12.92 : Math.pow((abs + 0.055) / 1.055, 2.4));
}
function linearSrgbToColor(r, g, b, a = 1) {
return { r: encodeSrgbChannel(r), g: encodeSrgbChannel(g), b: encodeSrgbChannel(b), a };
}
// OKLab to sRGB (Björn Ottosson's matrices). L in 0..1, a/b are signed axes.
function oklabToRgb(L, a, b) {
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
return linearSrgbToColor(
4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc,
-1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc,
-0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc,
);
}
// OKLCH to sRGB. L in 0..1, C in 0..~0.4 typical, H in degrees. Chroma past
// the sRGB gamut clamps per channel rather than producing NaN.
function oklchToRgb(L, C, H) {
const hRad = (H * Math.PI) / 180;
return oklabToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
}
// CIE Lab to sRGB. CSS lab()/lch() use the D50 white point; the matrix below
// is the Bradford-adapted XYZ-D50 to linear-sRGB transform from CSS Color 4.
function labToRgb(L, a, b) {
const kappa = 24389 / 27, epsilon = 216 / 24389;
const fy = (L + 16) / 116, fx = fy + a / 500, fz = fy - b / 200;
const invert = (t) => (t * t * t > epsilon ? t * t * t : (116 * t - 16) / kappa);
const yr = L > kappa * epsilon ? Math.pow((L + 16) / 116, 3) : L / kappa;
const Xn = 0.3457 / 0.3585, Zn = (1 - 0.3457 - 0.3585) / 0.3585;
const x = invert(fx) * Xn, y = yr, z = invert(fz) * Zn;
return linearSrgbToColor(
3.1341359569958707 * x - 1.6173863321612538 * y - 0.4906619460083532 * z,
-0.9787955029120890 * x + 1.9162545672595240 * y + 0.0334427311613195 * z,
0.0719553798841168 * x - 0.2289768264158322 * y + 1.4053860583241250 * z,
);
}
function lchToRgb(L, C, H) {
const hRad = (H * Math.PI) / 180;
return labToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
}
// color(<space> c1 c2 c3) for the spaces that turn up in real stylesheets.
// `srgb` is what Chrome serializes most color-mix() results into, routinely
// with channels outside 0..1. Spaces we do not model return null so callers
// abstain instead of measuring against a color we invented.
function colorFunctionToRgb(space, c1, c2, c3) {
switch (space) {
case 'srgb':
return { r: Math.round(clamp01(c1) * 255), g: Math.round(clamp01(c2) * 255), b: Math.round(clamp01(c3) * 255), a: 1 };
case 'srgb-linear':
return linearSrgbToColor(c1, c2, c3);
case 'display-p3': {
const [R, G, B] = [decodeSrgbChannel(c1), decodeSrgbChannel(c2), decodeSrgbChannel(c3)];
return linearSrgbToColor(
1.2249401762805587 * R - 0.2249404646817506 * G + 0.0000002884022551 * B,
-0.0420569547096138 * R + 1.0420571661298634 * G - 0.0000002113202247 * B,
-0.0196375587040044 * R - 0.0786360772174755 * G + 1.0982736359214800 * B,
);
}
default:
return null;
}
}
function hslToRgb(h, s, l) {
h = ((h % 360) + 360) % 360;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m0 = l - c / 2;
const [r, g, b] =
h < 60 ? [c, x, 0] :
h < 120 ? [x, c, 0] :
h < 180 ? [0, c, x] :
h < 240 ? [0, x, c] :
h < 300 ? [x, 0, c] : [c, 0, x];
return {
r: Math.round((r + m0) * 255),
g: Math.round((g + m0) * 255),
b: Math.round((b + m0) * 255),
a: 1,
};
}
function hwbToRgb(h, w, bl) {
if (w + bl >= 1) {
const g = Math.round((w / (w + bl)) * 255);
return { r: g, g, b: g, a: 1 };
}
const base = hslToRgb(h, 1, 0.5);
const mix = (c) => Math.round(((c / 255) * (1 - w - bl) + w) * 255);
return { r: mix(base.r), g: mix(base.g), b: mix(base.b), a: 1 };
}
// Common CSS named colors — the handful that actually show up in generated
// UIs, not the full 148-name spec list. Includes the achromatic names so a
// named gray parses (and correctly reads as no-chroma) instead of being
// treated as an unknown color.
const CSS_NAMED_COLORS = {
black: { r: 0, g: 0, b: 0 },
white: { r: 255, g: 255, b: 255 },
gray: { r: 128, g: 128, b: 128 },
grey: { r: 128, g: 128, b: 128 },
silver: { r: 192, g: 192, b: 192 },
dimgray: { r: 105, g: 105, b: 105 },
darkgray: { r: 169, g: 169, b: 169 },
lightgray: { r: 211, g: 211, b: 211 },
gainsboro: { r: 220, g: 220, b: 220 },
whitesmoke: { r: 245, g: 245, b: 245 },
red: { r: 255, g: 0, b: 0 },
crimson: { r: 220, g: 20, b: 60 },
tomato: { r: 255, g: 99, b: 71 },
coral: { r: 255, g: 127, b: 80 },
salmon: { r: 250, g: 128, b: 114 },
orange: { r: 255, g: 165, b: 0 },
gold: { r: 255, g: 215, b: 0 },
yellow: { r: 255, g: 255, b: 0 },
olive: { r: 128, g: 128, b: 0 },
lime: { r: 0, g: 255, b: 0 },
green: { r: 0, g: 128, b: 0 },
teal: { r: 0, g: 128, b: 128 },
turquoise: { r: 64, g: 224, b: 208 },
cyan: { r: 0, g: 255, b: 255 },
aqua: { r: 0, g: 255, b: 255 },
skyblue: { r: 135, g: 206, b: 235 },
dodgerblue: { r: 30, g: 144, b: 255 },
blue: { r: 0, g: 0, b: 255 },
navy: { r: 0, g: 0, b: 128 },
indigo: { r: 75, g: 0, b: 130 },
rebeccapurple: { r: 102, g: 51, b: 153 },
purple: { r: 128, g: 0, b: 128 },
violet: { r: 238, g: 130, b: 238 },
orchid: { r: 218, g: 112, b: 214 },
magenta: { r: 255, g: 0, b: 255 },
fuchsia: { r: 255, g: 0, b: 255 },
hotpink: { r: 255, g: 105, b: 180 },
pink: { r: 255, g: 192, b: 203 },
maroon: { r: 128, g: 0, b: 0 },
};
// Split a string on top-level commas (ignoring commas nested in parens).
function splitTopLevelCommas(str) {
const parts = [];
let depth = 0, start = 0;
for (let i = 0; i < str.length; i++) {
const ch = str[i];
if (ch === '(') depth++;
else if (ch === ')') depth = Math.max(0, depth - 1);
else if (ch === ',' && depth === 0) {
parts.push(str.slice(start, i).trim());
start = i + 1;
}
}
const tail = str.slice(start).trim();
if (tail) parts.push(tail);
return parts;
}
// Evaluate a CSS color-mix() expression to {r,g,b,a}. Returns null when
// the expression can't be resolved (unresolved var(), unknown colors).
//
// Mixing is done with premultiplied alpha in sRGB regardless of the
// declared interpolation space. That is exact for the dominant generated-UI
// pattern — `color-mix(in oklab, <color> N%, transparent)` — where the
// result is simply <color> at alpha N% in ANY rectangular space, and a
// close-enough approximation for opaque-opaque mixes (the detector only
// consumes these values for contrast/chroma thresholds, not for display).
function parseColorMix(str) {
const m = String(str).trim().match(/^color-mix\(/i);
if (!m) return null;
// Balanced-paren capture of the arguments.
let depth = 0, end = -1;
const open = str.indexOf('(');
for (let i = open; i < str.length; i++) {
if (str[i] === '(') depth++;
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
}
if (end < 0) return null;
const args = splitTopLevelCommas(str.slice(open + 1, end));
if (args.length !== 3 || !/^in\s/i.test(args[0])) return null;
const parseComponent = (component) => {
// Percentage may lead or trail the color per spec.
let pct = null;
let colorStr = component;
const trail = component.match(/\s+([\d.]+)%$/);
const lead = component.match(/^([\d.]+)%\s+/);
if (trail) { pct = parseFloat(trail[1]); colorStr = component.slice(0, trail.index).trim(); }
else if (lead) { pct = parseFloat(lead[1]); colorStr = component.slice(lead[0].length).trim(); }
let color;
if (/^transparent$/i.test(colorStr)) color = { r: 0, g: 0, b: 0, a: 0 };
else color = parseAnyColor(colorStr);
if (!color) return null;
return { color, pct };
};
const c1 = parseComponent(args[1]);
const c2 = parseComponent(args[2]);
if (!c1 || !c2) return null;
let p1 = c1.pct, p2 = c2.pct;
if (p1 == null && p2 == null) { p1 = 50; p2 = 50; }
else if (p1 == null) p1 = 100 - p2;
else if (p2 == null) p2 = 100 - p1;
const sum = p1 + p2;
if (sum <= 0) return null;
// Per spec: weights normalize to sum; when sum < 100 the result alpha is
// additionally scaled by sum/100.
const w1 = p1 / sum, w2 = p2 / sum;
const alphaScale = sum < 100 ? sum / 100 : 1;
const a1 = c1.color.a ?? 1, a2 = c2.color.a ?? 1;
const a = (a1 * w1 + a2 * w2) * alphaScale;
if (a <= 0) return { r: 0, g: 0, b: 0, a: 0 };
const mix = (ch) => Math.round((c1.color[ch] * a1 * w1 + c2.color[ch] * a2 * w2) / (a1 * w1 + a2 * w2));
return { r: mix('r'), g: mix('g'), b: mix('b'), a: Math.min(1, a) };
}
// Composite a translucent color over an opaque(ish) base (simple
// source-over in sRGB). Returns an opaque {r,g,b,a:1}.
function compositeColorOver(top, base) {
const a = top.a ?? 1;
return {
r: Math.round(top.r * a + base.r * (1 - a)),
g: Math.round(top.g * a + base.g * (1 - a)),
b: Math.round(top.b * a + base.b * (1 - a)),
a: 1,
};
}
// A color() / lab() / lch() component: a bare number, a percentage against
// `scale`, or the `none` keyword (which resolves to zero for our purposes).
function parseColorComponent(token, scale = 1) {
if (token == null) return null;
const t = String(token).trim();
if (/^none$/i.test(t)) return 0;
const num = parseFloat(t);
if (!Number.isFinite(num)) return null;
return t.endsWith('%') ? (num / 100) * scale : num;
}
function parseAlphaToken(token) {
if (token == null) return 1;
const t = String(token).trim();
if (/^none$/i.test(t)) return 1;
const num = parseFloat(t);
if (!Number.isFinite(num)) return 1;
return t.endsWith('%') ? num / 100 : num;
}
// Extended color parser: rgb/rgba/hex/oklch/oklab/lch/lab/hsl/hwb/color()/
// color-mix/common named colors. Returns null on no match. Use this when the
// input might be any CSS color form; use plain parseRgb when you only expect
// computed rgb() values from real browsers.
function parseAnyColor(s) {
if (!s || typeof s !== 'string') return null;
const str = s.trim();
if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
if (/^color-mix\(/i.test(str)) return parseColorMix(str);
let m;
m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/);
if (m) {
const c = { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: 1 };
if (m[4] !== undefined) c.a = m[5] === '%' ? parseFloat(m[4]) / 100 : +m[4];
return c;
}
m = str.match(/^#([0-9a-f]{3,8})$/i);
if (m) {
const h = m[1];
if (h.length === 3 || h.length === 4) {
return {
r: parseInt(h[0] + h[0], 16),
g: parseInt(h[1] + h[1], 16),
b: parseInt(h[2] + h[2], 16),
a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
};
}
if (h.length === 6 || h.length === 8) {
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
};
}
}
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
// Match L (with optional %), then C and H separated permissively.
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const Lnum = parseFloat(m[1]);
const L = m[2] === '%' ? Lnum / 100 : Lnum;
const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
if (m[5] !== undefined) {
const alpha = parseFloat(m[5]);
rgb.a = m[6] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// OKLAB — a/b are signed axes; percentages map 100% → 0.4.
m = str.match(/oklab\(\s*([\d.]+)(%?)\s+(-?[\d.]+)(%?)\s+(-?[\d.]+)(%?)(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const L = m[2] === '%' ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
const a = m[4] === '%' ? parseFloat(m[3]) * 0.004 : parseFloat(m[3]);
const b = m[6] === '%' ? parseFloat(m[5]) * 0.004 : parseFloat(m[5]);
const rgb = oklabToRgb(L, a, b);
if (m[7] !== undefined) {
const alpha = parseFloat(m[7]);
rgb.a = m[8] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// LCH / LAB — CIE, D50 white point. Chrome serializes lch(20% 5 60) as
// `lch(20 5 60)`, so L arrives with or without its percent sign. In both
// spaces L runs 0..100 and 100% means 100.
m = str.match(/^lch\(\s*([\d.]+%?|none)\s+([\d.]+%?|none)\s+(-?[\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
if (m) {
const L = parseColorComponent(m[1], 100);
const C = parseColorComponent(m[2], 150);
const H = parseFloat(m[3]);
if (L == null || C == null || !Number.isFinite(H)) return null;
const rgb = lchToRgb(L, C, H);
rgb.a = parseAlphaToken(m[4]);
return rgb;
}
m = str.match(/^lab\(\s*([\d.]+%?|none)\s+(-?[\d.]+%?|none)\s+(-?[\d.]+%?|none)(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
if (m) {
const L = parseColorComponent(m[1], 100);
const a = parseColorComponent(m[2], 125);
const b = parseColorComponent(m[3], 125);
if (L == null || a == null || b == null) return null;
const rgb = labToRgb(L, a, b);
rgb.a = parseAlphaToken(m[4]);
return rgb;
}
// color(<space> c1 c2 c3 [/ alpha]) — what Chrome hands back for most
// color-mix() results and for any wide-gamut color an author wrote.
m = str.match(/^color\(\s*([a-z0-9-]+)\s+(-?[\d.eE+-]+%?|none)\s+(-?[\d.eE+-]+%?|none)\s+(-?[\d.eE+-]+%?|none)(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
if (m) {
const c1 = parseColorComponent(m[2]);
const c2 = parseColorComponent(m[3]);
const c3 = parseColorComponent(m[4]);
if (c1 == null || c2 == null || c3 == null) return null;
const rgb = colorFunctionToRgb(m[1].toLowerCase(), c1, c2, c3);
if (!rgb) return null;
rgb.a = parseAlphaToken(m[5]);
return rgb;
}
// HSL/HSLA — comma or space syntax, optional deg on hue.
m = str.match(/hsla?\(\s*(-?[\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%\s*[,\s]\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hslToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// HWB — hue whiteness% blackness%.
m = str.match(/hwb\(\s*(-?[\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hwbToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
const named = CSS_NAMED_COLORS[str.toLowerCase()];
if (named) return { ...named, a: 1 };
return null;
}
// True when a computed background-color string names no paint at all. Used to
// tell "this layer is see-through" (walk on to the ancestor) apart from "this
// layer has a color we could not read" (stop and abstain).
//
// `inherit` belongs here even though it is not literally see-through: it means
// "paint with the parent's background-color", and walking on to the parent IS
// that resolution. Real browsers resolve the keyword before getComputedStyle
// output; only jsdom's partial cascade hands it through verbatim, and treating
// it as unreadable would make the walk abstain on a surface it can know.
// (`currentcolor` is NOT here — it is real paint in the element's own text
// color; resolveBackgroundInfo substitutes the computed color for it.)
function isNoPaintColorValue(value) {
const v = String(value || '').trim().toLowerCase();
if (!v) return true;
return v === 'transparent' || v === 'none' || v === 'initial' || v === 'inherit' || v === 'unset' || v === 'revert' || v === 'revert-layer';
}
export {
isNeutralColor,
parseRgb,
relativeLuminance,
contrastRatio,
parseGradientColors,
extractColorFunctionTokens,
hasChroma,
getHue,
colorToHex,
oklabToRgb,
oklchToRgb,
labToRgb,
lchToRgb,
colorFunctionToRgb,
hslToRgb,
hwbToRgb,
CSS_NAMED_COLORS,
splitTopLevelCommas,
parseColorMix,
parseAnyColor,
compositeColorOver,
isNoPaintColorValue,
};
File diff suppressed because it is too large Load Diff
@@ -11,14 +11,21 @@ import {
isBrandFontOnOwnDomain,
} from '../shared/constants.mjs';
import {
CSS_NAMED_COLORS,
colorToHex,
compositeColorOver,
contrastRatio,
getHue,
hasChroma,
isNeutralColor,
isNoPaintColorValue,
oklchToRgb,
parseAnyColor,
parseColorMix,
parseGradientColors,
parseRgb,
relativeLuminance,
splitTopLevelCommas,
} from '../shared/color.mjs';
import { extractGoogleFontFamilies } from '../shared/fonts.mjs';
@@ -1731,7 +1738,7 @@ function readOwnBackgroundColor(el, computedStyle) {
// One element's background-color as the cascade walk sees it: computed style
// first (with the modern-color fallback), then, in static mode only,
// custom-prop resolution and the inline-shorthand peek. Shared by
// resolveBackground and resolveGradientStops so both walks read the same
// resolveBackgroundInfo and resolveGradientStops so both walks read the same
// surfaces.
function readCascadeBackgroundColor(current, style, customPropMap) {
let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
@@ -1755,7 +1762,19 @@ function readCascadeBackgroundColor(current, style, customPropMap) {
return bg;
}
function resolveBackground(el, win, customPropMap) {
// Walk up for the surface the element's text is painted on.
//
// Returns { color, unresolved }:
// • color set — the effective surface, overlays composited in.
// • unresolved: true — a layer on the way up paints a color this parser
// cannot read, so the surface is unknown. Callers
// must SKIP their contrast checks. Guessing white
// here is what flooded dark themes with false
// "on #ffffff" findings: one abstention costs a
// single finding, one wrong guess costs a hundred.
// • both null/false — no solid color, but a gradient or image is in
// play; callers fall back to its color stops.
function resolveBackgroundInfo(el, win, customPropMap) {
let current = el;
// Translucent layers (0.1 < a < 1) found on the way down to an opaque
// base. A browser composites these over the base; the old behavior
@@ -1783,59 +1802,81 @@ function resolveBackground(el, win, customPropMap) {
// body backgrounds.
// Real browsers serialize wide-gamut computed values as oklab()/oklch()
// (e.g. any color-mix() result), which plain parseRgb misses.
const bg = readCascadeBackgroundColor(current, style, customPropMap);
let bg = readCascadeBackgroundColor(current, style, customPropMap);
// `background-color: currentcolor` paints with the element's own text
// color — real paint whose value we know. Real browsers resolve the
// keyword before getComputedStyle output; jsdom hands it through
// verbatim, and without this substitution the layer would read as
// unparseable and force a needless abstention.
if ((!bg || bg.a < 0.1) && /^currentcolor$/i.test(String(style.backgroundColor || '').trim())) {
// The static cascade resolves var() text tokens before checks run, so
// style.color is normally already an rgb string here; parseColorResolved
// is defense in depth for any future caller that passes a live
// customPropMap (it matches the text-color path in checkElementColors
// and reduces to parseAnyColor when the map is null or absent).
bg = parseRgb(style.color) || parseColorResolved(style.color, customPropMap);
}
if (bg && bg.a > 0.1) {
if (bg.a >= 0.99) return flatten(bg);
if (bg.a >= 0.99) return { color: flatten(bg), unresolved: false };
overlays.push(bg);
} else if (!bg && !isNoPaintColorValue(style.backgroundColor)) {
// This layer names a color we could not parse (a color space we do not
// model, an unresolved var(), a syntax newer than the parser). It may
// well be opaque, which would make every ancestor below it invisible —
// so the surface is unknown and the walk stops here rather than
// reporting an ancestor the visitor never sees.
return { color: null, unresolved: true };
}
// No solid bg-color at this level, but this level paints an image. CSS
// stacks background-image layers first-on-top, so which layer leads
// decides what the visitor sees:
// • gradient on top — the gradient is the surface. Hand the caller a
// null color so it falls back to the gradient's own stops (body
// grounds, gradient buttons, hero sections).
// • url() on top — the surface is an image whose pixels this engine
// cannot read, and it may fully cover every layer and ancestor
// beneath it. Same contract as an unparseable color: abstain, so
// the gradient-stop fallback never measures a gradient the image
// hides (the shipped miss: `url(photo), linear-gradient(...)`
// reported low-contrast against the invisible gradient's stops).
if (hasGradientOrUrl) {
const layers = splitTopLevelCommas(bgImage);
const topPaintLayer = layers.find(
(layer) => /gradient\s*\(/i.test(layer) || /url\s*\(/i.test(layer),
);
const gradientOnTop = !!topPaintLayer
&& /gradient\s*\(/i.test(topPaintLayer)
&& !/^\s*url\s*\(/i.test(topPaintLayer);
if (!gradientOnTop) return { color: null, unresolved: true };
// Gradient on top of a url() layer: the image shows through wherever
// the gradient is not fully opaque, so a translucent wash like
// `linear-gradient(rgba(0,0,0,.2), rgba(0,0,0,.2)), url(photo)` paints
// a blend with pixels this engine cannot read. Only a gradient whose
// every readable stop is opaque provably covers the image; otherwise
// the surface is unknown — abstain rather than hand callers gradient
// stops (or a stop average) the visitor never sees unmixed.
const urlBeneath = layers.some(
(layer) => layer !== topPaintLayer && /url\s*\(/i.test(layer),
);
if (urlBeneath) {
const topStops = parseGradientColors(topPaintLayer);
const provablyOpaque = topStops.length > 0 && topStops.every((s) => (s.a ?? 1) >= 0.99);
if (!provablyOpaque) return { color: null, unresolved: true };
}
return { color: null, unresolved: false };
}
// No solid bg-color at this level. If THIS level has a gradient/url
// with no underlying solid color we can read:
// • on body/html: assume white. Body-level gradients are almost
// always decorative texture (paper grain, noise) on top of a
// solid bg-color the page set via `background: var(--paper)`
// shorthand — which jsdom can't decompose into bg-color. The
// downstream gradient-stops fallback path produces catastrophic
// false positives in this case (gradient noise stops have
// accidental browns/blacks that look like card backgrounds).
// • on other elements: bail to null and let the caller fall back
// to gradient stops (gradient buttons / hero sections are real
// bgs worth checking against).
// A gradient or image with no solid color under it, at any level
// including body/html, means the visible ground is that layer itself.
// Return null so the caller measures against the actual gradient stops,
// or skips when nothing is parseable — skipping beats a wrong ratio.
//
// Body/html used to assume white here, a guard written for jsdom, which
// never decomposed the `background:` shorthand and so could not see the
// solid paper color a texture gradient usually sits on. It turned every
// light-on-dark page into a wall of low-contrast false positives (a dark
// oklch body gradient produced ~120 "on #ffffff" findings on one site).
// Both engines can see shorthand solids now — the browser natively, the
// static cascade via expandStaticDeclaration + var() resolution — so a
// missing solid is real, and the old failure case cannot recur: opaque
// stops fully cover any hidden solid (they ARE the ground), alpha stops
// composite over the resolved base or the white canvas default, and
// unresolvable stops drop rather than guess.
if (hasGradientOrUrl) return null;
current = current.parentElement;
}
return flatten({ r: 255, g: 255, b: 255, a: 1 });
// Every layer up to the document root was genuinely see-through, so the
// browser paints its default canvas. This is the ONLY case that earns the
// white assumption.
return { color: flatten({ r: 255, g: 255, b: 255, a: 1 }), unresolved: false };
}
// parseGradientColors (shared) reads only the legacy serializations: rgb()
// and hex stops. Browsers keep modern-space stops in computed backgroundImage
// exactly as authored — `linear-gradient(oklch(7% 0.006 95), …)` stays oklch —
// which is what every token-driven page produces. Route those through
// parseAnyColor so a gradient ground is measurable rather than invisible.
function parseGradientColorsModern(bgImage) {
if (!bgImage || !/gradient/i.test(bgImage)) return [];
const colors = parseGradientColors(bgImage);
for (const m of bgImage.matchAll(/(?:oklch|oklab|hsla?|hwb)\(\s*[^()]*\)/gi)) {
const c = parseAnyColor(m[0]);
if (c) colors.push(c);
}
return colors;
function resolveBackground(el, win, customPropMap) {
return resolveBackgroundInfo(el, win, customPropMap).color;
}
// Walk parents looking for a gradient background and return its color stops.
@@ -1861,7 +1902,10 @@ function resolveGradientStops(el, win, customPropMap) {
if (bgImage && bgImage !== 'none' && /url\s*\(/i.test(bgImage)) return null;
let stops = null;
if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
const parsed = parseGradientColorsModern(bgImage);
// parseGradientColors (shared) reads modern-space stops too — oklch,
// color-mix and friends via balanced-paren token capture — so browser
// computed values that keep the authored syntax stay measurable.
const parsed = parseGradientColors(bgImage);
if (parsed.length > 0) stops = parsed;
}
if (!stops && !DETECTOR_IS_BROWSER) {
@@ -1869,7 +1913,7 @@ function resolveGradientStops(el, win, customPropMap) {
const rawStyle = current.getAttribute?.('style') || '';
const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
if (bgMatch && /gradient/i.test(bgMatch[1])) {
const parsed = parseGradientColorsModern(bgMatch[1]);
const parsed = parseGradientColors(bgMatch[1]);
if (parsed.length > 0) stops = parsed;
}
}
@@ -2115,13 +2159,19 @@ function checkElementColorsDOM(el) {
if (style.visibility === 'hidden' || effectiveOpacityDOM(el) <= 0.02) return [];
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
let effectiveBg = resolveBackground(el);
const bgInfo = resolveBackgroundInfo(el);
let effectiveBg = bgInfo.color;
// An unreadable surface anywhere up the chain: skip the gradient-stop
// fallback too, so nothing downstream measures against a ground we never
// resolved.
let surfaceUnresolved = bgInfo.unresolved;
let ownBg = readOwnBackgroundColor(el, style);
if (!ownBg || (ownBg.a ?? 1) <= 0.5) {
const pseudoSurface = readPseudoSurfaceDOM(el, rect);
if (pseudoSurface) {
ownBg = pseudoSurface;
effectiveBg = pseudoSurface;
surfaceUnresolved = false;
}
}
return checkColors({
@@ -2133,8 +2183,8 @@ function checkElementColorsDOM(el) {
// an oklch token near its own oklch background).
textColor: parseRgb(style.color) || parseAnyColor(style.color),
bgColor: ownBg,
effectiveBg,
effectiveBgStops: effectiveBg ? null : resolveGradientStops(el),
effectiveBg: surfaceUnresolved ? null : effectiveBg,
effectiveBgStops: surfaceUnresolved || effectiveBg ? null : resolveGradientStops(el),
fontSize: parseFloat(style.fontSize) || 16,
fontWeight: parseInt(style.fontWeight) || 400,
hasDirectText,
@@ -2284,283 +2334,6 @@ function resolveVarRefs(raw, customPropMap, depth = 0) {
});
}
// OKLCH → sRGB conversion (Björn Ottosson's matrices). L in 0..1 (or %),
// C in 0..~0.4 typical, H in degrees. Returns clamped {r,g,b,a:1} in 0..255.
// Needed because jsdom doesn't compute oklch() values — getComputedStyle
// returns the literal "oklch(...)" string. Without this, the entire
// Tailwind v4 color palette (which is OKLCH-based) is invisible to the
// detector's contrast / color checks.
function oklchToRgb(L, C, H) {
const hRad = (H * Math.PI) / 180;
return oklabToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
}
function oklabToRgb(L, a, b) {
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
const rLin = 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc;
const gLin = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc;
const bLin = -0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc;
const enc = (x) => {
const c = Math.max(0, Math.min(1, x));
return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
};
return {
r: Math.round(enc(rLin) * 255),
g: Math.round(enc(gLin) * 255),
b: Math.round(enc(bLin) * 255),
a: 1,
};
}
function hslToRgb(h, s, l) {
h = ((h % 360) + 360) % 360;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m0 = l - c / 2;
const [r, g, b] =
h < 60 ? [c, x, 0] :
h < 120 ? [x, c, 0] :
h < 180 ? [0, c, x] :
h < 240 ? [0, x, c] :
h < 300 ? [x, 0, c] : [c, 0, x];
return {
r: Math.round((r + m0) * 255),
g: Math.round((g + m0) * 255),
b: Math.round((b + m0) * 255),
a: 1,
};
}
function hwbToRgb(h, w, bl) {
if (w + bl >= 1) {
const g = Math.round((w / (w + bl)) * 255);
return { r: g, g, b: g, a: 1 };
}
const base = hslToRgb(h, 1, 0.5);
const mix = (c) => Math.round(((c / 255) * (1 - w - bl) + w) * 255);
return { r: mix(base.r), g: mix(base.g), b: mix(base.b), a: 1 };
}
// Common CSS named colors — the handful that actually show up in generated
// UIs, not the full 148-name spec list. Includes the achromatic names so a
// named gray parses (and correctly reads as no-chroma) instead of being
// treated as an unknown color.
const CSS_NAMED_COLORS = {
black: { r: 0, g: 0, b: 0 },
white: { r: 255, g: 255, b: 255 },
gray: { r: 128, g: 128, b: 128 },
grey: { r: 128, g: 128, b: 128 },
silver: { r: 192, g: 192, b: 192 },
dimgray: { r: 105, g: 105, b: 105 },
darkgray: { r: 169, g: 169, b: 169 },
lightgray: { r: 211, g: 211, b: 211 },
gainsboro: { r: 220, g: 220, b: 220 },
whitesmoke: { r: 245, g: 245, b: 245 },
red: { r: 255, g: 0, b: 0 },
crimson: { r: 220, g: 20, b: 60 },
tomato: { r: 255, g: 99, b: 71 },
coral: { r: 255, g: 127, b: 80 },
salmon: { r: 250, g: 128, b: 114 },
orange: { r: 255, g: 165, b: 0 },
gold: { r: 255, g: 215, b: 0 },
yellow: { r: 255, g: 255, b: 0 },
olive: { r: 128, g: 128, b: 0 },
lime: { r: 0, g: 255, b: 0 },
green: { r: 0, g: 128, b: 0 },
teal: { r: 0, g: 128, b: 128 },
turquoise: { r: 64, g: 224, b: 208 },
cyan: { r: 0, g: 255, b: 255 },
aqua: { r: 0, g: 255, b: 255 },
skyblue: { r: 135, g: 206, b: 235 },
dodgerblue: { r: 30, g: 144, b: 255 },
blue: { r: 0, g: 0, b: 255 },
navy: { r: 0, g: 0, b: 128 },
indigo: { r: 75, g: 0, b: 130 },
rebeccapurple: { r: 102, g: 51, b: 153 },
purple: { r: 128, g: 0, b: 128 },
violet: { r: 238, g: 130, b: 238 },
orchid: { r: 218, g: 112, b: 214 },
magenta: { r: 255, g: 0, b: 255 },
fuchsia: { r: 255, g: 0, b: 255 },
hotpink: { r: 255, g: 105, b: 180 },
pink: { r: 255, g: 192, b: 203 },
maroon: { r: 128, g: 0, b: 0 },
};
// Split a string on top-level commas (ignoring commas nested in parens).
function splitTopLevelCommas(str) {
const parts = [];
let depth = 0, start = 0;
for (let i = 0; i < str.length; i++) {
const ch = str[i];
if (ch === '(') depth++;
else if (ch === ')') depth = Math.max(0, depth - 1);
else if (ch === ',' && depth === 0) {
parts.push(str.slice(start, i).trim());
start = i + 1;
}
}
const tail = str.slice(start).trim();
if (tail) parts.push(tail);
return parts;
}
// Evaluate a CSS color-mix() expression to {r,g,b,a}. Returns null when
// the expression can't be resolved (unresolved var(), unknown colors).
//
// Mixing is done with premultiplied alpha in sRGB regardless of the
// declared interpolation space. That is exact for the dominant generated-UI
// pattern — `color-mix(in oklab, <color> N%, transparent)` — where the
// result is simply <color> at alpha N% in ANY rectangular space, and a
// close-enough approximation for opaque-opaque mixes (the detector only
// consumes these values for contrast/chroma thresholds, not for display).
function parseColorMix(str) {
const m = String(str).trim().match(/^color-mix\(/i);
if (!m) return null;
// Balanced-paren capture of the arguments.
let depth = 0, end = -1;
const open = str.indexOf('(');
for (let i = open; i < str.length; i++) {
if (str[i] === '(') depth++;
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
}
if (end < 0) return null;
const args = splitTopLevelCommas(str.slice(open + 1, end));
if (args.length !== 3 || !/^in\s/i.test(args[0])) return null;
const parseComponent = (component) => {
// Percentage may lead or trail the color per spec.
let pct = null;
let colorStr = component;
const trail = component.match(/\s+([\d.]+)%$/);
const lead = component.match(/^([\d.]+)%\s+/);
if (trail) { pct = parseFloat(trail[1]); colorStr = component.slice(0, trail.index).trim(); }
else if (lead) { pct = parseFloat(lead[1]); colorStr = component.slice(lead[0].length).trim(); }
let color;
if (/^transparent$/i.test(colorStr)) color = { r: 0, g: 0, b: 0, a: 0 };
else color = parseAnyColor(colorStr);
if (!color) return null;
return { color, pct };
};
const c1 = parseComponent(args[1]);
const c2 = parseComponent(args[2]);
if (!c1 || !c2) return null;
let p1 = c1.pct, p2 = c2.pct;
if (p1 == null && p2 == null) { p1 = 50; p2 = 50; }
else if (p1 == null) p1 = 100 - p2;
else if (p2 == null) p2 = 100 - p1;
const sum = p1 + p2;
if (sum <= 0) return null;
// Per spec: weights normalize to sum; when sum < 100 the result alpha is
// additionally scaled by sum/100.
const w1 = p1 / sum, w2 = p2 / sum;
const alphaScale = sum < 100 ? sum / 100 : 1;
const a1 = c1.color.a ?? 1, a2 = c2.color.a ?? 1;
const a = (a1 * w1 + a2 * w2) * alphaScale;
if (a <= 0) return { r: 0, g: 0, b: 0, a: 0 };
const mix = (ch) => Math.round((c1.color[ch] * a1 * w1 + c2.color[ch] * a2 * w2) / (a1 * w1 + a2 * w2));
return { r: mix('r'), g: mix('g'), b: mix('b'), a: Math.min(1, a) };
}
// Composite a translucent color over an opaque(ish) base (simple
// source-over in sRGB). Returns an opaque {r,g,b,a:1}.
function compositeColorOver(top, base) {
const a = top.a ?? 1;
return {
r: Math.round(top.r * a + base.r * (1 - a)),
g: Math.round(top.g * a + base.g * (1 - a)),
b: Math.round(top.b * a + base.b * (1 - a)),
a: 1,
};
}
// Extended color parser: rgb/rgba/hex/oklch/oklab/hsl/hwb/color-mix/common
// named colors. Returns null on no match. Use this when the input might be
// any CSS color form; use plain parseRgb when you only expect computed rgb()
// values from real browsers.
function parseAnyColor(s) {
if (!s || typeof s !== 'string') return null;
const str = s.trim();
if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
if (/^color-mix\(/i.test(str)) return parseColorMix(str);
let m;
m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+))?\s*\)/);
if (m) return { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: m[4] !== undefined ? +m[4] : 1 };
m = str.match(/^#([0-9a-f]{3,8})$/i);
if (m) {
const h = m[1];
if (h.length === 3 || h.length === 4) {
return {
r: parseInt(h[0] + h[0], 16),
g: parseInt(h[1] + h[1], 16),
b: parseInt(h[2] + h[2], 16),
a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
};
}
if (h.length === 6 || h.length === 8) {
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
};
}
}
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
// Match L (with optional %), then C and H separated permissively.
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const Lnum = parseFloat(m[1]);
const L = m[2] === '%' ? Lnum / 100 : Lnum;
const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
if (m[5] !== undefined) {
const alpha = parseFloat(m[5]);
rgb.a = m[6] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// OKLAB — a/b are signed axes; percentages map 100% → 0.4.
m = str.match(/oklab\(\s*([\d.]+)(%?)\s+(-?[\d.]+)(%?)\s+(-?[\d.]+)(%?)(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const L = m[2] === '%' ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
const a = m[4] === '%' ? parseFloat(m[3]) * 0.004 : parseFloat(m[3]);
const b = m[6] === '%' ? parseFloat(m[5]) * 0.004 : parseFloat(m[5]);
const rgb = oklabToRgb(L, a, b);
if (m[7] !== undefined) {
const alpha = parseFloat(m[7]);
rgb.a = m[8] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// HSL/HSLA — comma or space syntax, optional deg on hue.
m = str.match(/hsla?\(\s*(-?[\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%\s*[,\s]\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hslToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// HWB — hue whiteness% blackness%.
m = str.match(/hwb\(\s*(-?[\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hwbToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
const named = CSS_NAMED_COLORS[str.toLowerCase()];
if (named) return { ...named, a: 1 };
return null;
}
// Resolve var() refs in a color string (via customPropMap), then parse.
// Returns null on any failure. Used in jsdom-mode paths where
@@ -2923,15 +2696,24 @@ function checkElementGlowDOM(el) {
if (!boxShadow && !textShadow) return [];
// Use parent's background — glow radiates outward, so the surrounding context matters
// If resolveBackground returns null (gradient), try to infer from the gradient colors
let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el);
if (!parentBg) {
const parentBgInfo = resolveBackgroundInfo(el.parentElement || el);
// Unknown surface (an unreadable layer on the way up): skip only the
// gradient hunt below, which would walk PAST that layer and score the
// glow against a background the visitor never sees. checkGlow still runs
// with a null surface: the zero-offset chromatic halo tell holds on ANY
// background, and the static loop already passes the unresolved walk's
// null color straight through (detect-html.mjs uses resolveBackground).
let parentBg = parentBgInfo.color;
if (!parentBg && !parentBgInfo.unresolved) {
// Gradient background — sample its colors to determine if it's dark.
// Modern-syntax parsing matters here: body-level gradients now reach this
// fallback in browser mode, and their stops usually serialize as oklch.
// fallback in browser mode, and their stops usually serialize as oklch
// which the shared parseGradientColors reads via its color-function
// token capture.
let cur = el.parentElement;
while (cur && cur.nodeType === 1) {
const bgImage = getComputedStyle(cur).backgroundImage || '';
const gradColors = parseGradientColorsModern(bgImage);
const gradColors = parseGradientColors(bgImage);
if (gradColors.length > 0) {
// Average the gradient colors
const avg = { r: 0, g: 0, b: 0 };
@@ -2975,10 +2757,13 @@ function checkElementAIPaletteDOM(el) {
const hue = getHue(textColor);
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
if (isAIPalette) {
const parentBg = el.parentElement ? resolveBackground(el.parentElement) : null;
// Also check gradient parents
let effectiveBg = parentBg;
if (!effectiveBg) {
const parentBgInfo = el.parentElement
? resolveBackgroundInfo(el.parentElement)
: { color: null, unresolved: false };
// Unknown surface: leave effectiveBg null (no finding) rather than
// hunting gradient ancestors past a layer we could not read.
let effectiveBg = parentBgInfo.color;
if (!effectiveBg && !parentBgInfo.unresolved) {
let cur = el.parentElement;
while (cur && cur.nodeType === 1) {
const gi = getComputedStyle(cur).backgroundImage || '';
@@ -3784,7 +3569,8 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
const effectiveBg = resolveBackground(el, window, customPropMap);
const bgInfo = resolveBackgroundInfo(el, window, customPropMap);
const effectiveBg = bgInfo.color;
// jsdom returns literal "var(--X)" / "oklch(...)" for color, so plain
// parseRgb misses Tailwind-tokenized text colors. Resolve through the
// customPropMap first; fall back to parseRgb for vanilla rgb() pages.
@@ -3830,11 +3616,13 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
// element itself has no usable own background, that pseudo is the real
// surface for contrast purposes.
let finalEffectiveBg = effectiveBg;
let surfaceUnresolved = bgInfo.unresolved;
if ((!ownBg || (ownBg.a ?? 1) <= 0.5) && typeof window.getPseudoSurface === 'function') {
const pseudoSurface = window.getPseudoSurface(el);
if (pseudoSurface) {
ownBg = pseudoSurface;
finalEffectiveBg = pseudoSurface;
surfaceUnresolved = false;
}
}
@@ -3842,8 +3630,9 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
tag,
textColor,
bgColor: ownBg,
effectiveBg: finalEffectiveBg,
effectiveBgStops: finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
// Unknown surface: hand the checks nothing rather than a guess.
effectiveBg: surfaceUnresolved ? null : finalEffectiveBg,
effectiveBgStops: surfaceUnresolved || finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
fontSize: parseFloat(style.fontSize) || 16,
fontWeight: parseInt(style.fontWeight) || 400,
hasDirectText,
@@ -5663,6 +5452,7 @@ export {
checkHtmlPatterns,
readOwnBackgroundColor,
resolveBackground,
resolveBackgroundInfo,
resolveGradientStops,
parseRadiusToPx,
resolveBorderRadiusPx,
@@ -71,11 +71,43 @@ function contrastRatio(c1, c2) {
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
// The CSS color functions worth pulling out of a longer declaration. The set
// is deliberately closed: `linear-gradient(` and `url(` also look like
// `name(` and must not be read as colors.
const COLOR_FUNCTION_NAMES = new Set([
'rgb', 'rgba', 'hsl', 'hsla', 'hwb', 'oklch', 'oklab', 'lch', 'lab', 'color', 'color-mix',
]);
// Pull every color-function token out of a value, with balanced-paren capture
// so nested forms (`color-mix(in oklab, oklch(...) 20%, transparent)`) survive
// whole. Returns the raw substrings in source order.
function extractColorFunctionTokens(value) {
const str = String(value || '');
const tokens = [];
const re = /([a-z][a-z-]*)\(/gi;
let m;
while ((m = re.exec(str)) !== null) {
if (!COLOR_FUNCTION_NAMES.has(m[1].toLowerCase())) continue;
let depth = 0, end = -1;
for (let i = m.index + m[0].length - 1; i < str.length; i++) {
if (str[i] === '(') depth++;
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
}
if (end < 0) break;
tokens.push(str.slice(m.index, end + 1));
re.lastIndex = end + 1;
}
return tokens;
}
function parseGradientColors(bgImage) {
if (!bgImage || !bgImage.includes('gradient')) return [];
const colors = [];
for (const m of bgImage.matchAll(/rgba?\([^)]+\)/g)) {
const c = parseRgb(m[0]);
// Stops arrive in whatever syntax the author wrote and the browser kept.
// A dark ground painted as `linear-gradient(oklch(...), oklch(...))` used
// to read as a gradient with no stops at all.
for (const token of extractColorFunctionTokens(bgImage)) {
const c = parseAnyColor(token);
if (c) colors.push(c);
}
for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
@@ -112,13 +144,445 @@ function colorToHex(c) {
return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
}
// ─── Color-space conversions ────────────────────────────────────────────────
//
// Every function here lands on 8-bit sRGB, clamped to gamut. Chrome, Safari,
// and Firefox all keep the authored color space in getComputedStyle output
// (`oklch(0.84 0.19 80.46)`, `lch(20 5 60)`, `color(srgb 1.04 0.72 -0.21)`),
// so a detector that only reads rgb() is blind on any modern palette. The
// expected outputs are pinned in tests/detect-antipatterns.test.js against
// what Chrome itself paints for the same strings.
function clamp01(x) {
return Number.isFinite(x) ? Math.max(0, Math.min(1, x)) : 0;
}
// Linear-light sRGB channel to the encoded 0-255 value.
function encodeSrgbChannel(x) {
const c = clamp01(x);
return Math.round((c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055) * 255);
}
function decodeSrgbChannel(x) {
const c = Number.isFinite(x) ? x : 0;
const sign = c < 0 ? -1 : 1;
const abs = Math.abs(c);
return sign * (abs <= 0.04045 ? abs / 12.92 : Math.pow((abs + 0.055) / 1.055, 2.4));
}
function linearSrgbToColor(r, g, b, a = 1) {
return { r: encodeSrgbChannel(r), g: encodeSrgbChannel(g), b: encodeSrgbChannel(b), a };
}
// OKLab to sRGB (Björn Ottosson's matrices). L in 0..1, a/b are signed axes.
function oklabToRgb(L, a, b) {
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
return linearSrgbToColor(
4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc,
-1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc,
-0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc,
);
}
// OKLCH to sRGB. L in 0..1, C in 0..~0.4 typical, H in degrees. Chroma past
// the sRGB gamut clamps per channel rather than producing NaN.
function oklchToRgb(L, C, H) {
const hRad = (H * Math.PI) / 180;
return oklabToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
}
// CIE Lab to sRGB. CSS lab()/lch() use the D50 white point; the matrix below
// is the Bradford-adapted XYZ-D50 to linear-sRGB transform from CSS Color 4.
function labToRgb(L, a, b) {
const kappa = 24389 / 27, epsilon = 216 / 24389;
const fy = (L + 16) / 116, fx = fy + a / 500, fz = fy - b / 200;
const invert = (t) => (t * t * t > epsilon ? t * t * t : (116 * t - 16) / kappa);
const yr = L > kappa * epsilon ? Math.pow((L + 16) / 116, 3) : L / kappa;
const Xn = 0.3457 / 0.3585, Zn = (1 - 0.3457 - 0.3585) / 0.3585;
const x = invert(fx) * Xn, y = yr, z = invert(fz) * Zn;
return linearSrgbToColor(
3.1341359569958707 * x - 1.6173863321612538 * y - 0.4906619460083532 * z,
-0.9787955029120890 * x + 1.9162545672595240 * y + 0.0334427311613195 * z,
0.0719553798841168 * x - 0.2289768264158322 * y + 1.4053860583241250 * z,
);
}
function lchToRgb(L, C, H) {
const hRad = (H * Math.PI) / 180;
return labToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
}
// color(<space> c1 c2 c3) for the spaces that turn up in real stylesheets.
// `srgb` is what Chrome serializes most color-mix() results into, routinely
// with channels outside 0..1. Spaces we do not model return null so callers
// abstain instead of measuring against a color we invented.
function colorFunctionToRgb(space, c1, c2, c3) {
switch (space) {
case 'srgb':
return { r: Math.round(clamp01(c1) * 255), g: Math.round(clamp01(c2) * 255), b: Math.round(clamp01(c3) * 255), a: 1 };
case 'srgb-linear':
return linearSrgbToColor(c1, c2, c3);
case 'display-p3': {
const [R, G, B] = [decodeSrgbChannel(c1), decodeSrgbChannel(c2), decodeSrgbChannel(c3)];
return linearSrgbToColor(
1.2249401762805587 * R - 0.2249404646817506 * G + 0.0000002884022551 * B,
-0.0420569547096138 * R + 1.0420571661298634 * G - 0.0000002113202247 * B,
-0.0196375587040044 * R - 0.0786360772174755 * G + 1.0982736359214800 * B,
);
}
default:
return null;
}
}
function hslToRgb(h, s, l) {
h = ((h % 360) + 360) % 360;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m0 = l - c / 2;
const [r, g, b] =
h < 60 ? [c, x, 0] :
h < 120 ? [x, c, 0] :
h < 180 ? [0, c, x] :
h < 240 ? [0, x, c] :
h < 300 ? [x, 0, c] : [c, 0, x];
return {
r: Math.round((r + m0) * 255),
g: Math.round((g + m0) * 255),
b: Math.round((b + m0) * 255),
a: 1,
};
}
function hwbToRgb(h, w, bl) {
if (w + bl >= 1) {
const g = Math.round((w / (w + bl)) * 255);
return { r: g, g, b: g, a: 1 };
}
const base = hslToRgb(h, 1, 0.5);
const mix = (c) => Math.round(((c / 255) * (1 - w - bl) + w) * 255);
return { r: mix(base.r), g: mix(base.g), b: mix(base.b), a: 1 };
}
// Common CSS named colors — the handful that actually show up in generated
// UIs, not the full 148-name spec list. Includes the achromatic names so a
// named gray parses (and correctly reads as no-chroma) instead of being
// treated as an unknown color.
const CSS_NAMED_COLORS = {
black: { r: 0, g: 0, b: 0 },
white: { r: 255, g: 255, b: 255 },
gray: { r: 128, g: 128, b: 128 },
grey: { r: 128, g: 128, b: 128 },
silver: { r: 192, g: 192, b: 192 },
dimgray: { r: 105, g: 105, b: 105 },
darkgray: { r: 169, g: 169, b: 169 },
lightgray: { r: 211, g: 211, b: 211 },
gainsboro: { r: 220, g: 220, b: 220 },
whitesmoke: { r: 245, g: 245, b: 245 },
red: { r: 255, g: 0, b: 0 },
crimson: { r: 220, g: 20, b: 60 },
tomato: { r: 255, g: 99, b: 71 },
coral: { r: 255, g: 127, b: 80 },
salmon: { r: 250, g: 128, b: 114 },
orange: { r: 255, g: 165, b: 0 },
gold: { r: 255, g: 215, b: 0 },
yellow: { r: 255, g: 255, b: 0 },
olive: { r: 128, g: 128, b: 0 },
lime: { r: 0, g: 255, b: 0 },
green: { r: 0, g: 128, b: 0 },
teal: { r: 0, g: 128, b: 128 },
turquoise: { r: 64, g: 224, b: 208 },
cyan: { r: 0, g: 255, b: 255 },
aqua: { r: 0, g: 255, b: 255 },
skyblue: { r: 135, g: 206, b: 235 },
dodgerblue: { r: 30, g: 144, b: 255 },
blue: { r: 0, g: 0, b: 255 },
navy: { r: 0, g: 0, b: 128 },
indigo: { r: 75, g: 0, b: 130 },
rebeccapurple: { r: 102, g: 51, b: 153 },
purple: { r: 128, g: 0, b: 128 },
violet: { r: 238, g: 130, b: 238 },
orchid: { r: 218, g: 112, b: 214 },
magenta: { r: 255, g: 0, b: 255 },
fuchsia: { r: 255, g: 0, b: 255 },
hotpink: { r: 255, g: 105, b: 180 },
pink: { r: 255, g: 192, b: 203 },
maroon: { r: 128, g: 0, b: 0 },
};
// Split a string on top-level commas (ignoring commas nested in parens).
function splitTopLevelCommas(str) {
const parts = [];
let depth = 0, start = 0;
for (let i = 0; i < str.length; i++) {
const ch = str[i];
if (ch === '(') depth++;
else if (ch === ')') depth = Math.max(0, depth - 1);
else if (ch === ',' && depth === 0) {
parts.push(str.slice(start, i).trim());
start = i + 1;
}
}
const tail = str.slice(start).trim();
if (tail) parts.push(tail);
return parts;
}
// Evaluate a CSS color-mix() expression to {r,g,b,a}. Returns null when
// the expression can't be resolved (unresolved var(), unknown colors).
//
// Mixing is done with premultiplied alpha in sRGB regardless of the
// declared interpolation space. That is exact for the dominant generated-UI
// pattern — `color-mix(in oklab, <color> N%, transparent)` — where the
// result is simply <color> at alpha N% in ANY rectangular space, and a
// close-enough approximation for opaque-opaque mixes (the detector only
// consumes these values for contrast/chroma thresholds, not for display).
function parseColorMix(str) {
const m = String(str).trim().match(/^color-mix\(/i);
if (!m) return null;
// Balanced-paren capture of the arguments.
let depth = 0, end = -1;
const open = str.indexOf('(');
for (let i = open; i < str.length; i++) {
if (str[i] === '(') depth++;
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
}
if (end < 0) return null;
const args = splitTopLevelCommas(str.slice(open + 1, end));
if (args.length !== 3 || !/^in\s/i.test(args[0])) return null;
const parseComponent = (component) => {
// Percentage may lead or trail the color per spec.
let pct = null;
let colorStr = component;
const trail = component.match(/\s+([\d.]+)%$/);
const lead = component.match(/^([\d.]+)%\s+/);
if (trail) { pct = parseFloat(trail[1]); colorStr = component.slice(0, trail.index).trim(); }
else if (lead) { pct = parseFloat(lead[1]); colorStr = component.slice(lead[0].length).trim(); }
let color;
if (/^transparent$/i.test(colorStr)) color = { r: 0, g: 0, b: 0, a: 0 };
else color = parseAnyColor(colorStr);
if (!color) return null;
return { color, pct };
};
const c1 = parseComponent(args[1]);
const c2 = parseComponent(args[2]);
if (!c1 || !c2) return null;
let p1 = c1.pct, p2 = c2.pct;
if (p1 == null && p2 == null) { p1 = 50; p2 = 50; }
else if (p1 == null) p1 = 100 - p2;
else if (p2 == null) p2 = 100 - p1;
const sum = p1 + p2;
if (sum <= 0) return null;
// Per spec: weights normalize to sum; when sum < 100 the result alpha is
// additionally scaled by sum/100.
const w1 = p1 / sum, w2 = p2 / sum;
const alphaScale = sum < 100 ? sum / 100 : 1;
const a1 = c1.color.a ?? 1, a2 = c2.color.a ?? 1;
const a = (a1 * w1 + a2 * w2) * alphaScale;
if (a <= 0) return { r: 0, g: 0, b: 0, a: 0 };
const mix = (ch) => Math.round((c1.color[ch] * a1 * w1 + c2.color[ch] * a2 * w2) / (a1 * w1 + a2 * w2));
return { r: mix('r'), g: mix('g'), b: mix('b'), a: Math.min(1, a) };
}
// Composite a translucent color over an opaque(ish) base (simple
// source-over in sRGB). Returns an opaque {r,g,b,a:1}.
function compositeColorOver(top, base) {
const a = top.a ?? 1;
return {
r: Math.round(top.r * a + base.r * (1 - a)),
g: Math.round(top.g * a + base.g * (1 - a)),
b: Math.round(top.b * a + base.b * (1 - a)),
a: 1,
};
}
// A color() / lab() / lch() component: a bare number, a percentage against
// `scale`, or the `none` keyword (which resolves to zero for our purposes).
function parseColorComponent(token, scale = 1) {
if (token == null) return null;
const t = String(token).trim();
if (/^none$/i.test(t)) return 0;
const num = parseFloat(t);
if (!Number.isFinite(num)) return null;
return t.endsWith('%') ? (num / 100) * scale : num;
}
function parseAlphaToken(token) {
if (token == null) return 1;
const t = String(token).trim();
if (/^none$/i.test(t)) return 1;
const num = parseFloat(t);
if (!Number.isFinite(num)) return 1;
return t.endsWith('%') ? num / 100 : num;
}
// Extended color parser: rgb/rgba/hex/oklch/oklab/lch/lab/hsl/hwb/color()/
// color-mix/common named colors. Returns null on no match. Use this when the
// input might be any CSS color form; use plain parseRgb when you only expect
// computed rgb() values from real browsers.
function parseAnyColor(s) {
if (!s || typeof s !== 'string') return null;
const str = s.trim();
if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
if (/^color-mix\(/i.test(str)) return parseColorMix(str);
let m;
m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/);
if (m) {
const c = { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: 1 };
if (m[4] !== undefined) c.a = m[5] === '%' ? parseFloat(m[4]) / 100 : +m[4];
return c;
}
m = str.match(/^#([0-9a-f]{3,8})$/i);
if (m) {
const h = m[1];
if (h.length === 3 || h.length === 4) {
return {
r: parseInt(h[0] + h[0], 16),
g: parseInt(h[1] + h[1], 16),
b: parseInt(h[2] + h[2], 16),
a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
};
}
if (h.length === 6 || h.length === 8) {
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
};
}
}
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
// Match L (with optional %), then C and H separated permissively.
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const Lnum = parseFloat(m[1]);
const L = m[2] === '%' ? Lnum / 100 : Lnum;
const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
if (m[5] !== undefined) {
const alpha = parseFloat(m[5]);
rgb.a = m[6] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// OKLAB — a/b are signed axes; percentages map 100% → 0.4.
m = str.match(/oklab\(\s*([\d.]+)(%?)\s+(-?[\d.]+)(%?)\s+(-?[\d.]+)(%?)(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const L = m[2] === '%' ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
const a = m[4] === '%' ? parseFloat(m[3]) * 0.004 : parseFloat(m[3]);
const b = m[6] === '%' ? parseFloat(m[5]) * 0.004 : parseFloat(m[5]);
const rgb = oklabToRgb(L, a, b);
if (m[7] !== undefined) {
const alpha = parseFloat(m[7]);
rgb.a = m[8] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// LCH / LAB — CIE, D50 white point. Chrome serializes lch(20% 5 60) as
// `lch(20 5 60)`, so L arrives with or without its percent sign. In both
// spaces L runs 0..100 and 100% means 100.
m = str.match(/^lch\(\s*([\d.]+%?|none)\s+([\d.]+%?|none)\s+(-?[\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
if (m) {
const L = parseColorComponent(m[1], 100);
const C = parseColorComponent(m[2], 150);
const H = parseFloat(m[3]);
if (L == null || C == null || !Number.isFinite(H)) return null;
const rgb = lchToRgb(L, C, H);
rgb.a = parseAlphaToken(m[4]);
return rgb;
}
m = str.match(/^lab\(\s*([\d.]+%?|none)\s+(-?[\d.]+%?|none)\s+(-?[\d.]+%?|none)(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
if (m) {
const L = parseColorComponent(m[1], 100);
const a = parseColorComponent(m[2], 125);
const b = parseColorComponent(m[3], 125);
if (L == null || a == null || b == null) return null;
const rgb = labToRgb(L, a, b);
rgb.a = parseAlphaToken(m[4]);
return rgb;
}
// color(<space> c1 c2 c3 [/ alpha]) — what Chrome hands back for most
// color-mix() results and for any wide-gamut color an author wrote.
m = str.match(/^color\(\s*([a-z0-9-]+)\s+(-?[\d.eE+-]+%?|none)\s+(-?[\d.eE+-]+%?|none)\s+(-?[\d.eE+-]+%?|none)(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
if (m) {
const c1 = parseColorComponent(m[2]);
const c2 = parseColorComponent(m[3]);
const c3 = parseColorComponent(m[4]);
if (c1 == null || c2 == null || c3 == null) return null;
const rgb = colorFunctionToRgb(m[1].toLowerCase(), c1, c2, c3);
if (!rgb) return null;
rgb.a = parseAlphaToken(m[5]);
return rgb;
}
// HSL/HSLA — comma or space syntax, optional deg on hue.
m = str.match(/hsla?\(\s*(-?[\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%\s*[,\s]\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hslToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// HWB — hue whiteness% blackness%.
m = str.match(/hwb\(\s*(-?[\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hwbToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
const named = CSS_NAMED_COLORS[str.toLowerCase()];
if (named) return { ...named, a: 1 };
return null;
}
// True when a computed background-color string names no paint at all. Used to
// tell "this layer is see-through" (walk on to the ancestor) apart from "this
// layer has a color we could not read" (stop and abstain).
//
// `inherit` belongs here even though it is not literally see-through: it means
// "paint with the parent's background-color", and walking on to the parent IS
// that resolution. Real browsers resolve the keyword before getComputedStyle
// output; only jsdom's partial cascade hands it through verbatim, and treating
// it as unreadable would make the walk abstain on a surface it can know.
// (`currentcolor` is NOT here — it is real paint in the element's own text
// color; resolveBackgroundInfo substitutes the computed color for it.)
function isNoPaintColorValue(value) {
const v = String(value || '').trim().toLowerCase();
if (!v) return true;
return v === 'transparent' || v === 'none' || v === 'initial' || v === 'inherit' || v === 'unset' || v === 'revert' || v === 'revert-layer';
}
export {
isNeutralColor,
parseRgb,
relativeLuminance,
contrastRatio,
parseGradientColors,
extractColorFunctionTokens,
hasChroma,
getHue,
colorToHex,
oklabToRgb,
oklchToRgb,
labToRgb,
lchToRgb,
colorFunctionToRgb,
hslToRgb,
hwbToRgb,
CSS_NAMED_COLORS,
splitTopLevelCommas,
parseColorMix,
parseAnyColor,
compositeColorOver,
isNoPaintColorValue,
};
File diff suppressed because it is too large Load Diff
@@ -11,14 +11,21 @@ import {
isBrandFontOnOwnDomain,
} from '../shared/constants.mjs';
import {
CSS_NAMED_COLORS,
colorToHex,
compositeColorOver,
contrastRatio,
getHue,
hasChroma,
isNeutralColor,
isNoPaintColorValue,
oklchToRgb,
parseAnyColor,
parseColorMix,
parseGradientColors,
parseRgb,
relativeLuminance,
splitTopLevelCommas,
} from '../shared/color.mjs';
import { extractGoogleFontFamilies } from '../shared/fonts.mjs';
@@ -1731,7 +1738,7 @@ function readOwnBackgroundColor(el, computedStyle) {
// One element's background-color as the cascade walk sees it: computed style
// first (with the modern-color fallback), then, in static mode only,
// custom-prop resolution and the inline-shorthand peek. Shared by
// resolveBackground and resolveGradientStops so both walks read the same
// resolveBackgroundInfo and resolveGradientStops so both walks read the same
// surfaces.
function readCascadeBackgroundColor(current, style, customPropMap) {
let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
@@ -1755,7 +1762,19 @@ function readCascadeBackgroundColor(current, style, customPropMap) {
return bg;
}
function resolveBackground(el, win, customPropMap) {
// Walk up for the surface the element's text is painted on.
//
// Returns { color, unresolved }:
// • color set — the effective surface, overlays composited in.
// • unresolved: true — a layer on the way up paints a color this parser
// cannot read, so the surface is unknown. Callers
// must SKIP their contrast checks. Guessing white
// here is what flooded dark themes with false
// "on #ffffff" findings: one abstention costs a
// single finding, one wrong guess costs a hundred.
// • both null/false — no solid color, but a gradient or image is in
// play; callers fall back to its color stops.
function resolveBackgroundInfo(el, win, customPropMap) {
let current = el;
// Translucent layers (0.1 < a < 1) found on the way down to an opaque
// base. A browser composites these over the base; the old behavior
@@ -1783,59 +1802,81 @@ function resolveBackground(el, win, customPropMap) {
// body backgrounds.
// Real browsers serialize wide-gamut computed values as oklab()/oklch()
// (e.g. any color-mix() result), which plain parseRgb misses.
const bg = readCascadeBackgroundColor(current, style, customPropMap);
let bg = readCascadeBackgroundColor(current, style, customPropMap);
// `background-color: currentcolor` paints with the element's own text
// color — real paint whose value we know. Real browsers resolve the
// keyword before getComputedStyle output; jsdom hands it through
// verbatim, and without this substitution the layer would read as
// unparseable and force a needless abstention.
if ((!bg || bg.a < 0.1) && /^currentcolor$/i.test(String(style.backgroundColor || '').trim())) {
// The static cascade resolves var() text tokens before checks run, so
// style.color is normally already an rgb string here; parseColorResolved
// is defense in depth for any future caller that passes a live
// customPropMap (it matches the text-color path in checkElementColors
// and reduces to parseAnyColor when the map is null or absent).
bg = parseRgb(style.color) || parseColorResolved(style.color, customPropMap);
}
if (bg && bg.a > 0.1) {
if (bg.a >= 0.99) return flatten(bg);
if (bg.a >= 0.99) return { color: flatten(bg), unresolved: false };
overlays.push(bg);
} else if (!bg && !isNoPaintColorValue(style.backgroundColor)) {
// This layer names a color we could not parse (a color space we do not
// model, an unresolved var(), a syntax newer than the parser). It may
// well be opaque, which would make every ancestor below it invisible —
// so the surface is unknown and the walk stops here rather than
// reporting an ancestor the visitor never sees.
return { color: null, unresolved: true };
}
// No solid bg-color at this level, but this level paints an image. CSS
// stacks background-image layers first-on-top, so which layer leads
// decides what the visitor sees:
// • gradient on top — the gradient is the surface. Hand the caller a
// null color so it falls back to the gradient's own stops (body
// grounds, gradient buttons, hero sections).
// • url() on top — the surface is an image whose pixels this engine
// cannot read, and it may fully cover every layer and ancestor
// beneath it. Same contract as an unparseable color: abstain, so
// the gradient-stop fallback never measures a gradient the image
// hides (the shipped miss: `url(photo), linear-gradient(...)`
// reported low-contrast against the invisible gradient's stops).
if (hasGradientOrUrl) {
const layers = splitTopLevelCommas(bgImage);
const topPaintLayer = layers.find(
(layer) => /gradient\s*\(/i.test(layer) || /url\s*\(/i.test(layer),
);
const gradientOnTop = !!topPaintLayer
&& /gradient\s*\(/i.test(topPaintLayer)
&& !/^\s*url\s*\(/i.test(topPaintLayer);
if (!gradientOnTop) return { color: null, unresolved: true };
// Gradient on top of a url() layer: the image shows through wherever
// the gradient is not fully opaque, so a translucent wash like
// `linear-gradient(rgba(0,0,0,.2), rgba(0,0,0,.2)), url(photo)` paints
// a blend with pixels this engine cannot read. Only a gradient whose
// every readable stop is opaque provably covers the image; otherwise
// the surface is unknown — abstain rather than hand callers gradient
// stops (or a stop average) the visitor never sees unmixed.
const urlBeneath = layers.some(
(layer) => layer !== topPaintLayer && /url\s*\(/i.test(layer),
);
if (urlBeneath) {
const topStops = parseGradientColors(topPaintLayer);
const provablyOpaque = topStops.length > 0 && topStops.every((s) => (s.a ?? 1) >= 0.99);
if (!provablyOpaque) return { color: null, unresolved: true };
}
return { color: null, unresolved: false };
}
// No solid bg-color at this level. If THIS level has a gradient/url
// with no underlying solid color we can read:
// • on body/html: assume white. Body-level gradients are almost
// always decorative texture (paper grain, noise) on top of a
// solid bg-color the page set via `background: var(--paper)`
// shorthand — which jsdom can't decompose into bg-color. The
// downstream gradient-stops fallback path produces catastrophic
// false positives in this case (gradient noise stops have
// accidental browns/blacks that look like card backgrounds).
// • on other elements: bail to null and let the caller fall back
// to gradient stops (gradient buttons / hero sections are real
// bgs worth checking against).
// A gradient or image with no solid color under it, at any level
// including body/html, means the visible ground is that layer itself.
// Return null so the caller measures against the actual gradient stops,
// or skips when nothing is parseable — skipping beats a wrong ratio.
//
// Body/html used to assume white here, a guard written for jsdom, which
// never decomposed the `background:` shorthand and so could not see the
// solid paper color a texture gradient usually sits on. It turned every
// light-on-dark page into a wall of low-contrast false positives (a dark
// oklch body gradient produced ~120 "on #ffffff" findings on one site).
// Both engines can see shorthand solids now — the browser natively, the
// static cascade via expandStaticDeclaration + var() resolution — so a
// missing solid is real, and the old failure case cannot recur: opaque
// stops fully cover any hidden solid (they ARE the ground), alpha stops
// composite over the resolved base or the white canvas default, and
// unresolvable stops drop rather than guess.
if (hasGradientOrUrl) return null;
current = current.parentElement;
}
return flatten({ r: 255, g: 255, b: 255, a: 1 });
// Every layer up to the document root was genuinely see-through, so the
// browser paints its default canvas. This is the ONLY case that earns the
// white assumption.
return { color: flatten({ r: 255, g: 255, b: 255, a: 1 }), unresolved: false };
}
// parseGradientColors (shared) reads only the legacy serializations: rgb()
// and hex stops. Browsers keep modern-space stops in computed backgroundImage
// exactly as authored — `linear-gradient(oklch(7% 0.006 95), …)` stays oklch —
// which is what every token-driven page produces. Route those through
// parseAnyColor so a gradient ground is measurable rather than invisible.
function parseGradientColorsModern(bgImage) {
if (!bgImage || !/gradient/i.test(bgImage)) return [];
const colors = parseGradientColors(bgImage);
for (const m of bgImage.matchAll(/(?:oklch|oklab|hsla?|hwb)\(\s*[^()]*\)/gi)) {
const c = parseAnyColor(m[0]);
if (c) colors.push(c);
}
return colors;
function resolveBackground(el, win, customPropMap) {
return resolveBackgroundInfo(el, win, customPropMap).color;
}
// Walk parents looking for a gradient background and return its color stops.
@@ -1861,7 +1902,10 @@ function resolveGradientStops(el, win, customPropMap) {
if (bgImage && bgImage !== 'none' && /url\s*\(/i.test(bgImage)) return null;
let stops = null;
if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
const parsed = parseGradientColorsModern(bgImage);
// parseGradientColors (shared) reads modern-space stops too — oklch,
// color-mix and friends via balanced-paren token capture — so browser
// computed values that keep the authored syntax stay measurable.
const parsed = parseGradientColors(bgImage);
if (parsed.length > 0) stops = parsed;
}
if (!stops && !DETECTOR_IS_BROWSER) {
@@ -1869,7 +1913,7 @@ function resolveGradientStops(el, win, customPropMap) {
const rawStyle = current.getAttribute?.('style') || '';
const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
if (bgMatch && /gradient/i.test(bgMatch[1])) {
const parsed = parseGradientColorsModern(bgMatch[1]);
const parsed = parseGradientColors(bgMatch[1]);
if (parsed.length > 0) stops = parsed;
}
}
@@ -2115,13 +2159,19 @@ function checkElementColorsDOM(el) {
if (style.visibility === 'hidden' || effectiveOpacityDOM(el) <= 0.02) return [];
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
let effectiveBg = resolveBackground(el);
const bgInfo = resolveBackgroundInfo(el);
let effectiveBg = bgInfo.color;
// An unreadable surface anywhere up the chain: skip the gradient-stop
// fallback too, so nothing downstream measures against a ground we never
// resolved.
let surfaceUnresolved = bgInfo.unresolved;
let ownBg = readOwnBackgroundColor(el, style);
if (!ownBg || (ownBg.a ?? 1) <= 0.5) {
const pseudoSurface = readPseudoSurfaceDOM(el, rect);
if (pseudoSurface) {
ownBg = pseudoSurface;
effectiveBg = pseudoSurface;
surfaceUnresolved = false;
}
}
return checkColors({
@@ -2133,8 +2183,8 @@ function checkElementColorsDOM(el) {
// an oklch token near its own oklch background).
textColor: parseRgb(style.color) || parseAnyColor(style.color),
bgColor: ownBg,
effectiveBg,
effectiveBgStops: effectiveBg ? null : resolveGradientStops(el),
effectiveBg: surfaceUnresolved ? null : effectiveBg,
effectiveBgStops: surfaceUnresolved || effectiveBg ? null : resolveGradientStops(el),
fontSize: parseFloat(style.fontSize) || 16,
fontWeight: parseInt(style.fontWeight) || 400,
hasDirectText,
@@ -2284,283 +2334,6 @@ function resolveVarRefs(raw, customPropMap, depth = 0) {
});
}
// OKLCH → sRGB conversion (Björn Ottosson's matrices). L in 0..1 (or %),
// C in 0..~0.4 typical, H in degrees. Returns clamped {r,g,b,a:1} in 0..255.
// Needed because jsdom doesn't compute oklch() values — getComputedStyle
// returns the literal "oklch(...)" string. Without this, the entire
// Tailwind v4 color palette (which is OKLCH-based) is invisible to the
// detector's contrast / color checks.
function oklchToRgb(L, C, H) {
const hRad = (H * Math.PI) / 180;
return oklabToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
}
function oklabToRgb(L, a, b) {
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
const rLin = 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc;
const gLin = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc;
const bLin = -0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc;
const enc = (x) => {
const c = Math.max(0, Math.min(1, x));
return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
};
return {
r: Math.round(enc(rLin) * 255),
g: Math.round(enc(gLin) * 255),
b: Math.round(enc(bLin) * 255),
a: 1,
};
}
function hslToRgb(h, s, l) {
h = ((h % 360) + 360) % 360;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m0 = l - c / 2;
const [r, g, b] =
h < 60 ? [c, x, 0] :
h < 120 ? [x, c, 0] :
h < 180 ? [0, c, x] :
h < 240 ? [0, x, c] :
h < 300 ? [x, 0, c] : [c, 0, x];
return {
r: Math.round((r + m0) * 255),
g: Math.round((g + m0) * 255),
b: Math.round((b + m0) * 255),
a: 1,
};
}
function hwbToRgb(h, w, bl) {
if (w + bl >= 1) {
const g = Math.round((w / (w + bl)) * 255);
return { r: g, g, b: g, a: 1 };
}
const base = hslToRgb(h, 1, 0.5);
const mix = (c) => Math.round(((c / 255) * (1 - w - bl) + w) * 255);
return { r: mix(base.r), g: mix(base.g), b: mix(base.b), a: 1 };
}
// Common CSS named colors — the handful that actually show up in generated
// UIs, not the full 148-name spec list. Includes the achromatic names so a
// named gray parses (and correctly reads as no-chroma) instead of being
// treated as an unknown color.
const CSS_NAMED_COLORS = {
black: { r: 0, g: 0, b: 0 },
white: { r: 255, g: 255, b: 255 },
gray: { r: 128, g: 128, b: 128 },
grey: { r: 128, g: 128, b: 128 },
silver: { r: 192, g: 192, b: 192 },
dimgray: { r: 105, g: 105, b: 105 },
darkgray: { r: 169, g: 169, b: 169 },
lightgray: { r: 211, g: 211, b: 211 },
gainsboro: { r: 220, g: 220, b: 220 },
whitesmoke: { r: 245, g: 245, b: 245 },
red: { r: 255, g: 0, b: 0 },
crimson: { r: 220, g: 20, b: 60 },
tomato: { r: 255, g: 99, b: 71 },
coral: { r: 255, g: 127, b: 80 },
salmon: { r: 250, g: 128, b: 114 },
orange: { r: 255, g: 165, b: 0 },
gold: { r: 255, g: 215, b: 0 },
yellow: { r: 255, g: 255, b: 0 },
olive: { r: 128, g: 128, b: 0 },
lime: { r: 0, g: 255, b: 0 },
green: { r: 0, g: 128, b: 0 },
teal: { r: 0, g: 128, b: 128 },
turquoise: { r: 64, g: 224, b: 208 },
cyan: { r: 0, g: 255, b: 255 },
aqua: { r: 0, g: 255, b: 255 },
skyblue: { r: 135, g: 206, b: 235 },
dodgerblue: { r: 30, g: 144, b: 255 },
blue: { r: 0, g: 0, b: 255 },
navy: { r: 0, g: 0, b: 128 },
indigo: { r: 75, g: 0, b: 130 },
rebeccapurple: { r: 102, g: 51, b: 153 },
purple: { r: 128, g: 0, b: 128 },
violet: { r: 238, g: 130, b: 238 },
orchid: { r: 218, g: 112, b: 214 },
magenta: { r: 255, g: 0, b: 255 },
fuchsia: { r: 255, g: 0, b: 255 },
hotpink: { r: 255, g: 105, b: 180 },
pink: { r: 255, g: 192, b: 203 },
maroon: { r: 128, g: 0, b: 0 },
};
// Split a string on top-level commas (ignoring commas nested in parens).
function splitTopLevelCommas(str) {
const parts = [];
let depth = 0, start = 0;
for (let i = 0; i < str.length; i++) {
const ch = str[i];
if (ch === '(') depth++;
else if (ch === ')') depth = Math.max(0, depth - 1);
else if (ch === ',' && depth === 0) {
parts.push(str.slice(start, i).trim());
start = i + 1;
}
}
const tail = str.slice(start).trim();
if (tail) parts.push(tail);
return parts;
}
// Evaluate a CSS color-mix() expression to {r,g,b,a}. Returns null when
// the expression can't be resolved (unresolved var(), unknown colors).
//
// Mixing is done with premultiplied alpha in sRGB regardless of the
// declared interpolation space. That is exact for the dominant generated-UI
// pattern — `color-mix(in oklab, <color> N%, transparent)` — where the
// result is simply <color> at alpha N% in ANY rectangular space, and a
// close-enough approximation for opaque-opaque mixes (the detector only
// consumes these values for contrast/chroma thresholds, not for display).
function parseColorMix(str) {
const m = String(str).trim().match(/^color-mix\(/i);
if (!m) return null;
// Balanced-paren capture of the arguments.
let depth = 0, end = -1;
const open = str.indexOf('(');
for (let i = open; i < str.length; i++) {
if (str[i] === '(') depth++;
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
}
if (end < 0) return null;
const args = splitTopLevelCommas(str.slice(open + 1, end));
if (args.length !== 3 || !/^in\s/i.test(args[0])) return null;
const parseComponent = (component) => {
// Percentage may lead or trail the color per spec.
let pct = null;
let colorStr = component;
const trail = component.match(/\s+([\d.]+)%$/);
const lead = component.match(/^([\d.]+)%\s+/);
if (trail) { pct = parseFloat(trail[1]); colorStr = component.slice(0, trail.index).trim(); }
else if (lead) { pct = parseFloat(lead[1]); colorStr = component.slice(lead[0].length).trim(); }
let color;
if (/^transparent$/i.test(colorStr)) color = { r: 0, g: 0, b: 0, a: 0 };
else color = parseAnyColor(colorStr);
if (!color) return null;
return { color, pct };
};
const c1 = parseComponent(args[1]);
const c2 = parseComponent(args[2]);
if (!c1 || !c2) return null;
let p1 = c1.pct, p2 = c2.pct;
if (p1 == null && p2 == null) { p1 = 50; p2 = 50; }
else if (p1 == null) p1 = 100 - p2;
else if (p2 == null) p2 = 100 - p1;
const sum = p1 + p2;
if (sum <= 0) return null;
// Per spec: weights normalize to sum; when sum < 100 the result alpha is
// additionally scaled by sum/100.
const w1 = p1 / sum, w2 = p2 / sum;
const alphaScale = sum < 100 ? sum / 100 : 1;
const a1 = c1.color.a ?? 1, a2 = c2.color.a ?? 1;
const a = (a1 * w1 + a2 * w2) * alphaScale;
if (a <= 0) return { r: 0, g: 0, b: 0, a: 0 };
const mix = (ch) => Math.round((c1.color[ch] * a1 * w1 + c2.color[ch] * a2 * w2) / (a1 * w1 + a2 * w2));
return { r: mix('r'), g: mix('g'), b: mix('b'), a: Math.min(1, a) };
}
// Composite a translucent color over an opaque(ish) base (simple
// source-over in sRGB). Returns an opaque {r,g,b,a:1}.
function compositeColorOver(top, base) {
const a = top.a ?? 1;
return {
r: Math.round(top.r * a + base.r * (1 - a)),
g: Math.round(top.g * a + base.g * (1 - a)),
b: Math.round(top.b * a + base.b * (1 - a)),
a: 1,
};
}
// Extended color parser: rgb/rgba/hex/oklch/oklab/hsl/hwb/color-mix/common
// named colors. Returns null on no match. Use this when the input might be
// any CSS color form; use plain parseRgb when you only expect computed rgb()
// values from real browsers.
function parseAnyColor(s) {
if (!s || typeof s !== 'string') return null;
const str = s.trim();
if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
if (/^color-mix\(/i.test(str)) return parseColorMix(str);
let m;
m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+))?\s*\)/);
if (m) return { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: m[4] !== undefined ? +m[4] : 1 };
m = str.match(/^#([0-9a-f]{3,8})$/i);
if (m) {
const h = m[1];
if (h.length === 3 || h.length === 4) {
return {
r: parseInt(h[0] + h[0], 16),
g: parseInt(h[1] + h[1], 16),
b: parseInt(h[2] + h[2], 16),
a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
};
}
if (h.length === 6 || h.length === 8) {
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
};
}
}
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
// Match L (with optional %), then C and H separated permissively.
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const Lnum = parseFloat(m[1]);
const L = m[2] === '%' ? Lnum / 100 : Lnum;
const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
if (m[5] !== undefined) {
const alpha = parseFloat(m[5]);
rgb.a = m[6] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// OKLAB — a/b are signed axes; percentages map 100% → 0.4.
m = str.match(/oklab\(\s*([\d.]+)(%?)\s+(-?[\d.]+)(%?)\s+(-?[\d.]+)(%?)(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const L = m[2] === '%' ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
const a = m[4] === '%' ? parseFloat(m[3]) * 0.004 : parseFloat(m[3]);
const b = m[6] === '%' ? parseFloat(m[5]) * 0.004 : parseFloat(m[5]);
const rgb = oklabToRgb(L, a, b);
if (m[7] !== undefined) {
const alpha = parseFloat(m[7]);
rgb.a = m[8] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// HSL/HSLA — comma or space syntax, optional deg on hue.
m = str.match(/hsla?\(\s*(-?[\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%\s*[,\s]\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hslToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// HWB — hue whiteness% blackness%.
m = str.match(/hwb\(\s*(-?[\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hwbToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
const named = CSS_NAMED_COLORS[str.toLowerCase()];
if (named) return { ...named, a: 1 };
return null;
}
// Resolve var() refs in a color string (via customPropMap), then parse.
// Returns null on any failure. Used in jsdom-mode paths where
@@ -2923,15 +2696,24 @@ function checkElementGlowDOM(el) {
if (!boxShadow && !textShadow) return [];
// Use parent's background — glow radiates outward, so the surrounding context matters
// If resolveBackground returns null (gradient), try to infer from the gradient colors
let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el);
if (!parentBg) {
const parentBgInfo = resolveBackgroundInfo(el.parentElement || el);
// Unknown surface (an unreadable layer on the way up): skip only the
// gradient hunt below, which would walk PAST that layer and score the
// glow against a background the visitor never sees. checkGlow still runs
// with a null surface: the zero-offset chromatic halo tell holds on ANY
// background, and the static loop already passes the unresolved walk's
// null color straight through (detect-html.mjs uses resolveBackground).
let parentBg = parentBgInfo.color;
if (!parentBg && !parentBgInfo.unresolved) {
// Gradient background — sample its colors to determine if it's dark.
// Modern-syntax parsing matters here: body-level gradients now reach this
// fallback in browser mode, and their stops usually serialize as oklch.
// fallback in browser mode, and their stops usually serialize as oklch
// which the shared parseGradientColors reads via its color-function
// token capture.
let cur = el.parentElement;
while (cur && cur.nodeType === 1) {
const bgImage = getComputedStyle(cur).backgroundImage || '';
const gradColors = parseGradientColorsModern(bgImage);
const gradColors = parseGradientColors(bgImage);
if (gradColors.length > 0) {
// Average the gradient colors
const avg = { r: 0, g: 0, b: 0 };
@@ -2975,10 +2757,13 @@ function checkElementAIPaletteDOM(el) {
const hue = getHue(textColor);
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
if (isAIPalette) {
const parentBg = el.parentElement ? resolveBackground(el.parentElement) : null;
// Also check gradient parents
let effectiveBg = parentBg;
if (!effectiveBg) {
const parentBgInfo = el.parentElement
? resolveBackgroundInfo(el.parentElement)
: { color: null, unresolved: false };
// Unknown surface: leave effectiveBg null (no finding) rather than
// hunting gradient ancestors past a layer we could not read.
let effectiveBg = parentBgInfo.color;
if (!effectiveBg && !parentBgInfo.unresolved) {
let cur = el.parentElement;
while (cur && cur.nodeType === 1) {
const gi = getComputedStyle(cur).backgroundImage || '';
@@ -3784,7 +3569,8 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
const effectiveBg = resolveBackground(el, window, customPropMap);
const bgInfo = resolveBackgroundInfo(el, window, customPropMap);
const effectiveBg = bgInfo.color;
// jsdom returns literal "var(--X)" / "oklch(...)" for color, so plain
// parseRgb misses Tailwind-tokenized text colors. Resolve through the
// customPropMap first; fall back to parseRgb for vanilla rgb() pages.
@@ -3830,11 +3616,13 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
// element itself has no usable own background, that pseudo is the real
// surface for contrast purposes.
let finalEffectiveBg = effectiveBg;
let surfaceUnresolved = bgInfo.unresolved;
if ((!ownBg || (ownBg.a ?? 1) <= 0.5) && typeof window.getPseudoSurface === 'function') {
const pseudoSurface = window.getPseudoSurface(el);
if (pseudoSurface) {
ownBg = pseudoSurface;
finalEffectiveBg = pseudoSurface;
surfaceUnresolved = false;
}
}
@@ -3842,8 +3630,9 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
tag,
textColor,
bgColor: ownBg,
effectiveBg: finalEffectiveBg,
effectiveBgStops: finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
// Unknown surface: hand the checks nothing rather than a guess.
effectiveBg: surfaceUnresolved ? null : finalEffectiveBg,
effectiveBgStops: surfaceUnresolved || finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
fontSize: parseFloat(style.fontSize) || 16,
fontWeight: parseInt(style.fontWeight) || 400,
hasDirectText,
@@ -5663,6 +5452,7 @@ export {
checkHtmlPatterns,
readOwnBackgroundColor,
resolveBackground,
resolveBackgroundInfo,
resolveGradientStops,
parseRadiusToPx,
resolveBorderRadiusPx,
@@ -71,11 +71,43 @@ function contrastRatio(c1, c2) {
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
// The CSS color functions worth pulling out of a longer declaration. The set
// is deliberately closed: `linear-gradient(` and `url(` also look like
// `name(` and must not be read as colors.
const COLOR_FUNCTION_NAMES = new Set([
'rgb', 'rgba', 'hsl', 'hsla', 'hwb', 'oklch', 'oklab', 'lch', 'lab', 'color', 'color-mix',
]);
// Pull every color-function token out of a value, with balanced-paren capture
// so nested forms (`color-mix(in oklab, oklch(...) 20%, transparent)`) survive
// whole. Returns the raw substrings in source order.
function extractColorFunctionTokens(value) {
const str = String(value || '');
const tokens = [];
const re = /([a-z][a-z-]*)\(/gi;
let m;
while ((m = re.exec(str)) !== null) {
if (!COLOR_FUNCTION_NAMES.has(m[1].toLowerCase())) continue;
let depth = 0, end = -1;
for (let i = m.index + m[0].length - 1; i < str.length; i++) {
if (str[i] === '(') depth++;
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
}
if (end < 0) break;
tokens.push(str.slice(m.index, end + 1));
re.lastIndex = end + 1;
}
return tokens;
}
function parseGradientColors(bgImage) {
if (!bgImage || !bgImage.includes('gradient')) return [];
const colors = [];
for (const m of bgImage.matchAll(/rgba?\([^)]+\)/g)) {
const c = parseRgb(m[0]);
// Stops arrive in whatever syntax the author wrote and the browser kept.
// A dark ground painted as `linear-gradient(oklch(...), oklch(...))` used
// to read as a gradient with no stops at all.
for (const token of extractColorFunctionTokens(bgImage)) {
const c = parseAnyColor(token);
if (c) colors.push(c);
}
for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
@@ -112,13 +144,445 @@ function colorToHex(c) {
return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
}
// ─── Color-space conversions ────────────────────────────────────────────────
//
// Every function here lands on 8-bit sRGB, clamped to gamut. Chrome, Safari,
// and Firefox all keep the authored color space in getComputedStyle output
// (`oklch(0.84 0.19 80.46)`, `lch(20 5 60)`, `color(srgb 1.04 0.72 -0.21)`),
// so a detector that only reads rgb() is blind on any modern palette. The
// expected outputs are pinned in tests/detect-antipatterns.test.js against
// what Chrome itself paints for the same strings.
function clamp01(x) {
return Number.isFinite(x) ? Math.max(0, Math.min(1, x)) : 0;
}
// Linear-light sRGB channel to the encoded 0-255 value.
function encodeSrgbChannel(x) {
const c = clamp01(x);
return Math.round((c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055) * 255);
}
function decodeSrgbChannel(x) {
const c = Number.isFinite(x) ? x : 0;
const sign = c < 0 ? -1 : 1;
const abs = Math.abs(c);
return sign * (abs <= 0.04045 ? abs / 12.92 : Math.pow((abs + 0.055) / 1.055, 2.4));
}
function linearSrgbToColor(r, g, b, a = 1) {
return { r: encodeSrgbChannel(r), g: encodeSrgbChannel(g), b: encodeSrgbChannel(b), a };
}
// OKLab to sRGB (Björn Ottosson's matrices). L in 0..1, a/b are signed axes.
function oklabToRgb(L, a, b) {
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
return linearSrgbToColor(
4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc,
-1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc,
-0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc,
);
}
// OKLCH to sRGB. L in 0..1, C in 0..~0.4 typical, H in degrees. Chroma past
// the sRGB gamut clamps per channel rather than producing NaN.
function oklchToRgb(L, C, H) {
const hRad = (H * Math.PI) / 180;
return oklabToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
}
// CIE Lab to sRGB. CSS lab()/lch() use the D50 white point; the matrix below
// is the Bradford-adapted XYZ-D50 to linear-sRGB transform from CSS Color 4.
function labToRgb(L, a, b) {
const kappa = 24389 / 27, epsilon = 216 / 24389;
const fy = (L + 16) / 116, fx = fy + a / 500, fz = fy - b / 200;
const invert = (t) => (t * t * t > epsilon ? t * t * t : (116 * t - 16) / kappa);
const yr = L > kappa * epsilon ? Math.pow((L + 16) / 116, 3) : L / kappa;
const Xn = 0.3457 / 0.3585, Zn = (1 - 0.3457 - 0.3585) / 0.3585;
const x = invert(fx) * Xn, y = yr, z = invert(fz) * Zn;
return linearSrgbToColor(
3.1341359569958707 * x - 1.6173863321612538 * y - 0.4906619460083532 * z,
-0.9787955029120890 * x + 1.9162545672595240 * y + 0.0334427311613195 * z,
0.0719553798841168 * x - 0.2289768264158322 * y + 1.4053860583241250 * z,
);
}
function lchToRgb(L, C, H) {
const hRad = (H * Math.PI) / 180;
return labToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
}
// color(<space> c1 c2 c3) for the spaces that turn up in real stylesheets.
// `srgb` is what Chrome serializes most color-mix() results into, routinely
// with channels outside 0..1. Spaces we do not model return null so callers
// abstain instead of measuring against a color we invented.
function colorFunctionToRgb(space, c1, c2, c3) {
switch (space) {
case 'srgb':
return { r: Math.round(clamp01(c1) * 255), g: Math.round(clamp01(c2) * 255), b: Math.round(clamp01(c3) * 255), a: 1 };
case 'srgb-linear':
return linearSrgbToColor(c1, c2, c3);
case 'display-p3': {
const [R, G, B] = [decodeSrgbChannel(c1), decodeSrgbChannel(c2), decodeSrgbChannel(c3)];
return linearSrgbToColor(
1.2249401762805587 * R - 0.2249404646817506 * G + 0.0000002884022551 * B,
-0.0420569547096138 * R + 1.0420571661298634 * G - 0.0000002113202247 * B,
-0.0196375587040044 * R - 0.0786360772174755 * G + 1.0982736359214800 * B,
);
}
default:
return null;
}
}
function hslToRgb(h, s, l) {
h = ((h % 360) + 360) % 360;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m0 = l - c / 2;
const [r, g, b] =
h < 60 ? [c, x, 0] :
h < 120 ? [x, c, 0] :
h < 180 ? [0, c, x] :
h < 240 ? [0, x, c] :
h < 300 ? [x, 0, c] : [c, 0, x];
return {
r: Math.round((r + m0) * 255),
g: Math.round((g + m0) * 255),
b: Math.round((b + m0) * 255),
a: 1,
};
}
function hwbToRgb(h, w, bl) {
if (w + bl >= 1) {
const g = Math.round((w / (w + bl)) * 255);
return { r: g, g, b: g, a: 1 };
}
const base = hslToRgb(h, 1, 0.5);
const mix = (c) => Math.round(((c / 255) * (1 - w - bl) + w) * 255);
return { r: mix(base.r), g: mix(base.g), b: mix(base.b), a: 1 };
}
// Common CSS named colors — the handful that actually show up in generated
// UIs, not the full 148-name spec list. Includes the achromatic names so a
// named gray parses (and correctly reads as no-chroma) instead of being
// treated as an unknown color.
const CSS_NAMED_COLORS = {
black: { r: 0, g: 0, b: 0 },
white: { r: 255, g: 255, b: 255 },
gray: { r: 128, g: 128, b: 128 },
grey: { r: 128, g: 128, b: 128 },
silver: { r: 192, g: 192, b: 192 },
dimgray: { r: 105, g: 105, b: 105 },
darkgray: { r: 169, g: 169, b: 169 },
lightgray: { r: 211, g: 211, b: 211 },
gainsboro: { r: 220, g: 220, b: 220 },
whitesmoke: { r: 245, g: 245, b: 245 },
red: { r: 255, g: 0, b: 0 },
crimson: { r: 220, g: 20, b: 60 },
tomato: { r: 255, g: 99, b: 71 },
coral: { r: 255, g: 127, b: 80 },
salmon: { r: 250, g: 128, b: 114 },
orange: { r: 255, g: 165, b: 0 },
gold: { r: 255, g: 215, b: 0 },
yellow: { r: 255, g: 255, b: 0 },
olive: { r: 128, g: 128, b: 0 },
lime: { r: 0, g: 255, b: 0 },
green: { r: 0, g: 128, b: 0 },
teal: { r: 0, g: 128, b: 128 },
turquoise: { r: 64, g: 224, b: 208 },
cyan: { r: 0, g: 255, b: 255 },
aqua: { r: 0, g: 255, b: 255 },
skyblue: { r: 135, g: 206, b: 235 },
dodgerblue: { r: 30, g: 144, b: 255 },
blue: { r: 0, g: 0, b: 255 },
navy: { r: 0, g: 0, b: 128 },
indigo: { r: 75, g: 0, b: 130 },
rebeccapurple: { r: 102, g: 51, b: 153 },
purple: { r: 128, g: 0, b: 128 },
violet: { r: 238, g: 130, b: 238 },
orchid: { r: 218, g: 112, b: 214 },
magenta: { r: 255, g: 0, b: 255 },
fuchsia: { r: 255, g: 0, b: 255 },
hotpink: { r: 255, g: 105, b: 180 },
pink: { r: 255, g: 192, b: 203 },
maroon: { r: 128, g: 0, b: 0 },
};
// Split a string on top-level commas (ignoring commas nested in parens).
function splitTopLevelCommas(str) {
const parts = [];
let depth = 0, start = 0;
for (let i = 0; i < str.length; i++) {
const ch = str[i];
if (ch === '(') depth++;
else if (ch === ')') depth = Math.max(0, depth - 1);
else if (ch === ',' && depth === 0) {
parts.push(str.slice(start, i).trim());
start = i + 1;
}
}
const tail = str.slice(start).trim();
if (tail) parts.push(tail);
return parts;
}
// Evaluate a CSS color-mix() expression to {r,g,b,a}. Returns null when
// the expression can't be resolved (unresolved var(), unknown colors).
//
// Mixing is done with premultiplied alpha in sRGB regardless of the
// declared interpolation space. That is exact for the dominant generated-UI
// pattern — `color-mix(in oklab, <color> N%, transparent)` — where the
// result is simply <color> at alpha N% in ANY rectangular space, and a
// close-enough approximation for opaque-opaque mixes (the detector only
// consumes these values for contrast/chroma thresholds, not for display).
function parseColorMix(str) {
const m = String(str).trim().match(/^color-mix\(/i);
if (!m) return null;
// Balanced-paren capture of the arguments.
let depth = 0, end = -1;
const open = str.indexOf('(');
for (let i = open; i < str.length; i++) {
if (str[i] === '(') depth++;
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
}
if (end < 0) return null;
const args = splitTopLevelCommas(str.slice(open + 1, end));
if (args.length !== 3 || !/^in\s/i.test(args[0])) return null;
const parseComponent = (component) => {
// Percentage may lead or trail the color per spec.
let pct = null;
let colorStr = component;
const trail = component.match(/\s+([\d.]+)%$/);
const lead = component.match(/^([\d.]+)%\s+/);
if (trail) { pct = parseFloat(trail[1]); colorStr = component.slice(0, trail.index).trim(); }
else if (lead) { pct = parseFloat(lead[1]); colorStr = component.slice(lead[0].length).trim(); }
let color;
if (/^transparent$/i.test(colorStr)) color = { r: 0, g: 0, b: 0, a: 0 };
else color = parseAnyColor(colorStr);
if (!color) return null;
return { color, pct };
};
const c1 = parseComponent(args[1]);
const c2 = parseComponent(args[2]);
if (!c1 || !c2) return null;
let p1 = c1.pct, p2 = c2.pct;
if (p1 == null && p2 == null) { p1 = 50; p2 = 50; }
else if (p1 == null) p1 = 100 - p2;
else if (p2 == null) p2 = 100 - p1;
const sum = p1 + p2;
if (sum <= 0) return null;
// Per spec: weights normalize to sum; when sum < 100 the result alpha is
// additionally scaled by sum/100.
const w1 = p1 / sum, w2 = p2 / sum;
const alphaScale = sum < 100 ? sum / 100 : 1;
const a1 = c1.color.a ?? 1, a2 = c2.color.a ?? 1;
const a = (a1 * w1 + a2 * w2) * alphaScale;
if (a <= 0) return { r: 0, g: 0, b: 0, a: 0 };
const mix = (ch) => Math.round((c1.color[ch] * a1 * w1 + c2.color[ch] * a2 * w2) / (a1 * w1 + a2 * w2));
return { r: mix('r'), g: mix('g'), b: mix('b'), a: Math.min(1, a) };
}
// Composite a translucent color over an opaque(ish) base (simple
// source-over in sRGB). Returns an opaque {r,g,b,a:1}.
function compositeColorOver(top, base) {
const a = top.a ?? 1;
return {
r: Math.round(top.r * a + base.r * (1 - a)),
g: Math.round(top.g * a + base.g * (1 - a)),
b: Math.round(top.b * a + base.b * (1 - a)),
a: 1,
};
}
// A color() / lab() / lch() component: a bare number, a percentage against
// `scale`, or the `none` keyword (which resolves to zero for our purposes).
function parseColorComponent(token, scale = 1) {
if (token == null) return null;
const t = String(token).trim();
if (/^none$/i.test(t)) return 0;
const num = parseFloat(t);
if (!Number.isFinite(num)) return null;
return t.endsWith('%') ? (num / 100) * scale : num;
}
function parseAlphaToken(token) {
if (token == null) return 1;
const t = String(token).trim();
if (/^none$/i.test(t)) return 1;
const num = parseFloat(t);
if (!Number.isFinite(num)) return 1;
return t.endsWith('%') ? num / 100 : num;
}
// Extended color parser: rgb/rgba/hex/oklch/oklab/lch/lab/hsl/hwb/color()/
// color-mix/common named colors. Returns null on no match. Use this when the
// input might be any CSS color form; use plain parseRgb when you only expect
// computed rgb() values from real browsers.
function parseAnyColor(s) {
if (!s || typeof s !== 'string') return null;
const str = s.trim();
if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
if (/^color-mix\(/i.test(str)) return parseColorMix(str);
let m;
m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/);
if (m) {
const c = { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: 1 };
if (m[4] !== undefined) c.a = m[5] === '%' ? parseFloat(m[4]) / 100 : +m[4];
return c;
}
m = str.match(/^#([0-9a-f]{3,8})$/i);
if (m) {
const h = m[1];
if (h.length === 3 || h.length === 4) {
return {
r: parseInt(h[0] + h[0], 16),
g: parseInt(h[1] + h[1], 16),
b: parseInt(h[2] + h[2], 16),
a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
};
}
if (h.length === 6 || h.length === 8) {
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
};
}
}
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
// Match L (with optional %), then C and H separated permissively.
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const Lnum = parseFloat(m[1]);
const L = m[2] === '%' ? Lnum / 100 : Lnum;
const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
if (m[5] !== undefined) {
const alpha = parseFloat(m[5]);
rgb.a = m[6] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// OKLAB — a/b are signed axes; percentages map 100% → 0.4.
m = str.match(/oklab\(\s*([\d.]+)(%?)\s+(-?[\d.]+)(%?)\s+(-?[\d.]+)(%?)(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const L = m[2] === '%' ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
const a = m[4] === '%' ? parseFloat(m[3]) * 0.004 : parseFloat(m[3]);
const b = m[6] === '%' ? parseFloat(m[5]) * 0.004 : parseFloat(m[5]);
const rgb = oklabToRgb(L, a, b);
if (m[7] !== undefined) {
const alpha = parseFloat(m[7]);
rgb.a = m[8] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// LCH / LAB — CIE, D50 white point. Chrome serializes lch(20% 5 60) as
// `lch(20 5 60)`, so L arrives with or without its percent sign. In both
// spaces L runs 0..100 and 100% means 100.
m = str.match(/^lch\(\s*([\d.]+%?|none)\s+([\d.]+%?|none)\s+(-?[\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
if (m) {
const L = parseColorComponent(m[1], 100);
const C = parseColorComponent(m[2], 150);
const H = parseFloat(m[3]);
if (L == null || C == null || !Number.isFinite(H)) return null;
const rgb = lchToRgb(L, C, H);
rgb.a = parseAlphaToken(m[4]);
return rgb;
}
m = str.match(/^lab\(\s*([\d.]+%?|none)\s+(-?[\d.]+%?|none)\s+(-?[\d.]+%?|none)(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
if (m) {
const L = parseColorComponent(m[1], 100);
const a = parseColorComponent(m[2], 125);
const b = parseColorComponent(m[3], 125);
if (L == null || a == null || b == null) return null;
const rgb = labToRgb(L, a, b);
rgb.a = parseAlphaToken(m[4]);
return rgb;
}
// color(<space> c1 c2 c3 [/ alpha]) — what Chrome hands back for most
// color-mix() results and for any wide-gamut color an author wrote.
m = str.match(/^color\(\s*([a-z0-9-]+)\s+(-?[\d.eE+-]+%?|none)\s+(-?[\d.eE+-]+%?|none)\s+(-?[\d.eE+-]+%?|none)(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
if (m) {
const c1 = parseColorComponent(m[2]);
const c2 = parseColorComponent(m[3]);
const c3 = parseColorComponent(m[4]);
if (c1 == null || c2 == null || c3 == null) return null;
const rgb = colorFunctionToRgb(m[1].toLowerCase(), c1, c2, c3);
if (!rgb) return null;
rgb.a = parseAlphaToken(m[5]);
return rgb;
}
// HSL/HSLA — comma or space syntax, optional deg on hue.
m = str.match(/hsla?\(\s*(-?[\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%\s*[,\s]\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hslToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// HWB — hue whiteness% blackness%.
m = str.match(/hwb\(\s*(-?[\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hwbToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
const named = CSS_NAMED_COLORS[str.toLowerCase()];
if (named) return { ...named, a: 1 };
return null;
}
// True when a computed background-color string names no paint at all. Used to
// tell "this layer is see-through" (walk on to the ancestor) apart from "this
// layer has a color we could not read" (stop and abstain).
//
// `inherit` belongs here even though it is not literally see-through: it means
// "paint with the parent's background-color", and walking on to the parent IS
// that resolution. Real browsers resolve the keyword before getComputedStyle
// output; only jsdom's partial cascade hands it through verbatim, and treating
// it as unreadable would make the walk abstain on a surface it can know.
// (`currentcolor` is NOT here — it is real paint in the element's own text
// color; resolveBackgroundInfo substitutes the computed color for it.)
function isNoPaintColorValue(value) {
const v = String(value || '').trim().toLowerCase();
if (!v) return true;
return v === 'transparent' || v === 'none' || v === 'initial' || v === 'inherit' || v === 'unset' || v === 'revert' || v === 'revert-layer';
}
export {
isNeutralColor,
parseRgb,
relativeLuminance,
contrastRatio,
parseGradientColors,
extractColorFunctionTokens,
hasChroma,
getHue,
colorToHex,
oklabToRgb,
oklchToRgb,
labToRgb,
lchToRgb,
colorFunctionToRgb,
hslToRgb,
hwbToRgb,
CSS_NAMED_COLORS,
splitTopLevelCommas,
parseColorMix,
parseAnyColor,
compositeColorOver,
isNoPaintColorValue,
};
File diff suppressed because it is too large Load Diff
@@ -11,14 +11,21 @@ import {
isBrandFontOnOwnDomain,
} from '../shared/constants.mjs';
import {
CSS_NAMED_COLORS,
colorToHex,
compositeColorOver,
contrastRatio,
getHue,
hasChroma,
isNeutralColor,
isNoPaintColorValue,
oklchToRgb,
parseAnyColor,
parseColorMix,
parseGradientColors,
parseRgb,
relativeLuminance,
splitTopLevelCommas,
} from '../shared/color.mjs';
import { extractGoogleFontFamilies } from '../shared/fonts.mjs';
@@ -1731,7 +1738,7 @@ function readOwnBackgroundColor(el, computedStyle) {
// One element's background-color as the cascade walk sees it: computed style
// first (with the modern-color fallback), then, in static mode only,
// custom-prop resolution and the inline-shorthand peek. Shared by
// resolveBackground and resolveGradientStops so both walks read the same
// resolveBackgroundInfo and resolveGradientStops so both walks read the same
// surfaces.
function readCascadeBackgroundColor(current, style, customPropMap) {
let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
@@ -1755,7 +1762,19 @@ function readCascadeBackgroundColor(current, style, customPropMap) {
return bg;
}
function resolveBackground(el, win, customPropMap) {
// Walk up for the surface the element's text is painted on.
//
// Returns { color, unresolved }:
// • color set — the effective surface, overlays composited in.
// • unresolved: true — a layer on the way up paints a color this parser
// cannot read, so the surface is unknown. Callers
// must SKIP their contrast checks. Guessing white
// here is what flooded dark themes with false
// "on #ffffff" findings: one abstention costs a
// single finding, one wrong guess costs a hundred.
// • both null/false — no solid color, but a gradient or image is in
// play; callers fall back to its color stops.
function resolveBackgroundInfo(el, win, customPropMap) {
let current = el;
// Translucent layers (0.1 < a < 1) found on the way down to an opaque
// base. A browser composites these over the base; the old behavior
@@ -1783,59 +1802,81 @@ function resolveBackground(el, win, customPropMap) {
// body backgrounds.
// Real browsers serialize wide-gamut computed values as oklab()/oklch()
// (e.g. any color-mix() result), which plain parseRgb misses.
const bg = readCascadeBackgroundColor(current, style, customPropMap);
let bg = readCascadeBackgroundColor(current, style, customPropMap);
// `background-color: currentcolor` paints with the element's own text
// color — real paint whose value we know. Real browsers resolve the
// keyword before getComputedStyle output; jsdom hands it through
// verbatim, and without this substitution the layer would read as
// unparseable and force a needless abstention.
if ((!bg || bg.a < 0.1) && /^currentcolor$/i.test(String(style.backgroundColor || '').trim())) {
// The static cascade resolves var() text tokens before checks run, so
// style.color is normally already an rgb string here; parseColorResolved
// is defense in depth for any future caller that passes a live
// customPropMap (it matches the text-color path in checkElementColors
// and reduces to parseAnyColor when the map is null or absent).
bg = parseRgb(style.color) || parseColorResolved(style.color, customPropMap);
}
if (bg && bg.a > 0.1) {
if (bg.a >= 0.99) return flatten(bg);
if (bg.a >= 0.99) return { color: flatten(bg), unresolved: false };
overlays.push(bg);
} else if (!bg && !isNoPaintColorValue(style.backgroundColor)) {
// This layer names a color we could not parse (a color space we do not
// model, an unresolved var(), a syntax newer than the parser). It may
// well be opaque, which would make every ancestor below it invisible —
// so the surface is unknown and the walk stops here rather than
// reporting an ancestor the visitor never sees.
return { color: null, unresolved: true };
}
// No solid bg-color at this level, but this level paints an image. CSS
// stacks background-image layers first-on-top, so which layer leads
// decides what the visitor sees:
// • gradient on top — the gradient is the surface. Hand the caller a
// null color so it falls back to the gradient's own stops (body
// grounds, gradient buttons, hero sections).
// • url() on top — the surface is an image whose pixels this engine
// cannot read, and it may fully cover every layer and ancestor
// beneath it. Same contract as an unparseable color: abstain, so
// the gradient-stop fallback never measures a gradient the image
// hides (the shipped miss: `url(photo), linear-gradient(...)`
// reported low-contrast against the invisible gradient's stops).
if (hasGradientOrUrl) {
const layers = splitTopLevelCommas(bgImage);
const topPaintLayer = layers.find(
(layer) => /gradient\s*\(/i.test(layer) || /url\s*\(/i.test(layer),
);
const gradientOnTop = !!topPaintLayer
&& /gradient\s*\(/i.test(topPaintLayer)
&& !/^\s*url\s*\(/i.test(topPaintLayer);
if (!gradientOnTop) return { color: null, unresolved: true };
// Gradient on top of a url() layer: the image shows through wherever
// the gradient is not fully opaque, so a translucent wash like
// `linear-gradient(rgba(0,0,0,.2), rgba(0,0,0,.2)), url(photo)` paints
// a blend with pixels this engine cannot read. Only a gradient whose
// every readable stop is opaque provably covers the image; otherwise
// the surface is unknown — abstain rather than hand callers gradient
// stops (or a stop average) the visitor never sees unmixed.
const urlBeneath = layers.some(
(layer) => layer !== topPaintLayer && /url\s*\(/i.test(layer),
);
if (urlBeneath) {
const topStops = parseGradientColors(topPaintLayer);
const provablyOpaque = topStops.length > 0 && topStops.every((s) => (s.a ?? 1) >= 0.99);
if (!provablyOpaque) return { color: null, unresolved: true };
}
return { color: null, unresolved: false };
}
// No solid bg-color at this level. If THIS level has a gradient/url
// with no underlying solid color we can read:
// • on body/html: assume white. Body-level gradients are almost
// always decorative texture (paper grain, noise) on top of a
// solid bg-color the page set via `background: var(--paper)`
// shorthand — which jsdom can't decompose into bg-color. The
// downstream gradient-stops fallback path produces catastrophic
// false positives in this case (gradient noise stops have
// accidental browns/blacks that look like card backgrounds).
// • on other elements: bail to null and let the caller fall back
// to gradient stops (gradient buttons / hero sections are real
// bgs worth checking against).
// A gradient or image with no solid color under it, at any level
// including body/html, means the visible ground is that layer itself.
// Return null so the caller measures against the actual gradient stops,
// or skips when nothing is parseable — skipping beats a wrong ratio.
//
// Body/html used to assume white here, a guard written for jsdom, which
// never decomposed the `background:` shorthand and so could not see the
// solid paper color a texture gradient usually sits on. It turned every
// light-on-dark page into a wall of low-contrast false positives (a dark
// oklch body gradient produced ~120 "on #ffffff" findings on one site).
// Both engines can see shorthand solids now — the browser natively, the
// static cascade via expandStaticDeclaration + var() resolution — so a
// missing solid is real, and the old failure case cannot recur: opaque
// stops fully cover any hidden solid (they ARE the ground), alpha stops
// composite over the resolved base or the white canvas default, and
// unresolvable stops drop rather than guess.
if (hasGradientOrUrl) return null;
current = current.parentElement;
}
return flatten({ r: 255, g: 255, b: 255, a: 1 });
// Every layer up to the document root was genuinely see-through, so the
// browser paints its default canvas. This is the ONLY case that earns the
// white assumption.
return { color: flatten({ r: 255, g: 255, b: 255, a: 1 }), unresolved: false };
}
// parseGradientColors (shared) reads only the legacy serializations: rgb()
// and hex stops. Browsers keep modern-space stops in computed backgroundImage
// exactly as authored — `linear-gradient(oklch(7% 0.006 95), …)` stays oklch —
// which is what every token-driven page produces. Route those through
// parseAnyColor so a gradient ground is measurable rather than invisible.
function parseGradientColorsModern(bgImage) {
if (!bgImage || !/gradient/i.test(bgImage)) return [];
const colors = parseGradientColors(bgImage);
for (const m of bgImage.matchAll(/(?:oklch|oklab|hsla?|hwb)\(\s*[^()]*\)/gi)) {
const c = parseAnyColor(m[0]);
if (c) colors.push(c);
}
return colors;
function resolveBackground(el, win, customPropMap) {
return resolveBackgroundInfo(el, win, customPropMap).color;
}
// Walk parents looking for a gradient background and return its color stops.
@@ -1861,7 +1902,10 @@ function resolveGradientStops(el, win, customPropMap) {
if (bgImage && bgImage !== 'none' && /url\s*\(/i.test(bgImage)) return null;
let stops = null;
if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
const parsed = parseGradientColorsModern(bgImage);
// parseGradientColors (shared) reads modern-space stops too — oklch,
// color-mix and friends via balanced-paren token capture — so browser
// computed values that keep the authored syntax stay measurable.
const parsed = parseGradientColors(bgImage);
if (parsed.length > 0) stops = parsed;
}
if (!stops && !DETECTOR_IS_BROWSER) {
@@ -1869,7 +1913,7 @@ function resolveGradientStops(el, win, customPropMap) {
const rawStyle = current.getAttribute?.('style') || '';
const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
if (bgMatch && /gradient/i.test(bgMatch[1])) {
const parsed = parseGradientColorsModern(bgMatch[1]);
const parsed = parseGradientColors(bgMatch[1]);
if (parsed.length > 0) stops = parsed;
}
}
@@ -2115,13 +2159,19 @@ function checkElementColorsDOM(el) {
if (style.visibility === 'hidden' || effectiveOpacityDOM(el) <= 0.02) return [];
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
let effectiveBg = resolveBackground(el);
const bgInfo = resolveBackgroundInfo(el);
let effectiveBg = bgInfo.color;
// An unreadable surface anywhere up the chain: skip the gradient-stop
// fallback too, so nothing downstream measures against a ground we never
// resolved.
let surfaceUnresolved = bgInfo.unresolved;
let ownBg = readOwnBackgroundColor(el, style);
if (!ownBg || (ownBg.a ?? 1) <= 0.5) {
const pseudoSurface = readPseudoSurfaceDOM(el, rect);
if (pseudoSurface) {
ownBg = pseudoSurface;
effectiveBg = pseudoSurface;
surfaceUnresolved = false;
}
}
return checkColors({
@@ -2133,8 +2183,8 @@ function checkElementColorsDOM(el) {
// an oklch token near its own oklch background).
textColor: parseRgb(style.color) || parseAnyColor(style.color),
bgColor: ownBg,
effectiveBg,
effectiveBgStops: effectiveBg ? null : resolveGradientStops(el),
effectiveBg: surfaceUnresolved ? null : effectiveBg,
effectiveBgStops: surfaceUnresolved || effectiveBg ? null : resolveGradientStops(el),
fontSize: parseFloat(style.fontSize) || 16,
fontWeight: parseInt(style.fontWeight) || 400,
hasDirectText,
@@ -2284,283 +2334,6 @@ function resolveVarRefs(raw, customPropMap, depth = 0) {
});
}
// OKLCH → sRGB conversion (Björn Ottosson's matrices). L in 0..1 (or %),
// C in 0..~0.4 typical, H in degrees. Returns clamped {r,g,b,a:1} in 0..255.
// Needed because jsdom doesn't compute oklch() values — getComputedStyle
// returns the literal "oklch(...)" string. Without this, the entire
// Tailwind v4 color palette (which is OKLCH-based) is invisible to the
// detector's contrast / color checks.
function oklchToRgb(L, C, H) {
const hRad = (H * Math.PI) / 180;
return oklabToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
}
function oklabToRgb(L, a, b) {
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
const rLin = 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc;
const gLin = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc;
const bLin = -0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc;
const enc = (x) => {
const c = Math.max(0, Math.min(1, x));
return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
};
return {
r: Math.round(enc(rLin) * 255),
g: Math.round(enc(gLin) * 255),
b: Math.round(enc(bLin) * 255),
a: 1,
};
}
function hslToRgb(h, s, l) {
h = ((h % 360) + 360) % 360;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m0 = l - c / 2;
const [r, g, b] =
h < 60 ? [c, x, 0] :
h < 120 ? [x, c, 0] :
h < 180 ? [0, c, x] :
h < 240 ? [0, x, c] :
h < 300 ? [x, 0, c] : [c, 0, x];
return {
r: Math.round((r + m0) * 255),
g: Math.round((g + m0) * 255),
b: Math.round((b + m0) * 255),
a: 1,
};
}
function hwbToRgb(h, w, bl) {
if (w + bl >= 1) {
const g = Math.round((w / (w + bl)) * 255);
return { r: g, g, b: g, a: 1 };
}
const base = hslToRgb(h, 1, 0.5);
const mix = (c) => Math.round(((c / 255) * (1 - w - bl) + w) * 255);
return { r: mix(base.r), g: mix(base.g), b: mix(base.b), a: 1 };
}
// Common CSS named colors — the handful that actually show up in generated
// UIs, not the full 148-name spec list. Includes the achromatic names so a
// named gray parses (and correctly reads as no-chroma) instead of being
// treated as an unknown color.
const CSS_NAMED_COLORS = {
black: { r: 0, g: 0, b: 0 },
white: { r: 255, g: 255, b: 255 },
gray: { r: 128, g: 128, b: 128 },
grey: { r: 128, g: 128, b: 128 },
silver: { r: 192, g: 192, b: 192 },
dimgray: { r: 105, g: 105, b: 105 },
darkgray: { r: 169, g: 169, b: 169 },
lightgray: { r: 211, g: 211, b: 211 },
gainsboro: { r: 220, g: 220, b: 220 },
whitesmoke: { r: 245, g: 245, b: 245 },
red: { r: 255, g: 0, b: 0 },
crimson: { r: 220, g: 20, b: 60 },
tomato: { r: 255, g: 99, b: 71 },
coral: { r: 255, g: 127, b: 80 },
salmon: { r: 250, g: 128, b: 114 },
orange: { r: 255, g: 165, b: 0 },
gold: { r: 255, g: 215, b: 0 },
yellow: { r: 255, g: 255, b: 0 },
olive: { r: 128, g: 128, b: 0 },
lime: { r: 0, g: 255, b: 0 },
green: { r: 0, g: 128, b: 0 },
teal: { r: 0, g: 128, b: 128 },
turquoise: { r: 64, g: 224, b: 208 },
cyan: { r: 0, g: 255, b: 255 },
aqua: { r: 0, g: 255, b: 255 },
skyblue: { r: 135, g: 206, b: 235 },
dodgerblue: { r: 30, g: 144, b: 255 },
blue: { r: 0, g: 0, b: 255 },
navy: { r: 0, g: 0, b: 128 },
indigo: { r: 75, g: 0, b: 130 },
rebeccapurple: { r: 102, g: 51, b: 153 },
purple: { r: 128, g: 0, b: 128 },
violet: { r: 238, g: 130, b: 238 },
orchid: { r: 218, g: 112, b: 214 },
magenta: { r: 255, g: 0, b: 255 },
fuchsia: { r: 255, g: 0, b: 255 },
hotpink: { r: 255, g: 105, b: 180 },
pink: { r: 255, g: 192, b: 203 },
maroon: { r: 128, g: 0, b: 0 },
};
// Split a string on top-level commas (ignoring commas nested in parens).
function splitTopLevelCommas(str) {
const parts = [];
let depth = 0, start = 0;
for (let i = 0; i < str.length; i++) {
const ch = str[i];
if (ch === '(') depth++;
else if (ch === ')') depth = Math.max(0, depth - 1);
else if (ch === ',' && depth === 0) {
parts.push(str.slice(start, i).trim());
start = i + 1;
}
}
const tail = str.slice(start).trim();
if (tail) parts.push(tail);
return parts;
}
// Evaluate a CSS color-mix() expression to {r,g,b,a}. Returns null when
// the expression can't be resolved (unresolved var(), unknown colors).
//
// Mixing is done with premultiplied alpha in sRGB regardless of the
// declared interpolation space. That is exact for the dominant generated-UI
// pattern — `color-mix(in oklab, <color> N%, transparent)` — where the
// result is simply <color> at alpha N% in ANY rectangular space, and a
// close-enough approximation for opaque-opaque mixes (the detector only
// consumes these values for contrast/chroma thresholds, not for display).
function parseColorMix(str) {
const m = String(str).trim().match(/^color-mix\(/i);
if (!m) return null;
// Balanced-paren capture of the arguments.
let depth = 0, end = -1;
const open = str.indexOf('(');
for (let i = open; i < str.length; i++) {
if (str[i] === '(') depth++;
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
}
if (end < 0) return null;
const args = splitTopLevelCommas(str.slice(open + 1, end));
if (args.length !== 3 || !/^in\s/i.test(args[0])) return null;
const parseComponent = (component) => {
// Percentage may lead or trail the color per spec.
let pct = null;
let colorStr = component;
const trail = component.match(/\s+([\d.]+)%$/);
const lead = component.match(/^([\d.]+)%\s+/);
if (trail) { pct = parseFloat(trail[1]); colorStr = component.slice(0, trail.index).trim(); }
else if (lead) { pct = parseFloat(lead[1]); colorStr = component.slice(lead[0].length).trim(); }
let color;
if (/^transparent$/i.test(colorStr)) color = { r: 0, g: 0, b: 0, a: 0 };
else color = parseAnyColor(colorStr);
if (!color) return null;
return { color, pct };
};
const c1 = parseComponent(args[1]);
const c2 = parseComponent(args[2]);
if (!c1 || !c2) return null;
let p1 = c1.pct, p2 = c2.pct;
if (p1 == null && p2 == null) { p1 = 50; p2 = 50; }
else if (p1 == null) p1 = 100 - p2;
else if (p2 == null) p2 = 100 - p1;
const sum = p1 + p2;
if (sum <= 0) return null;
// Per spec: weights normalize to sum; when sum < 100 the result alpha is
// additionally scaled by sum/100.
const w1 = p1 / sum, w2 = p2 / sum;
const alphaScale = sum < 100 ? sum / 100 : 1;
const a1 = c1.color.a ?? 1, a2 = c2.color.a ?? 1;
const a = (a1 * w1 + a2 * w2) * alphaScale;
if (a <= 0) return { r: 0, g: 0, b: 0, a: 0 };
const mix = (ch) => Math.round((c1.color[ch] * a1 * w1 + c2.color[ch] * a2 * w2) / (a1 * w1 + a2 * w2));
return { r: mix('r'), g: mix('g'), b: mix('b'), a: Math.min(1, a) };
}
// Composite a translucent color over an opaque(ish) base (simple
// source-over in sRGB). Returns an opaque {r,g,b,a:1}.
function compositeColorOver(top, base) {
const a = top.a ?? 1;
return {
r: Math.round(top.r * a + base.r * (1 - a)),
g: Math.round(top.g * a + base.g * (1 - a)),
b: Math.round(top.b * a + base.b * (1 - a)),
a: 1,
};
}
// Extended color parser: rgb/rgba/hex/oklch/oklab/hsl/hwb/color-mix/common
// named colors. Returns null on no match. Use this when the input might be
// any CSS color form; use plain parseRgb when you only expect computed rgb()
// values from real browsers.
function parseAnyColor(s) {
if (!s || typeof s !== 'string') return null;
const str = s.trim();
if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
if (/^color-mix\(/i.test(str)) return parseColorMix(str);
let m;
m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+))?\s*\)/);
if (m) return { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: m[4] !== undefined ? +m[4] : 1 };
m = str.match(/^#([0-9a-f]{3,8})$/i);
if (m) {
const h = m[1];
if (h.length === 3 || h.length === 4) {
return {
r: parseInt(h[0] + h[0], 16),
g: parseInt(h[1] + h[1], 16),
b: parseInt(h[2] + h[2], 16),
a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
};
}
if (h.length === 6 || h.length === 8) {
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
};
}
}
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
// Match L (with optional %), then C and H separated permissively.
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const Lnum = parseFloat(m[1]);
const L = m[2] === '%' ? Lnum / 100 : Lnum;
const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
if (m[5] !== undefined) {
const alpha = parseFloat(m[5]);
rgb.a = m[6] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// OKLAB — a/b are signed axes; percentages map 100% → 0.4.
m = str.match(/oklab\(\s*([\d.]+)(%?)\s+(-?[\d.]+)(%?)\s+(-?[\d.]+)(%?)(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const L = m[2] === '%' ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
const a = m[4] === '%' ? parseFloat(m[3]) * 0.004 : parseFloat(m[3]);
const b = m[6] === '%' ? parseFloat(m[5]) * 0.004 : parseFloat(m[5]);
const rgb = oklabToRgb(L, a, b);
if (m[7] !== undefined) {
const alpha = parseFloat(m[7]);
rgb.a = m[8] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// HSL/HSLA — comma or space syntax, optional deg on hue.
m = str.match(/hsla?\(\s*(-?[\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%\s*[,\s]\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hslToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// HWB — hue whiteness% blackness%.
m = str.match(/hwb\(\s*(-?[\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hwbToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
const named = CSS_NAMED_COLORS[str.toLowerCase()];
if (named) return { ...named, a: 1 };
return null;
}
// Resolve var() refs in a color string (via customPropMap), then parse.
// Returns null on any failure. Used in jsdom-mode paths where
@@ -2923,15 +2696,24 @@ function checkElementGlowDOM(el) {
if (!boxShadow && !textShadow) return [];
// Use parent's background — glow radiates outward, so the surrounding context matters
// If resolveBackground returns null (gradient), try to infer from the gradient colors
let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el);
if (!parentBg) {
const parentBgInfo = resolveBackgroundInfo(el.parentElement || el);
// Unknown surface (an unreadable layer on the way up): skip only the
// gradient hunt below, which would walk PAST that layer and score the
// glow against a background the visitor never sees. checkGlow still runs
// with a null surface: the zero-offset chromatic halo tell holds on ANY
// background, and the static loop already passes the unresolved walk's
// null color straight through (detect-html.mjs uses resolveBackground).
let parentBg = parentBgInfo.color;
if (!parentBg && !parentBgInfo.unresolved) {
// Gradient background — sample its colors to determine if it's dark.
// Modern-syntax parsing matters here: body-level gradients now reach this
// fallback in browser mode, and their stops usually serialize as oklch.
// fallback in browser mode, and their stops usually serialize as oklch
// which the shared parseGradientColors reads via its color-function
// token capture.
let cur = el.parentElement;
while (cur && cur.nodeType === 1) {
const bgImage = getComputedStyle(cur).backgroundImage || '';
const gradColors = parseGradientColorsModern(bgImage);
const gradColors = parseGradientColors(bgImage);
if (gradColors.length > 0) {
// Average the gradient colors
const avg = { r: 0, g: 0, b: 0 };
@@ -2975,10 +2757,13 @@ function checkElementAIPaletteDOM(el) {
const hue = getHue(textColor);
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
if (isAIPalette) {
const parentBg = el.parentElement ? resolveBackground(el.parentElement) : null;
// Also check gradient parents
let effectiveBg = parentBg;
if (!effectiveBg) {
const parentBgInfo = el.parentElement
? resolveBackgroundInfo(el.parentElement)
: { color: null, unresolved: false };
// Unknown surface: leave effectiveBg null (no finding) rather than
// hunting gradient ancestors past a layer we could not read.
let effectiveBg = parentBgInfo.color;
if (!effectiveBg && !parentBgInfo.unresolved) {
let cur = el.parentElement;
while (cur && cur.nodeType === 1) {
const gi = getComputedStyle(cur).backgroundImage || '';
@@ -3784,7 +3569,8 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
const effectiveBg = resolveBackground(el, window, customPropMap);
const bgInfo = resolveBackgroundInfo(el, window, customPropMap);
const effectiveBg = bgInfo.color;
// jsdom returns literal "var(--X)" / "oklch(...)" for color, so plain
// parseRgb misses Tailwind-tokenized text colors. Resolve through the
// customPropMap first; fall back to parseRgb for vanilla rgb() pages.
@@ -3830,11 +3616,13 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
// element itself has no usable own background, that pseudo is the real
// surface for contrast purposes.
let finalEffectiveBg = effectiveBg;
let surfaceUnresolved = bgInfo.unresolved;
if ((!ownBg || (ownBg.a ?? 1) <= 0.5) && typeof window.getPseudoSurface === 'function') {
const pseudoSurface = window.getPseudoSurface(el);
if (pseudoSurface) {
ownBg = pseudoSurface;
finalEffectiveBg = pseudoSurface;
surfaceUnresolved = false;
}
}
@@ -3842,8 +3630,9 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
tag,
textColor,
bgColor: ownBg,
effectiveBg: finalEffectiveBg,
effectiveBgStops: finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
// Unknown surface: hand the checks nothing rather than a guess.
effectiveBg: surfaceUnresolved ? null : finalEffectiveBg,
effectiveBgStops: surfaceUnresolved || finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
fontSize: parseFloat(style.fontSize) || 16,
fontWeight: parseInt(style.fontWeight) || 400,
hasDirectText,
@@ -5663,6 +5452,7 @@ export {
checkHtmlPatterns,
readOwnBackgroundColor,
resolveBackground,
resolveBackgroundInfo,
resolveGradientStops,
parseRadiusToPx,
resolveBorderRadiusPx,
@@ -71,11 +71,43 @@ function contrastRatio(c1, c2) {
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
// The CSS color functions worth pulling out of a longer declaration. The set
// is deliberately closed: `linear-gradient(` and `url(` also look like
// `name(` and must not be read as colors.
const COLOR_FUNCTION_NAMES = new Set([
'rgb', 'rgba', 'hsl', 'hsla', 'hwb', 'oklch', 'oklab', 'lch', 'lab', 'color', 'color-mix',
]);
// Pull every color-function token out of a value, with balanced-paren capture
// so nested forms (`color-mix(in oklab, oklch(...) 20%, transparent)`) survive
// whole. Returns the raw substrings in source order.
function extractColorFunctionTokens(value) {
const str = String(value || '');
const tokens = [];
const re = /([a-z][a-z-]*)\(/gi;
let m;
while ((m = re.exec(str)) !== null) {
if (!COLOR_FUNCTION_NAMES.has(m[1].toLowerCase())) continue;
let depth = 0, end = -1;
for (let i = m.index + m[0].length - 1; i < str.length; i++) {
if (str[i] === '(') depth++;
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
}
if (end < 0) break;
tokens.push(str.slice(m.index, end + 1));
re.lastIndex = end + 1;
}
return tokens;
}
function parseGradientColors(bgImage) {
if (!bgImage || !bgImage.includes('gradient')) return [];
const colors = [];
for (const m of bgImage.matchAll(/rgba?\([^)]+\)/g)) {
const c = parseRgb(m[0]);
// Stops arrive in whatever syntax the author wrote and the browser kept.
// A dark ground painted as `linear-gradient(oklch(...), oklch(...))` used
// to read as a gradient with no stops at all.
for (const token of extractColorFunctionTokens(bgImage)) {
const c = parseAnyColor(token);
if (c) colors.push(c);
}
for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
@@ -112,13 +144,445 @@ function colorToHex(c) {
return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
}
// ─── Color-space conversions ────────────────────────────────────────────────
//
// Every function here lands on 8-bit sRGB, clamped to gamut. Chrome, Safari,
// and Firefox all keep the authored color space in getComputedStyle output
// (`oklch(0.84 0.19 80.46)`, `lch(20 5 60)`, `color(srgb 1.04 0.72 -0.21)`),
// so a detector that only reads rgb() is blind on any modern palette. The
// expected outputs are pinned in tests/detect-antipatterns.test.js against
// what Chrome itself paints for the same strings.
function clamp01(x) {
return Number.isFinite(x) ? Math.max(0, Math.min(1, x)) : 0;
}
// Linear-light sRGB channel to the encoded 0-255 value.
function encodeSrgbChannel(x) {
const c = clamp01(x);
return Math.round((c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055) * 255);
}
function decodeSrgbChannel(x) {
const c = Number.isFinite(x) ? x : 0;
const sign = c < 0 ? -1 : 1;
const abs = Math.abs(c);
return sign * (abs <= 0.04045 ? abs / 12.92 : Math.pow((abs + 0.055) / 1.055, 2.4));
}
function linearSrgbToColor(r, g, b, a = 1) {
return { r: encodeSrgbChannel(r), g: encodeSrgbChannel(g), b: encodeSrgbChannel(b), a };
}
// OKLab to sRGB (Björn Ottosson's matrices). L in 0..1, a/b are signed axes.
function oklabToRgb(L, a, b) {
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
return linearSrgbToColor(
4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc,
-1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc,
-0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc,
);
}
// OKLCH to sRGB. L in 0..1, C in 0..~0.4 typical, H in degrees. Chroma past
// the sRGB gamut clamps per channel rather than producing NaN.
function oklchToRgb(L, C, H) {
const hRad = (H * Math.PI) / 180;
return oklabToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
}
// CIE Lab to sRGB. CSS lab()/lch() use the D50 white point; the matrix below
// is the Bradford-adapted XYZ-D50 to linear-sRGB transform from CSS Color 4.
function labToRgb(L, a, b) {
const kappa = 24389 / 27, epsilon = 216 / 24389;
const fy = (L + 16) / 116, fx = fy + a / 500, fz = fy - b / 200;
const invert = (t) => (t * t * t > epsilon ? t * t * t : (116 * t - 16) / kappa);
const yr = L > kappa * epsilon ? Math.pow((L + 16) / 116, 3) : L / kappa;
const Xn = 0.3457 / 0.3585, Zn = (1 - 0.3457 - 0.3585) / 0.3585;
const x = invert(fx) * Xn, y = yr, z = invert(fz) * Zn;
return linearSrgbToColor(
3.1341359569958707 * x - 1.6173863321612538 * y - 0.4906619460083532 * z,
-0.9787955029120890 * x + 1.9162545672595240 * y + 0.0334427311613195 * z,
0.0719553798841168 * x - 0.2289768264158322 * y + 1.4053860583241250 * z,
);
}
function lchToRgb(L, C, H) {
const hRad = (H * Math.PI) / 180;
return labToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
}
// color(<space> c1 c2 c3) for the spaces that turn up in real stylesheets.
// `srgb` is what Chrome serializes most color-mix() results into, routinely
// with channels outside 0..1. Spaces we do not model return null so callers
// abstain instead of measuring against a color we invented.
function colorFunctionToRgb(space, c1, c2, c3) {
switch (space) {
case 'srgb':
return { r: Math.round(clamp01(c1) * 255), g: Math.round(clamp01(c2) * 255), b: Math.round(clamp01(c3) * 255), a: 1 };
case 'srgb-linear':
return linearSrgbToColor(c1, c2, c3);
case 'display-p3': {
const [R, G, B] = [decodeSrgbChannel(c1), decodeSrgbChannel(c2), decodeSrgbChannel(c3)];
return linearSrgbToColor(
1.2249401762805587 * R - 0.2249404646817506 * G + 0.0000002884022551 * B,
-0.0420569547096138 * R + 1.0420571661298634 * G - 0.0000002113202247 * B,
-0.0196375587040044 * R - 0.0786360772174755 * G + 1.0982736359214800 * B,
);
}
default:
return null;
}
}
function hslToRgb(h, s, l) {
h = ((h % 360) + 360) % 360;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m0 = l - c / 2;
const [r, g, b] =
h < 60 ? [c, x, 0] :
h < 120 ? [x, c, 0] :
h < 180 ? [0, c, x] :
h < 240 ? [0, x, c] :
h < 300 ? [x, 0, c] : [c, 0, x];
return {
r: Math.round((r + m0) * 255),
g: Math.round((g + m0) * 255),
b: Math.round((b + m0) * 255),
a: 1,
};
}
function hwbToRgb(h, w, bl) {
if (w + bl >= 1) {
const g = Math.round((w / (w + bl)) * 255);
return { r: g, g, b: g, a: 1 };
}
const base = hslToRgb(h, 1, 0.5);
const mix = (c) => Math.round(((c / 255) * (1 - w - bl) + w) * 255);
return { r: mix(base.r), g: mix(base.g), b: mix(base.b), a: 1 };
}
// Common CSS named colors — the handful that actually show up in generated
// UIs, not the full 148-name spec list. Includes the achromatic names so a
// named gray parses (and correctly reads as no-chroma) instead of being
// treated as an unknown color.
const CSS_NAMED_COLORS = {
black: { r: 0, g: 0, b: 0 },
white: { r: 255, g: 255, b: 255 },
gray: { r: 128, g: 128, b: 128 },
grey: { r: 128, g: 128, b: 128 },
silver: { r: 192, g: 192, b: 192 },
dimgray: { r: 105, g: 105, b: 105 },
darkgray: { r: 169, g: 169, b: 169 },
lightgray: { r: 211, g: 211, b: 211 },
gainsboro: { r: 220, g: 220, b: 220 },
whitesmoke: { r: 245, g: 245, b: 245 },
red: { r: 255, g: 0, b: 0 },
crimson: { r: 220, g: 20, b: 60 },
tomato: { r: 255, g: 99, b: 71 },
coral: { r: 255, g: 127, b: 80 },
salmon: { r: 250, g: 128, b: 114 },
orange: { r: 255, g: 165, b: 0 },
gold: { r: 255, g: 215, b: 0 },
yellow: { r: 255, g: 255, b: 0 },
olive: { r: 128, g: 128, b: 0 },
lime: { r: 0, g: 255, b: 0 },
green: { r: 0, g: 128, b: 0 },
teal: { r: 0, g: 128, b: 128 },
turquoise: { r: 64, g: 224, b: 208 },
cyan: { r: 0, g: 255, b: 255 },
aqua: { r: 0, g: 255, b: 255 },
skyblue: { r: 135, g: 206, b: 235 },
dodgerblue: { r: 30, g: 144, b: 255 },
blue: { r: 0, g: 0, b: 255 },
navy: { r: 0, g: 0, b: 128 },
indigo: { r: 75, g: 0, b: 130 },
rebeccapurple: { r: 102, g: 51, b: 153 },
purple: { r: 128, g: 0, b: 128 },
violet: { r: 238, g: 130, b: 238 },
orchid: { r: 218, g: 112, b: 214 },
magenta: { r: 255, g: 0, b: 255 },
fuchsia: { r: 255, g: 0, b: 255 },
hotpink: { r: 255, g: 105, b: 180 },
pink: { r: 255, g: 192, b: 203 },
maroon: { r: 128, g: 0, b: 0 },
};
// Split a string on top-level commas (ignoring commas nested in parens).
function splitTopLevelCommas(str) {
const parts = [];
let depth = 0, start = 0;
for (let i = 0; i < str.length; i++) {
const ch = str[i];
if (ch === '(') depth++;
else if (ch === ')') depth = Math.max(0, depth - 1);
else if (ch === ',' && depth === 0) {
parts.push(str.slice(start, i).trim());
start = i + 1;
}
}
const tail = str.slice(start).trim();
if (tail) parts.push(tail);
return parts;
}
// Evaluate a CSS color-mix() expression to {r,g,b,a}. Returns null when
// the expression can't be resolved (unresolved var(), unknown colors).
//
// Mixing is done with premultiplied alpha in sRGB regardless of the
// declared interpolation space. That is exact for the dominant generated-UI
// pattern — `color-mix(in oklab, <color> N%, transparent)` — where the
// result is simply <color> at alpha N% in ANY rectangular space, and a
// close-enough approximation for opaque-opaque mixes (the detector only
// consumes these values for contrast/chroma thresholds, not for display).
function parseColorMix(str) {
const m = String(str).trim().match(/^color-mix\(/i);
if (!m) return null;
// Balanced-paren capture of the arguments.
let depth = 0, end = -1;
const open = str.indexOf('(');
for (let i = open; i < str.length; i++) {
if (str[i] === '(') depth++;
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
}
if (end < 0) return null;
const args = splitTopLevelCommas(str.slice(open + 1, end));
if (args.length !== 3 || !/^in\s/i.test(args[0])) return null;
const parseComponent = (component) => {
// Percentage may lead or trail the color per spec.
let pct = null;
let colorStr = component;
const trail = component.match(/\s+([\d.]+)%$/);
const lead = component.match(/^([\d.]+)%\s+/);
if (trail) { pct = parseFloat(trail[1]); colorStr = component.slice(0, trail.index).trim(); }
else if (lead) { pct = parseFloat(lead[1]); colorStr = component.slice(lead[0].length).trim(); }
let color;
if (/^transparent$/i.test(colorStr)) color = { r: 0, g: 0, b: 0, a: 0 };
else color = parseAnyColor(colorStr);
if (!color) return null;
return { color, pct };
};
const c1 = parseComponent(args[1]);
const c2 = parseComponent(args[2]);
if (!c1 || !c2) return null;
let p1 = c1.pct, p2 = c2.pct;
if (p1 == null && p2 == null) { p1 = 50; p2 = 50; }
else if (p1 == null) p1 = 100 - p2;
else if (p2 == null) p2 = 100 - p1;
const sum = p1 + p2;
if (sum <= 0) return null;
// Per spec: weights normalize to sum; when sum < 100 the result alpha is
// additionally scaled by sum/100.
const w1 = p1 / sum, w2 = p2 / sum;
const alphaScale = sum < 100 ? sum / 100 : 1;
const a1 = c1.color.a ?? 1, a2 = c2.color.a ?? 1;
const a = (a1 * w1 + a2 * w2) * alphaScale;
if (a <= 0) return { r: 0, g: 0, b: 0, a: 0 };
const mix = (ch) => Math.round((c1.color[ch] * a1 * w1 + c2.color[ch] * a2 * w2) / (a1 * w1 + a2 * w2));
return { r: mix('r'), g: mix('g'), b: mix('b'), a: Math.min(1, a) };
}
// Composite a translucent color over an opaque(ish) base (simple
// source-over in sRGB). Returns an opaque {r,g,b,a:1}.
function compositeColorOver(top, base) {
const a = top.a ?? 1;
return {
r: Math.round(top.r * a + base.r * (1 - a)),
g: Math.round(top.g * a + base.g * (1 - a)),
b: Math.round(top.b * a + base.b * (1 - a)),
a: 1,
};
}
// A color() / lab() / lch() component: a bare number, a percentage against
// `scale`, or the `none` keyword (which resolves to zero for our purposes).
function parseColorComponent(token, scale = 1) {
if (token == null) return null;
const t = String(token).trim();
if (/^none$/i.test(t)) return 0;
const num = parseFloat(t);
if (!Number.isFinite(num)) return null;
return t.endsWith('%') ? (num / 100) * scale : num;
}
function parseAlphaToken(token) {
if (token == null) return 1;
const t = String(token).trim();
if (/^none$/i.test(t)) return 1;
const num = parseFloat(t);
if (!Number.isFinite(num)) return 1;
return t.endsWith('%') ? num / 100 : num;
}
// Extended color parser: rgb/rgba/hex/oklch/oklab/lch/lab/hsl/hwb/color()/
// color-mix/common named colors. Returns null on no match. Use this when the
// input might be any CSS color form; use plain parseRgb when you only expect
// computed rgb() values from real browsers.
function parseAnyColor(s) {
if (!s || typeof s !== 'string') return null;
const str = s.trim();
if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
if (/^color-mix\(/i.test(str)) return parseColorMix(str);
let m;
m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/);
if (m) {
const c = { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: 1 };
if (m[4] !== undefined) c.a = m[5] === '%' ? parseFloat(m[4]) / 100 : +m[4];
return c;
}
m = str.match(/^#([0-9a-f]{3,8})$/i);
if (m) {
const h = m[1];
if (h.length === 3 || h.length === 4) {
return {
r: parseInt(h[0] + h[0], 16),
g: parseInt(h[1] + h[1], 16),
b: parseInt(h[2] + h[2], 16),
a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
};
}
if (h.length === 6 || h.length === 8) {
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
};
}
}
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
// Match L (with optional %), then C and H separated permissively.
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const Lnum = parseFloat(m[1]);
const L = m[2] === '%' ? Lnum / 100 : Lnum;
const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
if (m[5] !== undefined) {
const alpha = parseFloat(m[5]);
rgb.a = m[6] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// OKLAB — a/b are signed axes; percentages map 100% → 0.4.
m = str.match(/oklab\(\s*([\d.]+)(%?)\s+(-?[\d.]+)(%?)\s+(-?[\d.]+)(%?)(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const L = m[2] === '%' ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
const a = m[4] === '%' ? parseFloat(m[3]) * 0.004 : parseFloat(m[3]);
const b = m[6] === '%' ? parseFloat(m[5]) * 0.004 : parseFloat(m[5]);
const rgb = oklabToRgb(L, a, b);
if (m[7] !== undefined) {
const alpha = parseFloat(m[7]);
rgb.a = m[8] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// LCH / LAB — CIE, D50 white point. Chrome serializes lch(20% 5 60) as
// `lch(20 5 60)`, so L arrives with or without its percent sign. In both
// spaces L runs 0..100 and 100% means 100.
m = str.match(/^lch\(\s*([\d.]+%?|none)\s+([\d.]+%?|none)\s+(-?[\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
if (m) {
const L = parseColorComponent(m[1], 100);
const C = parseColorComponent(m[2], 150);
const H = parseFloat(m[3]);
if (L == null || C == null || !Number.isFinite(H)) return null;
const rgb = lchToRgb(L, C, H);
rgb.a = parseAlphaToken(m[4]);
return rgb;
}
m = str.match(/^lab\(\s*([\d.]+%?|none)\s+(-?[\d.]+%?|none)\s+(-?[\d.]+%?|none)(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
if (m) {
const L = parseColorComponent(m[1], 100);
const a = parseColorComponent(m[2], 125);
const b = parseColorComponent(m[3], 125);
if (L == null || a == null || b == null) return null;
const rgb = labToRgb(L, a, b);
rgb.a = parseAlphaToken(m[4]);
return rgb;
}
// color(<space> c1 c2 c3 [/ alpha]) — what Chrome hands back for most
// color-mix() results and for any wide-gamut color an author wrote.
m = str.match(/^color\(\s*([a-z0-9-]+)\s+(-?[\d.eE+-]+%?|none)\s+(-?[\d.eE+-]+%?|none)\s+(-?[\d.eE+-]+%?|none)(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
if (m) {
const c1 = parseColorComponent(m[2]);
const c2 = parseColorComponent(m[3]);
const c3 = parseColorComponent(m[4]);
if (c1 == null || c2 == null || c3 == null) return null;
const rgb = colorFunctionToRgb(m[1].toLowerCase(), c1, c2, c3);
if (!rgb) return null;
rgb.a = parseAlphaToken(m[5]);
return rgb;
}
// HSL/HSLA — comma or space syntax, optional deg on hue.
m = str.match(/hsla?\(\s*(-?[\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%\s*[,\s]\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hslToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// HWB — hue whiteness% blackness%.
m = str.match(/hwb\(\s*(-?[\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hwbToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
const named = CSS_NAMED_COLORS[str.toLowerCase()];
if (named) return { ...named, a: 1 };
return null;
}
// True when a computed background-color string names no paint at all. Used to
// tell "this layer is see-through" (walk on to the ancestor) apart from "this
// layer has a color we could not read" (stop and abstain).
//
// `inherit` belongs here even though it is not literally see-through: it means
// "paint with the parent's background-color", and walking on to the parent IS
// that resolution. Real browsers resolve the keyword before getComputedStyle
// output; only jsdom's partial cascade hands it through verbatim, and treating
// it as unreadable would make the walk abstain on a surface it can know.
// (`currentcolor` is NOT here — it is real paint in the element's own text
// color; resolveBackgroundInfo substitutes the computed color for it.)
function isNoPaintColorValue(value) {
const v = String(value || '').trim().toLowerCase();
if (!v) return true;
return v === 'transparent' || v === 'none' || v === 'initial' || v === 'inherit' || v === 'unset' || v === 'revert' || v === 'revert-layer';
}
export {
isNeutralColor,
parseRgb,
relativeLuminance,
contrastRatio,
parseGradientColors,
extractColorFunctionTokens,
hasChroma,
getHue,
colorToHex,
oklabToRgb,
oklchToRgb,
labToRgb,
lchToRgb,
colorFunctionToRgb,
hslToRgb,
hwbToRgb,
CSS_NAMED_COLORS,
splitTopLevelCommas,
parseColorMix,
parseAnyColor,
compositeColorOver,
isNoPaintColorValue,
};
File diff suppressed because it is too large Load Diff
@@ -11,14 +11,21 @@ import {
isBrandFontOnOwnDomain,
} from '../shared/constants.mjs';
import {
CSS_NAMED_COLORS,
colorToHex,
compositeColorOver,
contrastRatio,
getHue,
hasChroma,
isNeutralColor,
isNoPaintColorValue,
oklchToRgb,
parseAnyColor,
parseColorMix,
parseGradientColors,
parseRgb,
relativeLuminance,
splitTopLevelCommas,
} from '../shared/color.mjs';
import { extractGoogleFontFamilies } from '../shared/fonts.mjs';
@@ -1731,7 +1738,7 @@ function readOwnBackgroundColor(el, computedStyle) {
// One element's background-color as the cascade walk sees it: computed style
// first (with the modern-color fallback), then, in static mode only,
// custom-prop resolution and the inline-shorthand peek. Shared by
// resolveBackground and resolveGradientStops so both walks read the same
// resolveBackgroundInfo and resolveGradientStops so both walks read the same
// surfaces.
function readCascadeBackgroundColor(current, style, customPropMap) {
let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
@@ -1755,7 +1762,19 @@ function readCascadeBackgroundColor(current, style, customPropMap) {
return bg;
}
function resolveBackground(el, win, customPropMap) {
// Walk up for the surface the element's text is painted on.
//
// Returns { color, unresolved }:
// • color set — the effective surface, overlays composited in.
// • unresolved: true — a layer on the way up paints a color this parser
// cannot read, so the surface is unknown. Callers
// must SKIP their contrast checks. Guessing white
// here is what flooded dark themes with false
// "on #ffffff" findings: one abstention costs a
// single finding, one wrong guess costs a hundred.
// • both null/false — no solid color, but a gradient or image is in
// play; callers fall back to its color stops.
function resolveBackgroundInfo(el, win, customPropMap) {
let current = el;
// Translucent layers (0.1 < a < 1) found on the way down to an opaque
// base. A browser composites these over the base; the old behavior
@@ -1783,59 +1802,81 @@ function resolveBackground(el, win, customPropMap) {
// body backgrounds.
// Real browsers serialize wide-gamut computed values as oklab()/oklch()
// (e.g. any color-mix() result), which plain parseRgb misses.
const bg = readCascadeBackgroundColor(current, style, customPropMap);
let bg = readCascadeBackgroundColor(current, style, customPropMap);
// `background-color: currentcolor` paints with the element's own text
// color — real paint whose value we know. Real browsers resolve the
// keyword before getComputedStyle output; jsdom hands it through
// verbatim, and without this substitution the layer would read as
// unparseable and force a needless abstention.
if ((!bg || bg.a < 0.1) && /^currentcolor$/i.test(String(style.backgroundColor || '').trim())) {
// The static cascade resolves var() text tokens before checks run, so
// style.color is normally already an rgb string here; parseColorResolved
// is defense in depth for any future caller that passes a live
// customPropMap (it matches the text-color path in checkElementColors
// and reduces to parseAnyColor when the map is null or absent).
bg = parseRgb(style.color) || parseColorResolved(style.color, customPropMap);
}
if (bg && bg.a > 0.1) {
if (bg.a >= 0.99) return flatten(bg);
if (bg.a >= 0.99) return { color: flatten(bg), unresolved: false };
overlays.push(bg);
} else if (!bg && !isNoPaintColorValue(style.backgroundColor)) {
// This layer names a color we could not parse (a color space we do not
// model, an unresolved var(), a syntax newer than the parser). It may
// well be opaque, which would make every ancestor below it invisible —
// so the surface is unknown and the walk stops here rather than
// reporting an ancestor the visitor never sees.
return { color: null, unresolved: true };
}
// No solid bg-color at this level, but this level paints an image. CSS
// stacks background-image layers first-on-top, so which layer leads
// decides what the visitor sees:
// • gradient on top — the gradient is the surface. Hand the caller a
// null color so it falls back to the gradient's own stops (body
// grounds, gradient buttons, hero sections).
// • url() on top — the surface is an image whose pixels this engine
// cannot read, and it may fully cover every layer and ancestor
// beneath it. Same contract as an unparseable color: abstain, so
// the gradient-stop fallback never measures a gradient the image
// hides (the shipped miss: `url(photo), linear-gradient(...)`
// reported low-contrast against the invisible gradient's stops).
if (hasGradientOrUrl) {
const layers = splitTopLevelCommas(bgImage);
const topPaintLayer = layers.find(
(layer) => /gradient\s*\(/i.test(layer) || /url\s*\(/i.test(layer),
);
const gradientOnTop = !!topPaintLayer
&& /gradient\s*\(/i.test(topPaintLayer)
&& !/^\s*url\s*\(/i.test(topPaintLayer);
if (!gradientOnTop) return { color: null, unresolved: true };
// Gradient on top of a url() layer: the image shows through wherever
// the gradient is not fully opaque, so a translucent wash like
// `linear-gradient(rgba(0,0,0,.2), rgba(0,0,0,.2)), url(photo)` paints
// a blend with pixels this engine cannot read. Only a gradient whose
// every readable stop is opaque provably covers the image; otherwise
// the surface is unknown — abstain rather than hand callers gradient
// stops (or a stop average) the visitor never sees unmixed.
const urlBeneath = layers.some(
(layer) => layer !== topPaintLayer && /url\s*\(/i.test(layer),
);
if (urlBeneath) {
const topStops = parseGradientColors(topPaintLayer);
const provablyOpaque = topStops.length > 0 && topStops.every((s) => (s.a ?? 1) >= 0.99);
if (!provablyOpaque) return { color: null, unresolved: true };
}
return { color: null, unresolved: false };
}
// No solid bg-color at this level. If THIS level has a gradient/url
// with no underlying solid color we can read:
// • on body/html: assume white. Body-level gradients are almost
// always decorative texture (paper grain, noise) on top of a
// solid bg-color the page set via `background: var(--paper)`
// shorthand — which jsdom can't decompose into bg-color. The
// downstream gradient-stops fallback path produces catastrophic
// false positives in this case (gradient noise stops have
// accidental browns/blacks that look like card backgrounds).
// • on other elements: bail to null and let the caller fall back
// to gradient stops (gradient buttons / hero sections are real
// bgs worth checking against).
// A gradient or image with no solid color under it, at any level
// including body/html, means the visible ground is that layer itself.
// Return null so the caller measures against the actual gradient stops,
// or skips when nothing is parseable — skipping beats a wrong ratio.
//
// Body/html used to assume white here, a guard written for jsdom, which
// never decomposed the `background:` shorthand and so could not see the
// solid paper color a texture gradient usually sits on. It turned every
// light-on-dark page into a wall of low-contrast false positives (a dark
// oklch body gradient produced ~120 "on #ffffff" findings on one site).
// Both engines can see shorthand solids now — the browser natively, the
// static cascade via expandStaticDeclaration + var() resolution — so a
// missing solid is real, and the old failure case cannot recur: opaque
// stops fully cover any hidden solid (they ARE the ground), alpha stops
// composite over the resolved base or the white canvas default, and
// unresolvable stops drop rather than guess.
if (hasGradientOrUrl) return null;
current = current.parentElement;
}
return flatten({ r: 255, g: 255, b: 255, a: 1 });
// Every layer up to the document root was genuinely see-through, so the
// browser paints its default canvas. This is the ONLY case that earns the
// white assumption.
return { color: flatten({ r: 255, g: 255, b: 255, a: 1 }), unresolved: false };
}
// parseGradientColors (shared) reads only the legacy serializations: rgb()
// and hex stops. Browsers keep modern-space stops in computed backgroundImage
// exactly as authored — `linear-gradient(oklch(7% 0.006 95), …)` stays oklch —
// which is what every token-driven page produces. Route those through
// parseAnyColor so a gradient ground is measurable rather than invisible.
function parseGradientColorsModern(bgImage) {
if (!bgImage || !/gradient/i.test(bgImage)) return [];
const colors = parseGradientColors(bgImage);
for (const m of bgImage.matchAll(/(?:oklch|oklab|hsla?|hwb)\(\s*[^()]*\)/gi)) {
const c = parseAnyColor(m[0]);
if (c) colors.push(c);
}
return colors;
function resolveBackground(el, win, customPropMap) {
return resolveBackgroundInfo(el, win, customPropMap).color;
}
// Walk parents looking for a gradient background and return its color stops.
@@ -1861,7 +1902,10 @@ function resolveGradientStops(el, win, customPropMap) {
if (bgImage && bgImage !== 'none' && /url\s*\(/i.test(bgImage)) return null;
let stops = null;
if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
const parsed = parseGradientColorsModern(bgImage);
// parseGradientColors (shared) reads modern-space stops too — oklch,
// color-mix and friends via balanced-paren token capture — so browser
// computed values that keep the authored syntax stay measurable.
const parsed = parseGradientColors(bgImage);
if (parsed.length > 0) stops = parsed;
}
if (!stops && !DETECTOR_IS_BROWSER) {
@@ -1869,7 +1913,7 @@ function resolveGradientStops(el, win, customPropMap) {
const rawStyle = current.getAttribute?.('style') || '';
const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
if (bgMatch && /gradient/i.test(bgMatch[1])) {
const parsed = parseGradientColorsModern(bgMatch[1]);
const parsed = parseGradientColors(bgMatch[1]);
if (parsed.length > 0) stops = parsed;
}
}
@@ -2115,13 +2159,19 @@ function checkElementColorsDOM(el) {
if (style.visibility === 'hidden' || effectiveOpacityDOM(el) <= 0.02) return [];
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
let effectiveBg = resolveBackground(el);
const bgInfo = resolveBackgroundInfo(el);
let effectiveBg = bgInfo.color;
// An unreadable surface anywhere up the chain: skip the gradient-stop
// fallback too, so nothing downstream measures against a ground we never
// resolved.
let surfaceUnresolved = bgInfo.unresolved;
let ownBg = readOwnBackgroundColor(el, style);
if (!ownBg || (ownBg.a ?? 1) <= 0.5) {
const pseudoSurface = readPseudoSurfaceDOM(el, rect);
if (pseudoSurface) {
ownBg = pseudoSurface;
effectiveBg = pseudoSurface;
surfaceUnresolved = false;
}
}
return checkColors({
@@ -2133,8 +2183,8 @@ function checkElementColorsDOM(el) {
// an oklch token near its own oklch background).
textColor: parseRgb(style.color) || parseAnyColor(style.color),
bgColor: ownBg,
effectiveBg,
effectiveBgStops: effectiveBg ? null : resolveGradientStops(el),
effectiveBg: surfaceUnresolved ? null : effectiveBg,
effectiveBgStops: surfaceUnresolved || effectiveBg ? null : resolveGradientStops(el),
fontSize: parseFloat(style.fontSize) || 16,
fontWeight: parseInt(style.fontWeight) || 400,
hasDirectText,
@@ -2284,283 +2334,6 @@ function resolveVarRefs(raw, customPropMap, depth = 0) {
});
}
// OKLCH → sRGB conversion (Björn Ottosson's matrices). L in 0..1 (or %),
// C in 0..~0.4 typical, H in degrees. Returns clamped {r,g,b,a:1} in 0..255.
// Needed because jsdom doesn't compute oklch() values — getComputedStyle
// returns the literal "oklch(...)" string. Without this, the entire
// Tailwind v4 color palette (which is OKLCH-based) is invisible to the
// detector's contrast / color checks.
function oklchToRgb(L, C, H) {
const hRad = (H * Math.PI) / 180;
return oklabToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
}
function oklabToRgb(L, a, b) {
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
const rLin = 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc;
const gLin = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc;
const bLin = -0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc;
const enc = (x) => {
const c = Math.max(0, Math.min(1, x));
return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
};
return {
r: Math.round(enc(rLin) * 255),
g: Math.round(enc(gLin) * 255),
b: Math.round(enc(bLin) * 255),
a: 1,
};
}
function hslToRgb(h, s, l) {
h = ((h % 360) + 360) % 360;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m0 = l - c / 2;
const [r, g, b] =
h < 60 ? [c, x, 0] :
h < 120 ? [x, c, 0] :
h < 180 ? [0, c, x] :
h < 240 ? [0, x, c] :
h < 300 ? [x, 0, c] : [c, 0, x];
return {
r: Math.round((r + m0) * 255),
g: Math.round((g + m0) * 255),
b: Math.round((b + m0) * 255),
a: 1,
};
}
function hwbToRgb(h, w, bl) {
if (w + bl >= 1) {
const g = Math.round((w / (w + bl)) * 255);
return { r: g, g, b: g, a: 1 };
}
const base = hslToRgb(h, 1, 0.5);
const mix = (c) => Math.round(((c / 255) * (1 - w - bl) + w) * 255);
return { r: mix(base.r), g: mix(base.g), b: mix(base.b), a: 1 };
}
// Common CSS named colors — the handful that actually show up in generated
// UIs, not the full 148-name spec list. Includes the achromatic names so a
// named gray parses (and correctly reads as no-chroma) instead of being
// treated as an unknown color.
const CSS_NAMED_COLORS = {
black: { r: 0, g: 0, b: 0 },
white: { r: 255, g: 255, b: 255 },
gray: { r: 128, g: 128, b: 128 },
grey: { r: 128, g: 128, b: 128 },
silver: { r: 192, g: 192, b: 192 },
dimgray: { r: 105, g: 105, b: 105 },
darkgray: { r: 169, g: 169, b: 169 },
lightgray: { r: 211, g: 211, b: 211 },
gainsboro: { r: 220, g: 220, b: 220 },
whitesmoke: { r: 245, g: 245, b: 245 },
red: { r: 255, g: 0, b: 0 },
crimson: { r: 220, g: 20, b: 60 },
tomato: { r: 255, g: 99, b: 71 },
coral: { r: 255, g: 127, b: 80 },
salmon: { r: 250, g: 128, b: 114 },
orange: { r: 255, g: 165, b: 0 },
gold: { r: 255, g: 215, b: 0 },
yellow: { r: 255, g: 255, b: 0 },
olive: { r: 128, g: 128, b: 0 },
lime: { r: 0, g: 255, b: 0 },
green: { r: 0, g: 128, b: 0 },
teal: { r: 0, g: 128, b: 128 },
turquoise: { r: 64, g: 224, b: 208 },
cyan: { r: 0, g: 255, b: 255 },
aqua: { r: 0, g: 255, b: 255 },
skyblue: { r: 135, g: 206, b: 235 },
dodgerblue: { r: 30, g: 144, b: 255 },
blue: { r: 0, g: 0, b: 255 },
navy: { r: 0, g: 0, b: 128 },
indigo: { r: 75, g: 0, b: 130 },
rebeccapurple: { r: 102, g: 51, b: 153 },
purple: { r: 128, g: 0, b: 128 },
violet: { r: 238, g: 130, b: 238 },
orchid: { r: 218, g: 112, b: 214 },
magenta: { r: 255, g: 0, b: 255 },
fuchsia: { r: 255, g: 0, b: 255 },
hotpink: { r: 255, g: 105, b: 180 },
pink: { r: 255, g: 192, b: 203 },
maroon: { r: 128, g: 0, b: 0 },
};
// Split a string on top-level commas (ignoring commas nested in parens).
function splitTopLevelCommas(str) {
const parts = [];
let depth = 0, start = 0;
for (let i = 0; i < str.length; i++) {
const ch = str[i];
if (ch === '(') depth++;
else if (ch === ')') depth = Math.max(0, depth - 1);
else if (ch === ',' && depth === 0) {
parts.push(str.slice(start, i).trim());
start = i + 1;
}
}
const tail = str.slice(start).trim();
if (tail) parts.push(tail);
return parts;
}
// Evaluate a CSS color-mix() expression to {r,g,b,a}. Returns null when
// the expression can't be resolved (unresolved var(), unknown colors).
//
// Mixing is done with premultiplied alpha in sRGB regardless of the
// declared interpolation space. That is exact for the dominant generated-UI
// pattern — `color-mix(in oklab, <color> N%, transparent)` — where the
// result is simply <color> at alpha N% in ANY rectangular space, and a
// close-enough approximation for opaque-opaque mixes (the detector only
// consumes these values for contrast/chroma thresholds, not for display).
function parseColorMix(str) {
const m = String(str).trim().match(/^color-mix\(/i);
if (!m) return null;
// Balanced-paren capture of the arguments.
let depth = 0, end = -1;
const open = str.indexOf('(');
for (let i = open; i < str.length; i++) {
if (str[i] === '(') depth++;
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
}
if (end < 0) return null;
const args = splitTopLevelCommas(str.slice(open + 1, end));
if (args.length !== 3 || !/^in\s/i.test(args[0])) return null;
const parseComponent = (component) => {
// Percentage may lead or trail the color per spec.
let pct = null;
let colorStr = component;
const trail = component.match(/\s+([\d.]+)%$/);
const lead = component.match(/^([\d.]+)%\s+/);
if (trail) { pct = parseFloat(trail[1]); colorStr = component.slice(0, trail.index).trim(); }
else if (lead) { pct = parseFloat(lead[1]); colorStr = component.slice(lead[0].length).trim(); }
let color;
if (/^transparent$/i.test(colorStr)) color = { r: 0, g: 0, b: 0, a: 0 };
else color = parseAnyColor(colorStr);
if (!color) return null;
return { color, pct };
};
const c1 = parseComponent(args[1]);
const c2 = parseComponent(args[2]);
if (!c1 || !c2) return null;
let p1 = c1.pct, p2 = c2.pct;
if (p1 == null && p2 == null) { p1 = 50; p2 = 50; }
else if (p1 == null) p1 = 100 - p2;
else if (p2 == null) p2 = 100 - p1;
const sum = p1 + p2;
if (sum <= 0) return null;
// Per spec: weights normalize to sum; when sum < 100 the result alpha is
// additionally scaled by sum/100.
const w1 = p1 / sum, w2 = p2 / sum;
const alphaScale = sum < 100 ? sum / 100 : 1;
const a1 = c1.color.a ?? 1, a2 = c2.color.a ?? 1;
const a = (a1 * w1 + a2 * w2) * alphaScale;
if (a <= 0) return { r: 0, g: 0, b: 0, a: 0 };
const mix = (ch) => Math.round((c1.color[ch] * a1 * w1 + c2.color[ch] * a2 * w2) / (a1 * w1 + a2 * w2));
return { r: mix('r'), g: mix('g'), b: mix('b'), a: Math.min(1, a) };
}
// Composite a translucent color over an opaque(ish) base (simple
// source-over in sRGB). Returns an opaque {r,g,b,a:1}.
function compositeColorOver(top, base) {
const a = top.a ?? 1;
return {
r: Math.round(top.r * a + base.r * (1 - a)),
g: Math.round(top.g * a + base.g * (1 - a)),
b: Math.round(top.b * a + base.b * (1 - a)),
a: 1,
};
}
// Extended color parser: rgb/rgba/hex/oklch/oklab/hsl/hwb/color-mix/common
// named colors. Returns null on no match. Use this when the input might be
// any CSS color form; use plain parseRgb when you only expect computed rgb()
// values from real browsers.
function parseAnyColor(s) {
if (!s || typeof s !== 'string') return null;
const str = s.trim();
if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
if (/^color-mix\(/i.test(str)) return parseColorMix(str);
let m;
m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+))?\s*\)/);
if (m) return { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: m[4] !== undefined ? +m[4] : 1 };
m = str.match(/^#([0-9a-f]{3,8})$/i);
if (m) {
const h = m[1];
if (h.length === 3 || h.length === 4) {
return {
r: parseInt(h[0] + h[0], 16),
g: parseInt(h[1] + h[1], 16),
b: parseInt(h[2] + h[2], 16),
a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
};
}
if (h.length === 6 || h.length === 8) {
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
};
}
}
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
// Match L (with optional %), then C and H separated permissively.
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const Lnum = parseFloat(m[1]);
const L = m[2] === '%' ? Lnum / 100 : Lnum;
const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
if (m[5] !== undefined) {
const alpha = parseFloat(m[5]);
rgb.a = m[6] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// OKLAB — a/b are signed axes; percentages map 100% → 0.4.
m = str.match(/oklab\(\s*([\d.]+)(%?)\s+(-?[\d.]+)(%?)\s+(-?[\d.]+)(%?)(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const L = m[2] === '%' ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
const a = m[4] === '%' ? parseFloat(m[3]) * 0.004 : parseFloat(m[3]);
const b = m[6] === '%' ? parseFloat(m[5]) * 0.004 : parseFloat(m[5]);
const rgb = oklabToRgb(L, a, b);
if (m[7] !== undefined) {
const alpha = parseFloat(m[7]);
rgb.a = m[8] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// HSL/HSLA — comma or space syntax, optional deg on hue.
m = str.match(/hsla?\(\s*(-?[\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%\s*[,\s]\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hslToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// HWB — hue whiteness% blackness%.
m = str.match(/hwb\(\s*(-?[\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hwbToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
const named = CSS_NAMED_COLORS[str.toLowerCase()];
if (named) return { ...named, a: 1 };
return null;
}
// Resolve var() refs in a color string (via customPropMap), then parse.
// Returns null on any failure. Used in jsdom-mode paths where
@@ -2923,15 +2696,24 @@ function checkElementGlowDOM(el) {
if (!boxShadow && !textShadow) return [];
// Use parent's background — glow radiates outward, so the surrounding context matters
// If resolveBackground returns null (gradient), try to infer from the gradient colors
let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el);
if (!parentBg) {
const parentBgInfo = resolveBackgroundInfo(el.parentElement || el);
// Unknown surface (an unreadable layer on the way up): skip only the
// gradient hunt below, which would walk PAST that layer and score the
// glow against a background the visitor never sees. checkGlow still runs
// with a null surface: the zero-offset chromatic halo tell holds on ANY
// background, and the static loop already passes the unresolved walk's
// null color straight through (detect-html.mjs uses resolveBackground).
let parentBg = parentBgInfo.color;
if (!parentBg && !parentBgInfo.unresolved) {
// Gradient background — sample its colors to determine if it's dark.
// Modern-syntax parsing matters here: body-level gradients now reach this
// fallback in browser mode, and their stops usually serialize as oklch.
// fallback in browser mode, and their stops usually serialize as oklch
// which the shared parseGradientColors reads via its color-function
// token capture.
let cur = el.parentElement;
while (cur && cur.nodeType === 1) {
const bgImage = getComputedStyle(cur).backgroundImage || '';
const gradColors = parseGradientColorsModern(bgImage);
const gradColors = parseGradientColors(bgImage);
if (gradColors.length > 0) {
// Average the gradient colors
const avg = { r: 0, g: 0, b: 0 };
@@ -2975,10 +2757,13 @@ function checkElementAIPaletteDOM(el) {
const hue = getHue(textColor);
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
if (isAIPalette) {
const parentBg = el.parentElement ? resolveBackground(el.parentElement) : null;
// Also check gradient parents
let effectiveBg = parentBg;
if (!effectiveBg) {
const parentBgInfo = el.parentElement
? resolveBackgroundInfo(el.parentElement)
: { color: null, unresolved: false };
// Unknown surface: leave effectiveBg null (no finding) rather than
// hunting gradient ancestors past a layer we could not read.
let effectiveBg = parentBgInfo.color;
if (!effectiveBg && !parentBgInfo.unresolved) {
let cur = el.parentElement;
while (cur && cur.nodeType === 1) {
const gi = getComputedStyle(cur).backgroundImage || '';
@@ -3784,7 +3569,8 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
const effectiveBg = resolveBackground(el, window, customPropMap);
const bgInfo = resolveBackgroundInfo(el, window, customPropMap);
const effectiveBg = bgInfo.color;
// jsdom returns literal "var(--X)" / "oklch(...)" for color, so plain
// parseRgb misses Tailwind-tokenized text colors. Resolve through the
// customPropMap first; fall back to parseRgb for vanilla rgb() pages.
@@ -3830,11 +3616,13 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
// element itself has no usable own background, that pseudo is the real
// surface for contrast purposes.
let finalEffectiveBg = effectiveBg;
let surfaceUnresolved = bgInfo.unresolved;
if ((!ownBg || (ownBg.a ?? 1) <= 0.5) && typeof window.getPseudoSurface === 'function') {
const pseudoSurface = window.getPseudoSurface(el);
if (pseudoSurface) {
ownBg = pseudoSurface;
finalEffectiveBg = pseudoSurface;
surfaceUnresolved = false;
}
}
@@ -3842,8 +3630,9 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
tag,
textColor,
bgColor: ownBg,
effectiveBg: finalEffectiveBg,
effectiveBgStops: finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
// Unknown surface: hand the checks nothing rather than a guess.
effectiveBg: surfaceUnresolved ? null : finalEffectiveBg,
effectiveBgStops: surfaceUnresolved || finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
fontSize: parseFloat(style.fontSize) || 16,
fontWeight: parseInt(style.fontWeight) || 400,
hasDirectText,
@@ -5663,6 +5452,7 @@ export {
checkHtmlPatterns,
readOwnBackgroundColor,
resolveBackground,
resolveBackgroundInfo,
resolveGradientStops,
parseRadiusToPx,
resolveBorderRadiusPx,
@@ -71,11 +71,43 @@ function contrastRatio(c1, c2) {
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
// The CSS color functions worth pulling out of a longer declaration. The set
// is deliberately closed: `linear-gradient(` and `url(` also look like
// `name(` and must not be read as colors.
const COLOR_FUNCTION_NAMES = new Set([
'rgb', 'rgba', 'hsl', 'hsla', 'hwb', 'oklch', 'oklab', 'lch', 'lab', 'color', 'color-mix',
]);
// Pull every color-function token out of a value, with balanced-paren capture
// so nested forms (`color-mix(in oklab, oklch(...) 20%, transparent)`) survive
// whole. Returns the raw substrings in source order.
function extractColorFunctionTokens(value) {
const str = String(value || '');
const tokens = [];
const re = /([a-z][a-z-]*)\(/gi;
let m;
while ((m = re.exec(str)) !== null) {
if (!COLOR_FUNCTION_NAMES.has(m[1].toLowerCase())) continue;
let depth = 0, end = -1;
for (let i = m.index + m[0].length - 1; i < str.length; i++) {
if (str[i] === '(') depth++;
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
}
if (end < 0) break;
tokens.push(str.slice(m.index, end + 1));
re.lastIndex = end + 1;
}
return tokens;
}
function parseGradientColors(bgImage) {
if (!bgImage || !bgImage.includes('gradient')) return [];
const colors = [];
for (const m of bgImage.matchAll(/rgba?\([^)]+\)/g)) {
const c = parseRgb(m[0]);
// Stops arrive in whatever syntax the author wrote and the browser kept.
// A dark ground painted as `linear-gradient(oklch(...), oklch(...))` used
// to read as a gradient with no stops at all.
for (const token of extractColorFunctionTokens(bgImage)) {
const c = parseAnyColor(token);
if (c) colors.push(c);
}
for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
@@ -112,13 +144,445 @@ function colorToHex(c) {
return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
}
// ─── Color-space conversions ────────────────────────────────────────────────
//
// Every function here lands on 8-bit sRGB, clamped to gamut. Chrome, Safari,
// and Firefox all keep the authored color space in getComputedStyle output
// (`oklch(0.84 0.19 80.46)`, `lch(20 5 60)`, `color(srgb 1.04 0.72 -0.21)`),
// so a detector that only reads rgb() is blind on any modern palette. The
// expected outputs are pinned in tests/detect-antipatterns.test.js against
// what Chrome itself paints for the same strings.
function clamp01(x) {
return Number.isFinite(x) ? Math.max(0, Math.min(1, x)) : 0;
}
// Linear-light sRGB channel to the encoded 0-255 value.
function encodeSrgbChannel(x) {
const c = clamp01(x);
return Math.round((c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055) * 255);
}
function decodeSrgbChannel(x) {
const c = Number.isFinite(x) ? x : 0;
const sign = c < 0 ? -1 : 1;
const abs = Math.abs(c);
return sign * (abs <= 0.04045 ? abs / 12.92 : Math.pow((abs + 0.055) / 1.055, 2.4));
}
function linearSrgbToColor(r, g, b, a = 1) {
return { r: encodeSrgbChannel(r), g: encodeSrgbChannel(g), b: encodeSrgbChannel(b), a };
}
// OKLab to sRGB (Björn Ottosson's matrices). L in 0..1, a/b are signed axes.
function oklabToRgb(L, a, b) {
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
return linearSrgbToColor(
4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc,
-1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc,
-0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc,
);
}
// OKLCH to sRGB. L in 0..1, C in 0..~0.4 typical, H in degrees. Chroma past
// the sRGB gamut clamps per channel rather than producing NaN.
function oklchToRgb(L, C, H) {
const hRad = (H * Math.PI) / 180;
return oklabToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
}
// CIE Lab to sRGB. CSS lab()/lch() use the D50 white point; the matrix below
// is the Bradford-adapted XYZ-D50 to linear-sRGB transform from CSS Color 4.
function labToRgb(L, a, b) {
const kappa = 24389 / 27, epsilon = 216 / 24389;
const fy = (L + 16) / 116, fx = fy + a / 500, fz = fy - b / 200;
const invert = (t) => (t * t * t > epsilon ? t * t * t : (116 * t - 16) / kappa);
const yr = L > kappa * epsilon ? Math.pow((L + 16) / 116, 3) : L / kappa;
const Xn = 0.3457 / 0.3585, Zn = (1 - 0.3457 - 0.3585) / 0.3585;
const x = invert(fx) * Xn, y = yr, z = invert(fz) * Zn;
return linearSrgbToColor(
3.1341359569958707 * x - 1.6173863321612538 * y - 0.4906619460083532 * z,
-0.9787955029120890 * x + 1.9162545672595240 * y + 0.0334427311613195 * z,
0.0719553798841168 * x - 0.2289768264158322 * y + 1.4053860583241250 * z,
);
}
function lchToRgb(L, C, H) {
const hRad = (H * Math.PI) / 180;
return labToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
}
// color(<space> c1 c2 c3) for the spaces that turn up in real stylesheets.
// `srgb` is what Chrome serializes most color-mix() results into, routinely
// with channels outside 0..1. Spaces we do not model return null so callers
// abstain instead of measuring against a color we invented.
function colorFunctionToRgb(space, c1, c2, c3) {
switch (space) {
case 'srgb':
return { r: Math.round(clamp01(c1) * 255), g: Math.round(clamp01(c2) * 255), b: Math.round(clamp01(c3) * 255), a: 1 };
case 'srgb-linear':
return linearSrgbToColor(c1, c2, c3);
case 'display-p3': {
const [R, G, B] = [decodeSrgbChannel(c1), decodeSrgbChannel(c2), decodeSrgbChannel(c3)];
return linearSrgbToColor(
1.2249401762805587 * R - 0.2249404646817506 * G + 0.0000002884022551 * B,
-0.0420569547096138 * R + 1.0420571661298634 * G - 0.0000002113202247 * B,
-0.0196375587040044 * R - 0.0786360772174755 * G + 1.0982736359214800 * B,
);
}
default:
return null;
}
}
function hslToRgb(h, s, l) {
h = ((h % 360) + 360) % 360;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m0 = l - c / 2;
const [r, g, b] =
h < 60 ? [c, x, 0] :
h < 120 ? [x, c, 0] :
h < 180 ? [0, c, x] :
h < 240 ? [0, x, c] :
h < 300 ? [x, 0, c] : [c, 0, x];
return {
r: Math.round((r + m0) * 255),
g: Math.round((g + m0) * 255),
b: Math.round((b + m0) * 255),
a: 1,
};
}
function hwbToRgb(h, w, bl) {
if (w + bl >= 1) {
const g = Math.round((w / (w + bl)) * 255);
return { r: g, g, b: g, a: 1 };
}
const base = hslToRgb(h, 1, 0.5);
const mix = (c) => Math.round(((c / 255) * (1 - w - bl) + w) * 255);
return { r: mix(base.r), g: mix(base.g), b: mix(base.b), a: 1 };
}
// Common CSS named colors — the handful that actually show up in generated
// UIs, not the full 148-name spec list. Includes the achromatic names so a
// named gray parses (and correctly reads as no-chroma) instead of being
// treated as an unknown color.
const CSS_NAMED_COLORS = {
black: { r: 0, g: 0, b: 0 },
white: { r: 255, g: 255, b: 255 },
gray: { r: 128, g: 128, b: 128 },
grey: { r: 128, g: 128, b: 128 },
silver: { r: 192, g: 192, b: 192 },
dimgray: { r: 105, g: 105, b: 105 },
darkgray: { r: 169, g: 169, b: 169 },
lightgray: { r: 211, g: 211, b: 211 },
gainsboro: { r: 220, g: 220, b: 220 },
whitesmoke: { r: 245, g: 245, b: 245 },
red: { r: 255, g: 0, b: 0 },
crimson: { r: 220, g: 20, b: 60 },
tomato: { r: 255, g: 99, b: 71 },
coral: { r: 255, g: 127, b: 80 },
salmon: { r: 250, g: 128, b: 114 },
orange: { r: 255, g: 165, b: 0 },
gold: { r: 255, g: 215, b: 0 },
yellow: { r: 255, g: 255, b: 0 },
olive: { r: 128, g: 128, b: 0 },
lime: { r: 0, g: 255, b: 0 },
green: { r: 0, g: 128, b: 0 },
teal: { r: 0, g: 128, b: 128 },
turquoise: { r: 64, g: 224, b: 208 },
cyan: { r: 0, g: 255, b: 255 },
aqua: { r: 0, g: 255, b: 255 },
skyblue: { r: 135, g: 206, b: 235 },
dodgerblue: { r: 30, g: 144, b: 255 },
blue: { r: 0, g: 0, b: 255 },
navy: { r: 0, g: 0, b: 128 },
indigo: { r: 75, g: 0, b: 130 },
rebeccapurple: { r: 102, g: 51, b: 153 },
purple: { r: 128, g: 0, b: 128 },
violet: { r: 238, g: 130, b: 238 },
orchid: { r: 218, g: 112, b: 214 },
magenta: { r: 255, g: 0, b: 255 },
fuchsia: { r: 255, g: 0, b: 255 },
hotpink: { r: 255, g: 105, b: 180 },
pink: { r: 255, g: 192, b: 203 },
maroon: { r: 128, g: 0, b: 0 },
};
// Split a string on top-level commas (ignoring commas nested in parens).
function splitTopLevelCommas(str) {
const parts = [];
let depth = 0, start = 0;
for (let i = 0; i < str.length; i++) {
const ch = str[i];
if (ch === '(') depth++;
else if (ch === ')') depth = Math.max(0, depth - 1);
else if (ch === ',' && depth === 0) {
parts.push(str.slice(start, i).trim());
start = i + 1;
}
}
const tail = str.slice(start).trim();
if (tail) parts.push(tail);
return parts;
}
// Evaluate a CSS color-mix() expression to {r,g,b,a}. Returns null when
// the expression can't be resolved (unresolved var(), unknown colors).
//
// Mixing is done with premultiplied alpha in sRGB regardless of the
// declared interpolation space. That is exact for the dominant generated-UI
// pattern — `color-mix(in oklab, <color> N%, transparent)` — where the
// result is simply <color> at alpha N% in ANY rectangular space, and a
// close-enough approximation for opaque-opaque mixes (the detector only
// consumes these values for contrast/chroma thresholds, not for display).
function parseColorMix(str) {
const m = String(str).trim().match(/^color-mix\(/i);
if (!m) return null;
// Balanced-paren capture of the arguments.
let depth = 0, end = -1;
const open = str.indexOf('(');
for (let i = open; i < str.length; i++) {
if (str[i] === '(') depth++;
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
}
if (end < 0) return null;
const args = splitTopLevelCommas(str.slice(open + 1, end));
if (args.length !== 3 || !/^in\s/i.test(args[0])) return null;
const parseComponent = (component) => {
// Percentage may lead or trail the color per spec.
let pct = null;
let colorStr = component;
const trail = component.match(/\s+([\d.]+)%$/);
const lead = component.match(/^([\d.]+)%\s+/);
if (trail) { pct = parseFloat(trail[1]); colorStr = component.slice(0, trail.index).trim(); }
else if (lead) { pct = parseFloat(lead[1]); colorStr = component.slice(lead[0].length).trim(); }
let color;
if (/^transparent$/i.test(colorStr)) color = { r: 0, g: 0, b: 0, a: 0 };
else color = parseAnyColor(colorStr);
if (!color) return null;
return { color, pct };
};
const c1 = parseComponent(args[1]);
const c2 = parseComponent(args[2]);
if (!c1 || !c2) return null;
let p1 = c1.pct, p2 = c2.pct;
if (p1 == null && p2 == null) { p1 = 50; p2 = 50; }
else if (p1 == null) p1 = 100 - p2;
else if (p2 == null) p2 = 100 - p1;
const sum = p1 + p2;
if (sum <= 0) return null;
// Per spec: weights normalize to sum; when sum < 100 the result alpha is
// additionally scaled by sum/100.
const w1 = p1 / sum, w2 = p2 / sum;
const alphaScale = sum < 100 ? sum / 100 : 1;
const a1 = c1.color.a ?? 1, a2 = c2.color.a ?? 1;
const a = (a1 * w1 + a2 * w2) * alphaScale;
if (a <= 0) return { r: 0, g: 0, b: 0, a: 0 };
const mix = (ch) => Math.round((c1.color[ch] * a1 * w1 + c2.color[ch] * a2 * w2) / (a1 * w1 + a2 * w2));
return { r: mix('r'), g: mix('g'), b: mix('b'), a: Math.min(1, a) };
}
// Composite a translucent color over an opaque(ish) base (simple
// source-over in sRGB). Returns an opaque {r,g,b,a:1}.
function compositeColorOver(top, base) {
const a = top.a ?? 1;
return {
r: Math.round(top.r * a + base.r * (1 - a)),
g: Math.round(top.g * a + base.g * (1 - a)),
b: Math.round(top.b * a + base.b * (1 - a)),
a: 1,
};
}
// A color() / lab() / lch() component: a bare number, a percentage against
// `scale`, or the `none` keyword (which resolves to zero for our purposes).
function parseColorComponent(token, scale = 1) {
if (token == null) return null;
const t = String(token).trim();
if (/^none$/i.test(t)) return 0;
const num = parseFloat(t);
if (!Number.isFinite(num)) return null;
return t.endsWith('%') ? (num / 100) * scale : num;
}
function parseAlphaToken(token) {
if (token == null) return 1;
const t = String(token).trim();
if (/^none$/i.test(t)) return 1;
const num = parseFloat(t);
if (!Number.isFinite(num)) return 1;
return t.endsWith('%') ? num / 100 : num;
}
// Extended color parser: rgb/rgba/hex/oklch/oklab/lch/lab/hsl/hwb/color()/
// color-mix/common named colors. Returns null on no match. Use this when the
// input might be any CSS color form; use plain parseRgb when you only expect
// computed rgb() values from real browsers.
function parseAnyColor(s) {
if (!s || typeof s !== 'string') return null;
const str = s.trim();
if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
if (/^color-mix\(/i.test(str)) return parseColorMix(str);
let m;
m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/);
if (m) {
const c = { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: 1 };
if (m[4] !== undefined) c.a = m[5] === '%' ? parseFloat(m[4]) / 100 : +m[4];
return c;
}
m = str.match(/^#([0-9a-f]{3,8})$/i);
if (m) {
const h = m[1];
if (h.length === 3 || h.length === 4) {
return {
r: parseInt(h[0] + h[0], 16),
g: parseInt(h[1] + h[1], 16),
b: parseInt(h[2] + h[2], 16),
a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
};
}
if (h.length === 6 || h.length === 8) {
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
};
}
}
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
// Match L (with optional %), then C and H separated permissively.
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const Lnum = parseFloat(m[1]);
const L = m[2] === '%' ? Lnum / 100 : Lnum;
const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
if (m[5] !== undefined) {
const alpha = parseFloat(m[5]);
rgb.a = m[6] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// OKLAB — a/b are signed axes; percentages map 100% → 0.4.
m = str.match(/oklab\(\s*([\d.]+)(%?)\s+(-?[\d.]+)(%?)\s+(-?[\d.]+)(%?)(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const L = m[2] === '%' ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
const a = m[4] === '%' ? parseFloat(m[3]) * 0.004 : parseFloat(m[3]);
const b = m[6] === '%' ? parseFloat(m[5]) * 0.004 : parseFloat(m[5]);
const rgb = oklabToRgb(L, a, b);
if (m[7] !== undefined) {
const alpha = parseFloat(m[7]);
rgb.a = m[8] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// LCH / LAB — CIE, D50 white point. Chrome serializes lch(20% 5 60) as
// `lch(20 5 60)`, so L arrives with or without its percent sign. In both
// spaces L runs 0..100 and 100% means 100.
m = str.match(/^lch\(\s*([\d.]+%?|none)\s+([\d.]+%?|none)\s+(-?[\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
if (m) {
const L = parseColorComponent(m[1], 100);
const C = parseColorComponent(m[2], 150);
const H = parseFloat(m[3]);
if (L == null || C == null || !Number.isFinite(H)) return null;
const rgb = lchToRgb(L, C, H);
rgb.a = parseAlphaToken(m[4]);
return rgb;
}
m = str.match(/^lab\(\s*([\d.]+%?|none)\s+(-?[\d.]+%?|none)\s+(-?[\d.]+%?|none)(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
if (m) {
const L = parseColorComponent(m[1], 100);
const a = parseColorComponent(m[2], 125);
const b = parseColorComponent(m[3], 125);
if (L == null || a == null || b == null) return null;
const rgb = labToRgb(L, a, b);
rgb.a = parseAlphaToken(m[4]);
return rgb;
}
// color(<space> c1 c2 c3 [/ alpha]) — what Chrome hands back for most
// color-mix() results and for any wide-gamut color an author wrote.
m = str.match(/^color\(\s*([a-z0-9-]+)\s+(-?[\d.eE+-]+%?|none)\s+(-?[\d.eE+-]+%?|none)\s+(-?[\d.eE+-]+%?|none)(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
if (m) {
const c1 = parseColorComponent(m[2]);
const c2 = parseColorComponent(m[3]);
const c3 = parseColorComponent(m[4]);
if (c1 == null || c2 == null || c3 == null) return null;
const rgb = colorFunctionToRgb(m[1].toLowerCase(), c1, c2, c3);
if (!rgb) return null;
rgb.a = parseAlphaToken(m[5]);
return rgb;
}
// HSL/HSLA — comma or space syntax, optional deg on hue.
m = str.match(/hsla?\(\s*(-?[\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%\s*[,\s]\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hslToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
// HWB — hue whiteness% blackness%.
m = str.match(/hwb\(\s*(-?[\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const rgb = hwbToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
if (m[4] !== undefined) {
const alpha = parseFloat(m[4]);
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
const named = CSS_NAMED_COLORS[str.toLowerCase()];
if (named) return { ...named, a: 1 };
return null;
}
// True when a computed background-color string names no paint at all. Used to
// tell "this layer is see-through" (walk on to the ancestor) apart from "this
// layer has a color we could not read" (stop and abstain).
//
// `inherit` belongs here even though it is not literally see-through: it means
// "paint with the parent's background-color", and walking on to the parent IS
// that resolution. Real browsers resolve the keyword before getComputedStyle
// output; only jsdom's partial cascade hands it through verbatim, and treating
// it as unreadable would make the walk abstain on a surface it can know.
// (`currentcolor` is NOT here — it is real paint in the element's own text
// color; resolveBackgroundInfo substitutes the computed color for it.)
function isNoPaintColorValue(value) {
const v = String(value || '').trim().toLowerCase();
if (!v) return true;
return v === 'transparent' || v === 'none' || v === 'initial' || v === 'inherit' || v === 'unset' || v === 'revert' || v === 'revert-layer';
}
export {
isNeutralColor,
parseRgb,
relativeLuminance,
contrastRatio,
parseGradientColors,
extractColorFunctionTokens,
hasChroma,
getHue,
colorToHex,
oklabToRgb,
oklchToRgb,
labToRgb,
lchToRgb,
colorFunctionToRgb,
hslToRgb,
hwbToRgb,
CSS_NAMED_COLORS,
splitTopLevelCommas,
parseColorMix,
parseAnyColor,
compositeColorOver,
isNoPaintColorValue,
};