detector: hover-state contrast + color-mix/compositing, radial-halo rule, top/bottom stripe variant, file:// browser scans

Four changes driven by human design review of eval artifacts:

1. Static engine contrast fidelity (nav-CTA cascade miss):
   - parseAnyColor evaluates color-mix() (premultiplied sRGB mix; exact
     for the dominant `color-mix(in oklab, C n%, transparent)` chip form)
   - extractStaticColor captures color-mix() balanced instead of plucking
     "transparent" out of the expression
   - resolveBackground composites translucent layers over the opaque base
     in both engines instead of skipping (static) or returning them
     as-if-opaque (browser)
   - NEW hover pass in the static cascade: :hover rules are matched via
     state-stripped selectors, merged per-property against the resting
     cascade with real specificity, and checked for WCAG contrast on
     styled controls (checkHoverContrast). Catches the recurring miss
     where a broader selector (.nav-links a:hover) beats the CTA's own
     hover color and drops the pair below AA.

2. New `radial-halo` slop rule: chromatic radial-gradient wash (visible
   saturated center -> transparent) as a decorative background on a dark
   page. Exempts achromatic vignettes, opaque-end sheens, px-stop dot
   textures, url() photo layers, and translucent (<0.7 alpha) staged-
   light washes. Separate id from dark-glow so dashboards track the
   gradient-drawn variant independently.

3. side-tab horizontal variant: 3-12px chromatic border-top/bottom (and
   top/bottom-anchored full-width pseudo stripes) on cards/badges flag as
   side-tab. Exempt: tablist/nav/aria-selected underlines, link/button
   affordances, table cells, hr, state-conditional pseudo stripes, and
   >12px bands. Badge-shaped spans (own visible background) participate.

4. CLI: file:// URLs route to the Puppeteer browser engine (~2s on a
   50KB page), and detect --json findings now carry the registry
   `category` field so downstream QA loops can separate mechanical slop
   tells from judgment calls.

