mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
294199d542 | ||
|
|
da286a938d | ||
|
|
1fec78d3d2 | ||
|
|
6df96fbced | ||
|
|
331af89eae |
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,
|
||||
};
|
||||
|
||||
@@ -113,6 +113,35 @@ describe('detectUrl — browser-only fixtures', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('dark-glow: unreadable url() surface keeps zero-offset halos, abstains on offset chromatic shadows', async () => {
|
||||
// Mirrors the static assertions in detect-antipatterns-fixtures.test.mjs.
|
||||
// The browser adapter (checkElementGlowDOM) once returned [] for the
|
||||
// whole element when the parent surface was unresolved, which also
|
||||
// dropped zero-offset chromatic halos that need no background at all.
|
||||
const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/glow.html`, { visualContrast: false });
|
||||
const glow = f.filter(r => r.antipattern === 'dark-glow');
|
||||
assert.ok(
|
||||
glow.some(g => /Zero-offset box-shadow glow \(#d946ef\)/i.test(g.snippet || '')),
|
||||
'expected zero-offset halo finding under unreadable image surface',
|
||||
);
|
||||
assert.equal(
|
||||
glow.filter(g => /#10b981/i.test(g.snippet || '')).length, 0,
|
||||
'offset chromatic shadow on unknown surface must not be scored',
|
||||
);
|
||||
// Gradient-over-image split: an opaque gradient provably covers the
|
||||
// image, so the dark-background tell may score against its stops; a
|
||||
// translucent wash blends with unknowable pixels (a white photo under a
|
||||
// 20% black wash paints ~#cccccc, not black), so the walk abstains.
|
||||
assert.ok(
|
||||
glow.some(g => /Colored box-shadow glow \(#f97316\) on dark background/i.test(g.snippet || '')),
|
||||
'expected colored-glow finding under a provably opaque gradient over an image',
|
||||
);
|
||||
assert.equal(
|
||||
glow.filter(g => /#f43f5e/i.test(g.snippet || '')).length, 0,
|
||||
'offset chromatic shadow under a translucent wash over an image must abstain',
|
||||
);
|
||||
});
|
||||
|
||||
it('image-backed text: the overlay default pass pixel-samples the image itself', async () => {
|
||||
// Drives the OVERLAY entry (impeccableDetectAsync with default options),
|
||||
// not detectUrl's Node-side full fallback — the image-only default mode
|
||||
@@ -1028,4 +1057,60 @@ describe('detectUrl — browser-only fixtures', () => {
|
||||
await detector.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Only a real browser reproduces this one: Chrome keeps oklch(), lch(), and
|
||||
// color(srgb ...) verbatim in getComputedStyle output, so a detector that
|
||||
// cannot parse those reads every surface as unset, walks out of the page,
|
||||
// and assumes the white canvas. On a dark theme that turns every light line
|
||||
// into a false "on #ffffff" finding (two live scans of impeccable.style
|
||||
// produced 95 and ~120 of them).
|
||||
describe('dark themes written in modern color syntax', () => {
|
||||
const FLAG_PAIRS = [
|
||||
// Worst stop of the two-stop oklch ground.
|
||||
['#35332d', '#050403'],
|
||||
['#47474d', '#1a1c1f'],
|
||||
['#59595c', '#121215'],
|
||||
['#56514e', '#302b27'],
|
||||
['#bfbdb8', '#faf7f2'],
|
||||
// Chrome resolves inherit / currentcolor before getComputedStyle
|
||||
// output, so these two must flag natively as well.
|
||||
['#c7c4bf', '#faf7f2'],
|
||||
['#bfbdb8', '#f0ede8'],
|
||||
];
|
||||
|
||||
it('reads oklch / color() / lch grounds and never assumes white', async () => {
|
||||
const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/dark-theme-modern-color.html`, {
|
||||
visualContrast: false,
|
||||
});
|
||||
const lowContrast = f.filter(r => r.antipattern === 'low-contrast');
|
||||
const snippets = lowContrast.map(r => r.snippet || '');
|
||||
|
||||
const onWhite = snippets.filter(s => /on #ffffff/i.test(s));
|
||||
assert.equal(
|
||||
onWhite.length, 0,
|
||||
`no finding may claim a white ground on this page, got: ${onWhite.join('; ')}`,
|
||||
);
|
||||
|
||||
const pale = snippets.filter(s => /#e7e4dd/i.test(s));
|
||||
assert.equal(
|
||||
pale.length, 0,
|
||||
`ivory copy on dark grounds must not flag, got: ${pale.join('; ')}`,
|
||||
);
|
||||
|
||||
for (const [text, bg] of FLAG_PAIRS) {
|
||||
assert.ok(
|
||||
snippets.some(s => s.includes(`text ${text}`) && s.includes(`on ${bg}`)),
|
||||
`expected low-contrast for text ${text} on ${bg}, got: ${snippets.join('; ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// `url(...), linear-gradient(red, blue)` paints the image on top; no
|
||||
// finding may measure against the occluded gradient's stops.
|
||||
const hidden = f.filter(r => /#ff0000|#0000ff/i.test(r.snippet || ''));
|
||||
assert.equal(
|
||||
hidden.length, 0,
|
||||
`no finding may reference the occluded gradient's stops, got: ${hidden.map(r => r.snippet).join('; ')}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -224,6 +224,54 @@ describe('detectHtml — static HTML/CSS fixtures', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('color: currentcolor surface resolves var() text color instead of abstaining', async () => {
|
||||
// background-color: currentcolor paints with the element's own text
|
||||
// color, which in jsdom can itself be a var() token. The surface is
|
||||
// knowable through the custom-prop map, so the faint text on it is a
|
||||
// real low-contrast finding — abstention here would hide it.
|
||||
const f = await detectHtml(path.join(FIXTURES, 'color.html'));
|
||||
assert.ok(
|
||||
f.some(r =>
|
||||
r.antipattern === 'low-contrast' &&
|
||||
/#cfc9bd/i.test(r.snippet || '') &&
|
||||
/#e8e2d6/i.test(r.snippet || '')
|
||||
),
|
||||
'expected low-contrast finding on the currentcolor var() surface',
|
||||
);
|
||||
// Good contrast on the same surface must not flag.
|
||||
const goodFP = f.filter(r =>
|
||||
(r.antipattern === 'low-contrast' || r.antipattern === 'gray-on-color') &&
|
||||
/#3a352c/i.test(r.snippet || '')
|
||||
);
|
||||
assert.equal(goodFP.length, 0, `dark ink on bone must pass, got: ${goodFP.map(r => r.snippet).join('; ')}`);
|
||||
// An undefined token keeps the surface unknowable: abstain, don't guess.
|
||||
const unknownFP = f.filter(r =>
|
||||
(r.antipattern === 'low-contrast' || r.antipattern === 'gray-on-color') &&
|
||||
/#efe9dd/i.test(r.snippet || '')
|
||||
);
|
||||
assert.equal(
|
||||
unknownFP.length, 0,
|
||||
`unresolvable currentcolor surface must abstain, got: ${unknownFP.map(r => r.snippet).join('; ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('color: a color-mix gradient stop never leaks its nested ingredient as a phantom surface', async () => {
|
||||
// The stop paints as a 16% wash composited near-black over the dark
|
||||
// wrap; the bright oklch(90% ...) nested inside the color-mix is an
|
||||
// ingredient, never painted. Re-extracting nested tokens appended it as
|
||||
// a phantom opaque stop, and the worst-case ratio then flagged the
|
||||
// light text at ~1:1 against a color nobody sees.
|
||||
const f = await detectHtml(path.join(FIXTURES, 'color.html'));
|
||||
const phantom = f.filter(r =>
|
||||
(r.antipattern === 'low-contrast' || r.antipattern === 'gray-on-color') &&
|
||||
/#ded9cf/i.test(r.snippet || '')
|
||||
);
|
||||
assert.equal(
|
||||
phantom.length, 0,
|
||||
`light text on the mixed wash must not flag: ${phantom.map(r => r.snippet).join('; ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('color: white text on background-image url() ancestor is not flagged as low-contrast', async () => {
|
||||
const f = await detectHtml(path.join(FIXTURES, 'color.html'));
|
||||
// The pass column has white text on a div with background-image: url().
|
||||
@@ -1022,17 +1070,36 @@ describe('detectHtml — motion', () => {
|
||||
|
||||
describe('detectHtml — dark glow', () => {
|
||||
// Calibrated static baseline — see motion test note above.
|
||||
// 11 element-level findings (glow-blue, glow-purple, glow-cyan, glow-multi,
|
||||
// 12 element-level findings (glow-blue, glow-purple, glow-cyan, glow-multi,
|
||||
// inline pink, glow-oklch, glow-hex, glow-hsl, glow-var, glow-text,
|
||||
// glow-light-oklch) + 1 page-level text-scan finding. Pass column adds none.
|
||||
// glow-light-oklch, glow-photo-halo) + 1 page-level text-scan finding.
|
||||
// Pass column adds none.
|
||||
it('glow: flag column triggers dark-glow, pass column adds none', async () => {
|
||||
const f = await detectHtml(path.join(FIXTURES, 'glow.html'));
|
||||
const glow = f.filter(r => r.antipattern === 'dark-glow');
|
||||
assert.equal(glow.length, 12);
|
||||
assert.equal(glow.length, 13);
|
||||
// Every finding is a glow tell, none reference the pass-column shadows
|
||||
for (const g of glow) {
|
||||
assert.match(g.snippet, /Zero-offset (box|text)-shadow glow|Colored (box|text)-shadow glow/);
|
||||
}
|
||||
// Zero-offset halo under an unreadable url() surface still fires: the
|
||||
// halo tell does not depend on the background at all.
|
||||
assert.ok(
|
||||
glow.some(g => /Zero-offset box-shadow glow \(#d946ef\)/i.test(g.snippet)),
|
||||
'expected zero-offset halo finding under unreadable image surface',
|
||||
);
|
||||
// Offset chromatic shadow under the same unreadable surface abstains:
|
||||
// the dark-background tell needs a surface we can actually read.
|
||||
assert.equal(
|
||||
glow.filter(g => /#10b981/i.test(g.snippet)).length, 0,
|
||||
'offset chromatic shadow on unknown surface must not be scored',
|
||||
);
|
||||
// Translucent gradient over a url() image blends with pixels the engine
|
||||
// cannot read; the wash stops must never be scored as the surface.
|
||||
assert.equal(
|
||||
glow.filter(g => /#f43f5e/i.test(g.snippet)).length, 0,
|
||||
'offset chromatic shadow under a translucent wash over an image must abstain',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1401,3 +1468,65 @@ describe('detectHtml — CSS patterns in prose (css-in-prose fixtures)', () => {
|
||||
assert.ok(f.some(r => r.antipattern === 'ai-color-palette'), 'expected ai-color-palette');
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectHtml — dark themes written in modern color syntax', () => {
|
||||
// A dark page whose ground and surfaces are written in oklch, color(srgb),
|
||||
// color(display-p3), and lch. Backgrounds the parser cannot read must make
|
||||
// the contrast checks abstain; assuming the browser default of white turns
|
||||
// every light-on-dark line into a false "on #ffffff" finding.
|
||||
const FLAG_PAIRS = [
|
||||
// The ground is a two-stop oklch gradient; the check reports the worst
|
||||
// stop, which for charcoal copy is the lighter one.
|
||||
['#35332d', '#050403'], // Flag Muted On Oklch Ground
|
||||
['#47474d', '#1a1c1f'], // Flag Dim On Srgb Panel
|
||||
['#59595c', '#121215'], // Flag Dim On Display P3 Panel
|
||||
['#56514e', '#302b27'], // Flag Dim On Lch Panel
|
||||
['#bfbdb8', '#faf7f2'], // Flag Pale On Light Panel
|
||||
['#c7c4bf', '#faf7f2'], // Flag Pale On Inherited Light Panel
|
||||
['#bfbdb8', '#f0ede8'], // Flag Pale On Currentcolor Panel
|
||||
];
|
||||
|
||||
it('flags text that genuinely fails against a ground the parser can read', async () => {
|
||||
const f = await detectHtml(path.join(FIXTURES, 'dark-theme-modern-color.html'));
|
||||
const snippets = f.filter(r => r.antipattern === 'low-contrast').map(r => r.snippet || '');
|
||||
for (const [text, bg] of FLAG_PAIRS) {
|
||||
assert.ok(
|
||||
snippets.some(s => s.includes(`text ${text}`) && s.includes(`on ${bg}`)),
|
||||
`expected low-contrast for text ${text} on ${bg}, got: ${snippets.join('; ')}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('never assumes white when the ground is unreadable', async () => {
|
||||
const f = await detectHtml(path.join(FIXTURES, 'dark-theme-modern-color.html'));
|
||||
const onWhite = f.filter(r => /on #ffffff/i.test(r.snippet || ''));
|
||||
assert.equal(
|
||||
onWhite.length, 0,
|
||||
`no finding may claim a white ground on this page, got: ${onWhite.map(r => r.snippet).join('; ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('light copy on readable dark surfaces stays quiet', async () => {
|
||||
const f = await detectHtml(path.join(FIXTURES, 'dark-theme-modern-color.html'));
|
||||
const pale = f.filter(r =>
|
||||
(r.antipattern === 'low-contrast' || r.antipattern === 'gray-on-color') &&
|
||||
/#e7e4dd/i.test(r.snippet || '')
|
||||
);
|
||||
assert.equal(
|
||||
pale.length, 0,
|
||||
`ivory copy on dark grounds must not flag, got: ${pale.map(r => r.snippet).join('; ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('never measures a gradient hidden beneath an image layer', async () => {
|
||||
// `url(...), linear-gradient(red, blue)` paints the image on top; the
|
||||
// gradient is invisible. Falling back to its stops manufactured
|
||||
// gray-on-color / low-contrast findings against colors nobody sees.
|
||||
const f = await detectHtml(path.join(FIXTURES, 'dark-theme-modern-color.html'));
|
||||
const hidden = f.filter(r => /#ff0000|#0000ff/i.test(r.snippet || ''));
|
||||
assert.equal(
|
||||
hidden.length, 0,
|
||||
`no finding may reference the occluded gradient's stops, got: ${hidden.map(r => r.snippet).join('; ')}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
scanCssTextForRadialHalo,
|
||||
scanHtmlForShapeAssembledIllustration,
|
||||
} from '../cli/engine/rules/checks.mjs';
|
||||
import { parseGradientColors } from '../cli/engine/shared/color.mjs';
|
||||
|
||||
const FIXTURES = path.join(import.meta.dir, 'fixtures', 'antipatterns');
|
||||
const SCRIPT = path.join(import.meta.dir, '..', 'cli', 'engine', 'detect-antipatterns.mjs');
|
||||
@@ -1477,6 +1478,63 @@ describe('hover contrast + color-mix', () => {
|
||||
expect(c.a).toBeCloseTo(0.16, 2);
|
||||
});
|
||||
|
||||
// Every expected value below is what Chrome itself paints for that string
|
||||
// (read back from a 1x1 canvas), so the parser is pinned to the browser it
|
||||
// has to agree with rather than to my arithmetic.
|
||||
describe('parseAnyColor — the color syntaxes a browser reports verbatim', () => {
|
||||
const cases = [
|
||||
['oklch(0.84 0.19 80.46)', [255, 186, 0]],
|
||||
['oklch(84% 0.19 80.46)', [255, 186, 0]],
|
||||
['oklch(1 0 0)', [255, 255, 255]],
|
||||
['oklch(0 0 0)', [0, 0, 0]],
|
||||
// Chroma far outside the sRGB gamut must clamp, never produce NaN.
|
||||
['oklch(0.62 0.4 30)', [255, 0, 0]],
|
||||
['color(srgb 0.1 0.11 0.12)', [26, 28, 31]],
|
||||
// Chrome's serialization of a color-mix in srgb routinely lands outside
|
||||
// 0..1 on one or more channels.
|
||||
['color(srgb 1.04084 0.728032 -0.213551)', [255, 186, 0]],
|
||||
['color(srgb-linear 0.5 0.5 0.5)', [188, 188, 188]],
|
||||
['color(display-p3 0.9 0.8 0.2)', [235, 203, 0]],
|
||||
['color(display-p3 1 0 0)', [255, 0, 0]],
|
||||
['lch(20 5 60)', [54, 47, 42]],
|
||||
['lab(50 40 -30)', [165, 91, 171]],
|
||||
['lab(100 0 0)', [255, 255, 255]],
|
||||
['lab(0 0 0)', [0, 0, 0]],
|
||||
];
|
||||
for (const [input, [r, g, b]] of cases) {
|
||||
test(`${input} -> rgb(${r}, ${g}, ${b})`, () => {
|
||||
const c = parseAnyColor(input);
|
||||
expect(c).not.toBeNull();
|
||||
expect(Math.abs(c.r - r)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(c.g - g)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(c.b - b)).toBeLessThanOrEqual(1);
|
||||
expect(c.a).toBe(1);
|
||||
});
|
||||
}
|
||||
|
||||
test('carries the alpha channel through color() and lch()', () => {
|
||||
expect(parseAnyColor('color(srgb 0.1 0.11 0.12 / 0.4)').a).toBeCloseTo(0.4, 3);
|
||||
expect(parseAnyColor('lch(20 5 60 / 25%)').a).toBeCloseTo(0.25, 3);
|
||||
});
|
||||
|
||||
test('returns null for color spaces it does not model, so callers abstain', () => {
|
||||
expect(parseAnyColor('color(rec2020 0.5 0.2 0.1)')).toBeNull();
|
||||
expect(parseAnyColor('color(--custom-profile 0.2 0.3 0.4)')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test('parseGradientColors reads stops written in modern color syntax', () => {
|
||||
const stops = parseGradientColors('linear-gradient(oklch(0.07 0.006 95), oklch(0.11 0.008 95))');
|
||||
expect(stops).toHaveLength(2);
|
||||
expect(stops[0].r).toBeLessThan(10);
|
||||
expect(stops[1].r).toBeLessThan(20);
|
||||
});
|
||||
|
||||
test('parseGradientColors ignores the interpolation-space hint', () => {
|
||||
const stops = parseGradientColors('linear-gradient(in oklab, rgb(0, 0, 0), rgb(255, 255, 255))');
|
||||
expect(stops).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('checkHoverContrast flags a failing hover pair on a styled control', () => {
|
||||
const f = checkHoverContrast({
|
||||
tag: 'a',
|
||||
|
||||
+49
@@ -36,6 +36,27 @@
|
||||
.ox-glow { background: linear-gradient(160deg, rgba(52,192,168,0.09) 0%, #141419 65%); padding: 20px; }
|
||||
.ox-glow p { color: #e8e6e3; font-size: 18px; }
|
||||
.ox-glow .muted { color: #8e8c89; font-size: 16px; }
|
||||
/* Gradient stop written as color-mix with a bright nested ingredient.
|
||||
The stop paints as a 16% wash of the light oklch color (composited
|
||||
over the dark wrap it is near-black); the nested oklch(90% ...) is an
|
||||
INGREDIENT, never painted. A parser that re-extracts nested tokens
|
||||
appends it as a phantom opaque light stop and the worst-case ratio
|
||||
then flags the light text at ~1:1 against a color nobody sees. */
|
||||
.mix-dark-wrap { background: #0f0f11; padding: 16px; }
|
||||
.mix-glow { background: linear-gradient(160deg, color-mix(in oklab, oklch(90% 0.02 95) 16%, transparent) 0%, #141419 65%); padding: 20px; }
|
||||
.mix-glow p { color: #ded9cf; font-size: 16px; }
|
||||
/* currentcolor surface: background-color paints with the element's own
|
||||
text color, which is itself a var() token here. jsdom hands both
|
||||
through verbatim, so the walk must resolve the token via the
|
||||
custom-prop map instead of abstaining on a knowable surface. */
|
||||
:root { --fixture-bone: #e8e2d6; }
|
||||
.currentcolor-surface { background-color: currentcolor; color: var(--fixture-bone); padding: 14px 16px; border-radius: 10px; margin-bottom: 10px; }
|
||||
.currentcolor-low-text { color: #cfc9bd; font-size: 14px; }
|
||||
.currentcolor-good-text { color: #3a352c; font-size: 14px; }
|
||||
/* Same shape but the token does not exist: the surface truly cannot be
|
||||
read, so the walk must abstain rather than guess. */
|
||||
.currentcolor-unknown { background-color: currentcolor; color: var(--fixture-undefined-token); padding: 14px 16px; border-radius: 10px; }
|
||||
.currentcolor-unknown p { color: #efe9dd; font-size: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -111,6 +132,14 @@
|
||||
<div class="bg-gradient-to-r from-purple-500 to-indigo-500 text-white p-4 rounded card" style="background: linear-gradient(to right, rgb(168, 85, 247), rgb(99, 102, 241)); color: white;">
|
||||
<p>Purple-to-indigo gradient</p>
|
||||
</div>
|
||||
|
||||
<h3>currentcolor surface via var() token</h3>
|
||||
<!-- background-color: currentcolor with color: var(--fixture-bone).
|
||||
The surface is knowable (bone #e8e2d6), so the faint text on it is
|
||||
a real low-contrast finding, not an abstention. -->
|
||||
<div class="currentcolor-surface" data-test="currentcolor-low">
|
||||
<p class="currentcolor-low-text">Faint warm gray on a bone currentcolor surface</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════════
|
||||
@@ -212,6 +241,26 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>color-mix gradient stop: nested ingredient is not a surface</h3>
|
||||
<div class="mix-dark-wrap">
|
||||
<div class="mix-glow" data-test="mix-glow">
|
||||
<p>Light copy on a faint mixed wash over a dark ground stays readable</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>currentcolor surface with good contrast</h3>
|
||||
<div class="currentcolor-surface" data-test="currentcolor-good">
|
||||
<p class="currentcolor-good-text">Dark ink text on a bone currentcolor surface</p>
|
||||
</div>
|
||||
|
||||
<h3>currentcolor surface with unresolvable token (must abstain)</h3>
|
||||
<!-- The var() token is undefined, so the surface genuinely cannot be
|
||||
read. The walk must abstain instead of guessing a background for
|
||||
the light text inside. -->
|
||||
<div class="currentcolor-unknown" data-test="currentcolor-unknown">
|
||||
<p>Light text on an unknowable currentcolor surface</p>
|
||||
</div>
|
||||
|
||||
<h3>Emoji on light backgrounds</h3>
|
||||
<!-- Emojis render as multicolor glyphs regardless of CSS color, so the
|
||||
CSS color is irrelevant for contrast. These should NOT be flagged. -->
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Dark theme, modern color syntax</title>
|
||||
<style>
|
||||
/* A lacquer-black ground painted as a gradient in oklch, the shape that
|
||||
floods dark themes with "on #ffffff" low-contrast false positives when
|
||||
the detector cannot read the ground and assumes white. */
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
background: linear-gradient(oklch(0.07 0.006 95), oklch(0.11 0.008 95));
|
||||
font-family: Georgia, serif;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
section {
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
width: 640px;
|
||||
}
|
||||
|
||||
p { margin: 0 0 12px; font-size: 15px; }
|
||||
|
||||
/* Readable grounds, each written in a color syntax a real browser keeps
|
||||
verbatim in getComputedStyle output. */
|
||||
.panel-srgb { background-color: color(srgb 0.1 0.11 0.12); }
|
||||
.panel-mix { background-color: color-mix(in srgb, white 6%, #0b0b0d); }
|
||||
.panel-p3 { background-color: color(display-p3 0.07 0.07 0.08); }
|
||||
.panel-lch { background-color: lch(18 4 60); }
|
||||
.panel-light { background-color: color(srgb 0.98 0.97 0.95); }
|
||||
|
||||
/* A color space the detector does not model. Nested inside the light
|
||||
panel so a detector that walks PAST the unreadable layer would measure
|
||||
light text against the light section and invent a finding. */
|
||||
.panel-unreadable { background-color: color(rec2020 0.05 0.05 0.05); padding: 20px; }
|
||||
|
||||
/* An image layer stacked ABOVE a loud gradient. The image is the visible
|
||||
surface and its pixels are unreadable; a detector that falls back to
|
||||
gradient stops here measures a layer the visitor never sees. */
|
||||
.panel-image-over-gradient { background-image: url("opaque-panel.png"), linear-gradient(#ff0000, #0000ff); }
|
||||
|
||||
/* Keywords a real browser resolves before getComputedStyle output but a
|
||||
partial cascade hands through verbatim: inherit takes the parent's
|
||||
ground, currentcolor paints with the element's own text color. Both are
|
||||
knowable surfaces; abstaining on them would hide real findings. */
|
||||
.panel-inherit { background-color: inherit; padding: 20px; }
|
||||
.panel-current { background-color: currentcolor; color: color(srgb 0.94 0.93 0.91); }
|
||||
.text-pale-2 { color: color(srgb 0.78 0.77 0.75); }
|
||||
|
||||
.text-light { color: oklch(0.92 0.01 90); }
|
||||
.text-dark { color: color(srgb 0.1 0.11 0.12); }
|
||||
|
||||
.text-muted-ground { color: oklch(0.32 0.01 90); }
|
||||
.text-dim-srgb { color: color(srgb 0.28 0.28 0.3); }
|
||||
.text-dim-p3 { color: color(display-p3 0.35 0.35 0.36); }
|
||||
.text-dim-lch { color: lch(35 3 60); }
|
||||
.text-pale-light { color: color(srgb 0.75 0.74 0.72); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1 class="text-light">Should flag</h1>
|
||||
|
||||
<section>
|
||||
<p class="text-muted-ground">Flag Muted On Oklch Ground: charcoal body copy sitting straight on the lacquer gradient.</p>
|
||||
</section>
|
||||
|
||||
<section class="panel-srgb">
|
||||
<p class="text-dim-srgb">Flag Dim On Srgb Panel: slate copy on a near-black srgb surface.</p>
|
||||
</section>
|
||||
|
||||
<section class="panel-p3">
|
||||
<p class="text-dim-p3">Flag Dim On Display P3 Panel: slate copy on a near-black display-p3 surface.</p>
|
||||
</section>
|
||||
|
||||
<section class="panel-lch">
|
||||
<p class="text-dim-lch">Flag Dim On Lch Panel: warm gray copy on a warm near-black lch surface.</p>
|
||||
</section>
|
||||
|
||||
<section class="panel-light">
|
||||
<p class="text-pale-light">Flag Pale On Light Panel: bone copy on a bone-white srgb surface.</p>
|
||||
</section>
|
||||
|
||||
<section class="panel-light">
|
||||
<div class="panel-inherit">
|
||||
<p class="text-pale-2">Flag Pale On Inherited Light Panel: the ground is inherited from the light section, so the walk must resolve it rather than abstain.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-current">
|
||||
<p class="text-pale-light">Flag Pale On Currentcolor Panel: the ground is the panel's own bone text color, so the keyword must resolve rather than abstain.</p>
|
||||
</section>
|
||||
|
||||
<h1 class="text-light">Should pass</h1>
|
||||
|
||||
<section>
|
||||
<p class="text-light">Pass Light Text On Oklch Gradient Ground: warm ivory copy on the lacquer gradient, roughly 15:1.</p>
|
||||
</section>
|
||||
|
||||
<section class="panel-srgb">
|
||||
<p class="text-light">Pass Light Text On Srgb Panel: warm ivory copy on the near-black srgb surface.</p>
|
||||
</section>
|
||||
|
||||
<section class="panel-mix">
|
||||
<p class="text-light">Pass Light Text On Color Mix Panel: warm ivory copy on a surface a browser reports as color(srgb ...).</p>
|
||||
</section>
|
||||
|
||||
<section class="panel-p3">
|
||||
<p class="text-light">Pass Light Text On Display P3 Panel: warm ivory copy on the near-black display-p3 surface.</p>
|
||||
</section>
|
||||
|
||||
<section class="panel-lch">
|
||||
<p class="text-light">Pass Light Text On Lch Panel: warm ivory copy on the warm near-black lch surface.</p>
|
||||
</section>
|
||||
|
||||
<section class="panel-light">
|
||||
<p class="text-dark">Pass Dark Text On Light Panel: ink copy on the bone-white srgb surface.</p>
|
||||
<div class="panel-unreadable">
|
||||
<p class="text-light">Pass Unreadable Panel Inside Light Section: the ground is a color space the detector cannot read, so it must abstain instead of measuring against the light section or against white.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-image-over-gradient">
|
||||
<p class="text-muted-ground">Pass Image Over Gradient: the visible ground is an image whose pixels the engine cannot read, so it must abstain instead of measuring the loud gradient hidden beneath it.</p>
|
||||
</section>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
+53
@@ -42,6 +42,18 @@
|
||||
.glow-text h4 { text-shadow: 0 0 12px #22d3ee; }
|
||||
/* Zero-offset chromatic halo is slop on light backgrounds too */
|
||||
.glow-light-oklch { box-shadow: 0 0 20px oklch(0.65 0.2 300 / 0.45); }
|
||||
/* Unreadable surface: url() image ancestor. The zero-offset halo tell
|
||||
holds on ANY background, so an unknown surface must not suppress it.
|
||||
Both element loops must agree here (the browser adapter once returned
|
||||
[] for the whole element on an unresolved surface). */
|
||||
.photo-context { background-image: url('/fixtures/antipatterns/missing-photo.png'); padding: 16px; border-radius: 12px; }
|
||||
.glow-photo-halo { box-shadow: 0 0 26px rgba(217, 70, 239, 0.5); }
|
||||
/* Opaque gradient atop a url() layer: every stop is opaque, so the
|
||||
gradient provably covers the image — the surface IS the dark
|
||||
gradient, and the dark-background glow tell may score against it
|
||||
(browser hunt; the static loop has no gradient hunt). */
|
||||
.photo-opaque-grad { background: linear-gradient(#111827, #0b1220), url('/fixtures/antipatterns/missing-photo.png'); padding: 16px; border-radius: 12px; }
|
||||
.glow-photo-opaque { box-shadow: 0 6px 22px rgba(249, 115, 22, 0.5); }
|
||||
|
||||
/* ── PASS: same dark/light backgrounds, but neutral or no glow ── */
|
||||
.light-colored-shadow { box-shadow: 0 2px 4px rgba(59, 130, 246, 0.15); }
|
||||
@@ -67,6 +79,15 @@
|
||||
.light-offset-colored { box-shadow: 0 8px 20px rgba(59, 130, 246, 0.25); }
|
||||
/* Achromatic zero-offset shadow: soft ambient elevation, stays legal */
|
||||
.light-soft-neutral { box-shadow: 0 0 24px rgba(0, 0, 0, 0.15); }
|
||||
/* Offset chromatic shadow under the same unreadable url() surface:
|
||||
the dark-background glow tell needs a surface we can read — abstain. */
|
||||
.photo-offset-colored { box-shadow: 0 6px 20px rgba(16, 185, 129, 0.35); }
|
||||
/* Translucent gradient atop a url() layer: the image shows through the
|
||||
20% wash, so the real surface is a blend with unknowable pixels (a
|
||||
white photo composites to ~#cccccc, not black). Averaging the wash
|
||||
stops as if they were the surface scored this "dark" — abstain. */
|
||||
.photo-translucent-grad { background: linear-gradient(rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.2)), url('/fixtures/antipatterns/missing-photo.png'); padding: 16px; border-radius: 12px; }
|
||||
.photo-translucent-offset { box-shadow: 0 6px 22px rgba(244, 63, 94, 0.45); }
|
||||
/* Offset neutral text-shadow on dark card: legibility aid, not a glow */
|
||||
.dark-text-offset h4 { text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6); }
|
||||
</style>
|
||||
@@ -136,6 +157,22 @@
|
||||
<h4>Chromatic halo on light page</h4>
|
||||
<p>box-shadow: 0 0 20px oklch(0.65 0.2 300 / 0.45)</p>
|
||||
</div>
|
||||
|
||||
<h3>Zero-offset halo on unreadable (image) surface</h3>
|
||||
<div class="photo-context">
|
||||
<div class="card card-light glow-photo-halo">
|
||||
<h4>Halo over photo background</h4>
|
||||
<p>box-shadow: 0 0 26px rgba(217, 70, 239, 0.5)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Colored glow under an opaque gradient over an image (browser)</h3>
|
||||
<div class="photo-opaque-grad">
|
||||
<div class="card card-dark glow-photo-opaque">
|
||||
<h4>Opaque dark gradient covers the photo</h4>
|
||||
<p>box-shadow: 0 6px 22px rgba(249, 115, 22, 0.5)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════════
|
||||
@@ -202,6 +239,22 @@
|
||||
<p>Achromatic zero-offset shadow is ambient elevation.</p>
|
||||
</div>
|
||||
|
||||
<h3>Offset chromatic shadow on unreadable (image) surface</h3>
|
||||
<div class="photo-context">
|
||||
<div class="card card-light photo-offset-colored">
|
||||
<h4>Offset colored shadow over photo background</h4>
|
||||
<p>Surface unknown, offset shadow — abstain, not flag.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Offset chromatic shadow under a translucent gradient over an image</h3>
|
||||
<div class="photo-translucent-grad">
|
||||
<div class="card card-light photo-translucent-offset">
|
||||
<h4>The wash blends with unknowable image pixels</h4>
|
||||
<p>Surface unknown — abstain, never score the wash stops as dark.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Neutral text-shadow on dark card</h3>
|
||||
<div class="dark-context">
|
||||
<div class="card card-dark dark-text-offset">
|
||||
|
||||
Reference in New Issue
Block a user