mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-18 00:56:30 +03:00
Stop assuming white when a background cannot be read (#541)
* Stop assuming white when a background cannot be read Dark themes came back from a scan buried in low-contrast findings that all claimed the light text sat on #ffffff. Two live runs against impeccable.style produced 102 and 95 of them. Two causes, both fixed here. Parsing. Browsers keep the authored color space in getComputedStyle output: oklch() stayed oklch, but color-mix results come back as color(srgb 1.04 0.72 -0.21), wide-gamut authors get color(display-p3 ...), and lch()/lab() survive verbatim. The parser read none of those, so those surfaces registered as unset. parseGradientColors was worse: it matched only rgba() and #hex, so a ground painted as linear-gradient(oklch(...), oklch(...)) counted as a gradient with no stops at all. Guessing. When the ancestor walk ran out of readable color it returned white, and on a body-level gradient it returned white without even looking. Light copy on a lacquer-black page then measured 1.3:1 against a canvas the visitor never sees. resolveBackgroundInfo now separates three outcomes: a resolved surface, a gradient the caller should fall back to stops for, and an unreadable layer. The last one makes both color adapters skip their contrast checks entirely. White survives in exactly one case, the one that earns it: every layer up to the document root was genuinely transparent. Color conversions moved to cli/engine/shared/color.mjs and gained lab, lch, and color() for srgb, srgb-linear, and display-p3. Spaces outside that set return null, which now routes to abstention rather than to a color nobody painted. Every conversion is pinned against what Chrome itself paints for the same string. Rescanning impeccable.style: 102 low-contrast findings down to 30, none of them on an invented white ground. Assisted-by: Claude Code * fix: address PR review bot findings on background resolution - Treat a url() image layer stacked above a gradient as an occluding, unreadable surface: resolveBackgroundInfo now returns unresolved so the gradient-stop fallback never measures stops the image hides (greptile-apps finding, reproduced in Chrome). - Route the glow and AI-palette DOM adapters through resolveBackgroundInfo so an unresolved surface makes them abstain instead of hunting gradient ancestors past an unreadable layer (Cursor Bugbot finding). - Resolve background-color keywords jsdom hands through verbatim: inherit now reads as no-paint (the ancestor walk IS its resolution) and currentcolor substitutes the element's own computed text color instead of forcing an abstention (Copilot finding). - Regression coverage in the dark-theme fixture for all three, asserted in both the jsdom and real-Chrome suites; browser detector regenerated. AI-assisted: prepared with Claude Code at the maintainer's direction. Co-Authored-By: Claude <noreply@anthropic.com> * fix: keep zero-offset glow findings when the surface is unreadable The browser glow adapter abstained from the whole element when resolveBackgroundInfo reported an unreadable surface, which also dropped zero-offset chromatic halo findings that do not depend on the background at all. It now skips only the gradient hunt past the unreadable layer and scores the halo tell against a null surface, matching what the static loop already did. Fixture cases pin both sides: the halo over a url() image ancestor flags in both engines, and an offset chromatic shadow on the same unknown surface stays abstained. Also hardens the currentcolor background substitution with the parseColorResolved fallback used by the text-color path, and adds fixture coverage proving tokenized currentcolor surfaces already resolve through the static cascade (flag when knowable, abstain when the token is undefined). Addresses Cursor Bugbot review findings on PR #541. AI-assisted-by: Claude Code Co-Authored-By: Claude <noreply@anthropic.com> * fix: abstain on translucent gradients over images, drop phantom color-mix stops Two follow-up review findings on the merge with main. A gradient leading a url() layer was treated as a resolvable surface even when its stops are translucent, so the glow and AI-palette hunts averaged wash stops (a 20% black wash reads as pure black) while the real surface blends with image pixels the engine cannot read. resolveBackgroundInfo now marks gradient-over-image unresolved unless every readable stop of the leading gradient is opaque, in which case the gradient provably covers the image and remains the scorable surface. parseGradientColorsModern predated this branch's parseGradientColors rewrite: its second regex pass re-extracted color tokens nested inside color-mix() stops that the shared parser already captures whole via balanced-paren tokens, appending ingredient colors that are never painted. The worst-case stop ratio then invented low-contrast findings against a color nobody sees. The helper is removed; all callers use the shared parser, which covers the modern syntaxes it existed for. Fixture coverage pins both: the translucent-wash-over-image glow abstains in both engines, an opaque gradient over an image still flags in the browser, and the color-mix wash case stays clean in the static engine. Each new assertion was verified to fail against the previous engine. Addresses Greptile and Cursor Bugbot review findings on PR #541. AI-assisted-by: Claude Code Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
+130
-340
@@ -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,
|
||||
|
||||
+466
-2
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user