Fixture policy update: flat 3px top-accent cards moved from should-pass
to flag columns; tablist-underline and 16px-band pass cases added.
Browser bundle regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-07-12 18:18:33 -07:00
co-authored by Claude Fable 5
parent be03fe734a
commit 1a4b5c2fa2
12 changed files with 1043 additions and 106 deletions
+369 -43
View File
@@ -24,8 +24,12 @@ const DETECTOR_IS_BROWSER = typeof window !== 'undefined';
// ─── Section 3: Pure Detection ──────────────────────────────────────────────
function checkBorders(tag, widths, colors, radius) {
if (BORDER_SAFE_TAGS.has(tag)) return [];
function checkBorders(tag, widths, colors, radius, opts = {}) {
// Badge-shaped <span>s (own visible background) are a real stripe target
// for the top/bottom variant — the inline-tag exemption exists to quiet
// text-level borders, not chips. They skip the left/right arms below.
const spanBadge = tag === 'span' && !!opts.badgeLike;
if (BORDER_SAFE_TAGS.has(tag) && !spanBadge) return [];
const findings = [];
const sides = ['Top', 'Right', 'Bottom', 'Left'];
@@ -41,10 +45,20 @@ function checkBorders(tag, widths, colors, radius) {
const isSide = side === 'Left' || side === 'Right';
if (isSide) {
if (spanBadge) continue;
if (radius > 0) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` });
else if (w >= 3) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px` });
} else {
if (radius > 0 && w >= 2) findings.push({ id: 'border-accent-on-rounded', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` });
// Horizontal variant of the side-tab stripe: a thick chromatic accent
// riding the top or bottom edge of a card/badge/container. Same
// dominant-edge + chroma gates as left/right, 3-12px band. Selected-
// tab underlines are exempt via opts.tabContext (adapters look for
// tablist/nav/tab ancestors and aria-selected); links, buttons,
// table cells, and <hr> never reach here (BORDER_SAFE_TAGS).
else if (!opts.tabContext && w >= 3 && w <= 12) {
findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px` });
}
}
}
@@ -158,6 +172,27 @@ function checkColors(opts) {
return findings;
}
// WCAG contrast for the :hover state of an element whose hover rules change
// its text color and/or background. The classic miss: a nav CTA whose
// author-intended hover pair passes AA, but a broader selector (e.g.
// `.nav-links a:hover`) wins the specificity fight and swaps in a color
// that fails. Only fires on elements that present as styled controls —
// direct text plus an opaque-ish own background in either state — so plain
// inline links keep the same suppression they get in checkColors.
function checkHoverContrast(opts) {
const { tag, textColor, bg, ownBgAlpha, fontSize, fontWeight, hasDirectText, isEmojiOnly } = opts;
if (!hasDirectText || isEmojiOnly || !textColor || !bg) return [];
if (SAFE_TAGS.has(tag) && !(ownBgAlpha != null && ownBgAlpha > 0.5)) return [];
const ratio = contrastRatio(textColor, bg);
const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700);
const threshold = isLargeText ? 3.0 : 4.5;
if (ratio >= threshold) return [];
return [{
id: 'low-contrast',
snippet: `:hover state ${ratio.toFixed(1)}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(bg)}`,
}];
}
function isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg) {
if (!hasShadow && !hasBorder) return false;
return hasRadius || hasBg;
@@ -505,34 +540,36 @@ function collectCssCustomProps(content) {
// checkGlow: zero-offset chromatic halo (any background) and chromatic
// blurred shadow when the page has a dark background. Returns
// [{ index, snippet }] — index is the offset of the shadow declaration.
function scanCssTextForGlow(content) {
const customProps = collectCssCustomProps(content);
// Dark-page heuristic: dark hex/rgb literals, Tailwind dark bg utilities,
// or a ROOT-scoped (body/html/:root or <body style>) background that
// resolves — via var() — to a dark color. The var/modern-color extension
// is deliberately root-scoped: a light page with one dark accent chip
// must not turn every tinted drop shadow into a "dark page" glow.
// Dark-page heuristic for raw CSS/HTML text: dark hex/rgb literals, Tailwind
// dark bg utilities, or a ROOT-scoped (body/html/:root or <body style>)
// background that resolves — via var() — to a dark color. The var/modern-
// color extension is deliberately root-scoped: a light page with one dark
// accent chip must not turn every tinted drop shadow into a "dark page"
// signal. Shared by the glow and radial-halo text scanners.
function cssTextHasDarkRootBg(content, customProps) {
const darkBgRe = /background(?:-color)?\s*:\s*(?:#(?:0[0-9a-f]|1[0-9a-f]|2[0-3])[0-9a-f]{4}\b|#(?:0|1)[0-9a-f]{2}\b|rgb\(\s*(\d{1,2})\s*,\s*(\d{1,2})\s*,\s*(\d{1,2})\s*\))/i;
const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/;
let hasDarkBg = darkBgRe.test(content) || twDarkBg.test(content);
if (!hasDarkBg) {
const rootScopes = [];
const blockRe = /(?:^|[}\s,;>])(?:body|html|:root)\s*(?:,[^{]*)?\{([^}]*)\}/gi;
let sm;
while ((sm = blockRe.exec(content)) !== null) rootScopes.push(sm[1]);
const inlineBody = content.match(/<body[^>]*\bstyle\s*=\s*"([^"]*)"/i);
if (inlineBody) rootScopes.push(inlineBody[1]);
for (const scope of rootScopes) {
const bgRe = /background(?:-color)?\s*:\s*([^;{}]+)/gi;
let bm;
while (!hasDarkBg && (bm = bgRe.exec(scope)) !== null) {
const c = parseAnyColor(resolveVarRefs(bm[1].trim(), customProps));
if (c && (c.a ?? 1) > 0.5 && relativeLuminance(c) < 0.1) hasDarkBg = true;
}
if (hasDarkBg) break;
if (darkBgRe.test(content) || twDarkBg.test(content)) return true;
const rootScopes = [];
const blockRe = /(?:^|[}\s,;>])(?:body|html|:root)\s*(?:,[^{]*)?\{([^}]*)\}/gi;
let sm;
while ((sm = blockRe.exec(content)) !== null) rootScopes.push(sm[1]);
const inlineBody = content.match(/<body[^>]*\bstyle\s*=\s*"([^"]*)"/i);
if (inlineBody) rootScopes.push(inlineBody[1]);
for (const scope of rootScopes) {
const bgRe = /background(?:-color)?\s*:\s*([^;{}]+)/gi;
let bm;
while ((bm = bgRe.exec(scope)) !== null) {
const c = parseAnyColor(resolveVarRefs(bm[1].trim(), customProps));
if (c && (c.a ?? 1) > 0.5 && relativeLuminance(c) < 0.1) return true;
}
}
return false;
}
function scanCssTextForGlow(content) {
const customProps = collectCssCustomProps(content);
const hasDarkBg = cssTextHasDarkRootBg(content, customProps);
const results = [];
const shadowRe = /\b(box-shadow|text-shadow)\s*:\s*([^;{}]+)/gi;
@@ -559,6 +596,81 @@ function scanCssTextForGlow(content) {
return results;
}
// Decorative chromatic halo drawn as a radial-gradient background on a dark
// page: a saturated center stop dissolving to transparent. The gradient
// sibling of the dark-glow shadow tell. Mechanical gates, in order:
// * page has a dark root background (shared heuristic with the glow scan)
// * declaration has no url() layer (photographic imagery is exempt)
// * the gradient's first color stop is chromatic (RGB spread >= 24) and
// visible (alpha >= 0.7 — deliberately translucent light-scene washes
// composite with content instead of painting a flat halo, and stay legal)
// * the gradient's last stop is transparent / near-zero alpha
// * no small pixel-sized stop positions (<= 24px = dot/texture patterns)
// * not a repeating-* gradient
// Achromatic vignettes fail the chroma gate; panel sheens that fade to an
// opaque surface color fail the transparent-end gate.
function scanCssTextForRadialHalo(content) {
const customProps = collectCssCustomProps(content);
if (!cssTextHasDarkRootBg(content, customProps)) return [];
const findings = [];
const seen = new Set();
const declRe = /background(?:-image)?\s*:\s*([^;{}]+)/gi;
let m;
while ((m = declRe.exec(content)) !== null) {
const value = resolveVarRefs(m[1].trim(), customProps);
if (/url\s*\(/i.test(value)) continue;
const gradRe = /(repeating-)?radial-gradient\(/gi;
let g;
while ((g = gradRe.exec(value)) !== null) {
if (g[1]) continue; // repeating-* = pattern, not halo
// Balanced-paren capture of the gradient arguments.
let depth = 0, end = -1;
const open = value.indexOf('(', g.index);
for (let i = open; i < value.length; i++) {
if (value[i] === '(') depth++;
else if (value[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
}
if (end < 0) break;
const args = splitTopLevelCommas(value.slice(open + 1, end));
if (args.length < 2) continue;
// Optional prelude (shape / size / `at <pos>`) carries no color.
const colorTokenRe = /(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color-mix)\([^)]*(?:\([^)]*\))?[^)]*\)|#[0-9a-f]{3,8}\b|\btransparent\b/i;
const stops = args.filter(a => colorTokenRe.test(a));
if (stops.length < 2) continue;
// Dot/texture exemption: px-sized stop positions mean a repeating
// background-size pattern, not a page-scale halo.
const pxStop = stops.some(s => {
const pm = s.match(/(-?[\d.]+)px\b/);
return pm && Math.abs(parseFloat(pm[1])) <= 24;
});
if (pxStop) continue;
const first = stops[0].match(colorTokenRe);
const last = stops[stops.length - 1].match(colorTokenRe);
if (!first || !last) continue;
const lastColor = /^transparent$/i.test(last[0]) ? { r: 0, g: 0, b: 0, a: 0 } : parseAnyColor(last[0]);
if (!lastColor || (lastColor.a ?? 1) > 0.05) continue;
const firstColor = /^transparent$/i.test(first[0]) ? null : parseAnyColor(first[0]);
if (!firstColor) continue;
if ((firstColor.a ?? 1) < 0.7) continue;
const spread = Math.max(firstColor.r, firstColor.g, firstColor.b) - Math.min(firstColor.r, firstColor.g, firstColor.b);
if (spread < 24) continue;
const snippet = `radial-gradient halo (${colorToHex(firstColor)} → transparent) on dark page`;
if (seen.has(snippet)) continue;
seen.add(snippet);
findings.push({ index: m.index, snippet });
}
}
return findings;
}
// ---------------------------------------------------------------------------
// Text-level CSS rule-block scanners (pseudo-element stripes, pulsing dots)
// ---------------------------------------------------------------------------
@@ -618,7 +730,18 @@ function scanCssTextForPseudoStripe(content) {
const widthPx = cssLengthToPx(resolveVarRefs(
decls.get('width') || decls.get('inline-size') || '', customProps));
if (widthPx == null || widthPx < 3 || widthPx > 12) continue;
const heightPx = cssLengthToPx(resolveVarRefs(
decls.get('height') || decls.get('block-size') || '', customProps));
const verticalCandidate = widthPx != null && widthPx >= 3 && widthPx <= 12;
// Horizontal variant (top/bottom stripe) carries extra exemptions:
// link/button underline affordances, tab strips, selected states, and
// state-conditional (:hover/:focus/...) affordances are not stripes.
const horizontalCandidate = heightPx != null && heightPx >= 3 && heightPx <= 12
&& !/(?:^|[\s>+~,(])(?:a|button|summary|tr|td|th|table|li)(?![\w-])/i.test(selector)
&& !/\[role=["']?tab|\[aria-selected/i.test(selector)
&& !/(?:^|[\s._[-])(?:tabs?|tablist|tab-[\w-]*|btn[\w-]*|button[\w-]*|link[\w-]*)(?![\w])/i.test(selector)
&& !/:(?:hover|focus|focus-visible|focus-within|active|checked)\b/i.test(selector);
if (!verticalCandidate && !horizontalCandidate) continue;
// Resolve edge offsets, letting an `inset` shorthand fill the gaps.
const offsets = {
@@ -643,11 +766,29 @@ function scanCssTextForPseudoStripe(content) {
const heightValue = String(resolveVarRefs(
decls.get('height') || decls.get('block-size') || '', customProps)).trim();
const fullHeight = (isZeroOffset(offsets.top) && isZeroOffset(offsets.bottom))
|| /^100(?:\.0*)?%$/.test(heightValue);
if (!fullHeight) continue;
const edge = isZeroOffset(offsets.left) ? 'left'
: isZeroOffset(offsets.right) ? 'right' : null;
const widthValue = String(resolveVarRefs(
decls.get('width') || decls.get('inline-size') || '', customProps)).trim();
let edge = null;
let thicknessPx = null;
if (verticalCandidate) {
const fullHeight = (isZeroOffset(offsets.top) && isZeroOffset(offsets.bottom))
|| /^100(?:\.0*)?%$/.test(heightValue);
if (fullHeight) {
edge = isZeroOffset(offsets.left) ? 'left'
: isZeroOffset(offsets.right) ? 'right' : null;
thicknessPx = widthPx;
}
}
if (!edge && horizontalCandidate) {
const fullWidth = (isZeroOffset(offsets.left) && isZeroOffset(offsets.right))
|| /^100(?:\.0*)?%$/.test(widthValue);
if (fullWidth) {
edge = isZeroOffset(offsets.top) ? 'top'
: isZeroOffset(offsets.bottom) ? 'bottom' : null;
thicknessPx = heightPx;
}
}
if (!edge) continue;
// Chromatic fill only — a neutral hairline divider is not an accent
@@ -670,7 +811,7 @@ function scanCssTextForPseudoStripe(content) {
seen.add(selector);
findings.push({
id: 'side-tab',
snippet: `${selector} — absolute ${widthPx}px pseudo-element stripe (${edge}: 0)`,
snippet: `${selector} — absolute ${thicknessPx}px pseudo-element stripe (${edge}: 0)`,
});
}
return findings;
@@ -931,6 +1072,12 @@ function checkHtmlPatterns(html) {
findings.push({ id: 'dark-glow', snippet: glowHits[0].snippet });
}
// Radial-gradient background halo (gradient-drawn sibling of dark-glow)
const haloHits = scanCssTextForRadialHalo(html);
if (haloHits.length > 0) {
findings.push({ id: 'radial-halo', snippet: haloHits[0].snippet });
}
// --- Provider tells (gated): repeating-gradient stripes (GPT) ---
if (/repeating-(?:linear|radial|conic)-gradient\s*\(/i.test(html)) {
findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes' });
@@ -1035,6 +1182,18 @@ function readOwnBackgroundColor(el, computedStyle) {
function resolveBackground(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
// either returned them as-if-opaque (browser mode) or skipped them
// entirely (static mode), both of which misstate the effective surface
// for contrast checks (e.g. `background: color-mix(in oklab, var(--hot)
// 16%, transparent)` chips on dark pages).
const overlays = [];
const flatten = (base) => {
let acc = base;
for (let i = overlays.length - 1; i >= 0; i--) acc = compositeColorOver(overlays[i], acc);
return acc;
};
while (current && current.nodeType === 1) {
const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
const bgImage = style.backgroundImage || '';
@@ -1047,7 +1206,9 @@ function resolveBackground(el, win, customPropMap) {
// decorative. The old behavior bailed on any gradient ancestor, which
// caused massive false-positive contrast findings on grain-textured
// body backgrounds.
let bg = parseRgb(style.backgroundColor);
// Real browsers serialize wide-gamut computed values as oklab()/oklch()
// (e.g. any color-mix() result), which plain parseRgb misses.
let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
if (!DETECTOR_IS_BROWSER && (!bg || bg.a < 0.1)) {
// jsdom returns literal "var(--X)" / "oklch(...)" strings. Resolve
// through customPropMap so Tailwind v4 color tokens become RGB.
@@ -1067,7 +1228,8 @@ function resolveBackground(el, win, customPropMap) {
}
if (bg && bg.a > 0.1) {
if (DETECTOR_IS_BROWSER || bg.a >= 0.5) return bg;
if (bg.a >= 0.99) return flatten(bg);
overlays.push(bg);
}
// No solid bg-color at this level. If THIS level has a gradient/url
// with no underlying solid color we can read:
@@ -1083,13 +1245,13 @@ function resolveBackground(el, win, customPropMap) {
// bgs worth checking against).
if (hasGradientOrUrl) {
if (current.tagName === 'BODY' || current.tagName === 'HTML') {
return { r: 255, g: 255, b: 255, a: 1 };
return flatten({ r: 255, g: 255, b: 255, a: 1 });
}
return null;
}
current = current.parentElement;
}
return { r: 255, g: 255, b: 255 };
return flatten({ r: 255, g: 255, b: 255, a: 1 });
}
// Walk parents looking for a gradient background and return its color stops.
@@ -1151,6 +1313,24 @@ function resolveBorderRadiusPx(el, style, widthPx, win) {
// Browser adapters — call getComputedStyle/getBoundingClientRect on live DOM
// Tab-strip / selected-state context: a top or bottom accent on an element
// inside a tablist, nav, or aria-selected widget is an active-state
// underline affordance, not a decorative stripe.
function isTabContextElement(el) {
if (!el) return false;
try {
if (el.closest?.('[role="tablist"], [role="tab"], nav, [aria-selected]')) return true;
} catch { /* selector engine differences — fall through to class scan */ }
let cur = el, depth = 0;
while (cur && cur.nodeType === 1 && depth < 6) {
const cls = String(cur.getAttribute?.('class') || cur.className || '');
if (/(?:^|[\s_-])tabs?(?:$|[\s_-])/i.test(cls)) return true;
cur = cur.parentElement;
depth++;
}
return false;
}
function checkElementBordersDOM(el) {
const tag = el.tagName.toLowerCase();
if (BORDER_SAFE_TAGS.has(tag)) return [];
@@ -1163,7 +1343,11 @@ function checkElementBordersDOM(el) {
widths[s] = parseFloat(style[`border${s}Width`]) || 0;
colors[s] = style[`border${s}Color`] || '';
}
return checkBorders(tag, widths, colors, parseFloat(style.borderRadius) || 0);
const ownBg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
return checkBorders(tag, widths, colors, parseFloat(style.borderRadius) || 0, {
tabContext: isTabContextElement(el),
badgeLike: !!(ownBg && (ownBg.a ?? 1) > 0.1),
});
}
function checkElementColorsDOM(el) {
@@ -1421,14 +1605,103 @@ const CSS_NAMED_COLORS = {
maroon: { r: 128, g: 0, b: 0 },
};
// Extended color parser: rgb/rgba/hex/oklch/oklab/hsl/hwb/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()
// 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 };
@@ -2234,7 +2507,7 @@ function checkElementQuality(el, style, tag, window) {
return checkQuality({ el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect: null, win: window });
}
function checkElementBorders(tag, style, overrides, resolvedRadius) {
function checkElementBorders(tag, style, overrides, resolvedRadius, el = null) {
const sides = ['Top', 'Right', 'Bottom', 'Left'];
const widths = {}, colors = {};
for (const s of sides) {
@@ -2261,7 +2534,11 @@ function checkElementBorders(tag, style, overrides, resolvedRadius) {
const radius = resolvedRadius != null
? resolvedRadius
: (parseFloat(style.borderRadius) || 0);
return checkBorders(tag, widths, colors, radius);
const ownBg = parseAnyColor(style.backgroundColor);
return checkBorders(tag, widths, colors, radius, {
tabContext: isTabContextElement(el),
badgeLike: !!(ownBg && (ownBg.a ?? 1) > 0.1),
});
}
function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInheritRule) {
@@ -2318,6 +2595,50 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
});
}
// Static-engine adapter for hover-state contrast. Relies on the static
// cascade's hover pass (css-cascade.mjs) exposing a per-element hover style
// via window.getHoverStyle — present only when a :hover rule changed the
// element's color or background-color relative to its resting state.
function checkElementHoverContrast(el, style, tag, window) {
if (typeof window.getHoverStyle !== 'function') return [];
const hover = window.getHoverStyle(el);
if (!hover) return [];
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
if (directText.trim().length === 0) return [];
const textColor = parseAnyColor(hover.color);
if (!textColor || (textColor.a != null && textColor.a < 1)) return [];
const restingOwnBg = parseAnyColor(style.backgroundColor);
const hoverOwnBg = parseAnyColor(hover.backgroundColor);
const ownBg = hoverOwnBg || restingOwnBg;
// Effective hover background: the element's own hover bg composited over
// whatever sits underneath. Bail when the surface can't be resolved to a
// solid color — gradient ancestors are handled (as at rest) by the
// resting-state check, not duplicated here.
let bg = null;
if (ownBg && ownBg.a >= 0.99) {
bg = ownBg;
} else {
const under = resolveBackground(el.parentElement || el, window, null);
if (!under) return [];
bg = ownBg && ownBg.a > 0.1 ? compositeColorOver(ownBg, under) : under;
}
return checkHoverContrast({
tag,
textColor,
bg,
ownBgAlpha: ownBg ? ownBg.a ?? 1 : null,
fontSize: parseFloat(style.fontSize) || 16,
fontWeight: parseInt(style.fontWeight) || 400,
hasDirectText: true,
isEmojiOnly: isEmojiOnlyText(directText),
});
}
function checkElementIconTile(el, tag, window) {
if (!HEADING_TAGS.has(tag)) return [];
const sibling = el.previousElementSibling;
@@ -3117,6 +3438,10 @@ export {
checkBorders,
isEmojiOnlyText,
checkColors,
checkHoverContrast,
checkElementHoverContrast,
parseColorMix,
compositeColorOver,
isCardLikeFromProps,
checkIconTile,
resolveSerif,
@@ -3127,6 +3452,7 @@ export {
checkMotion,
checkGlow,
scanCssTextForGlow,
scanCssTextForRadialHalo,
scanCssTextForPseudoStripe,
scanCssTextForPulsingDot,
checkHtmlPatterns,