mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-14 15:16:35 +03:00
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:
co-authored by
Claude Fable 5
parent
be03fe734a
commit
1a4b5c2fa2
@@ -118,7 +118,8 @@ Inline ignores:
|
||||
Detection modes:
|
||||
HTML files Static HTML/CSS analysis (default, catches linked CSS)
|
||||
Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.)
|
||||
URLs Puppeteer full browser rendering (auto-detected)
|
||||
URLs Puppeteer full browser rendering (auto-detected;
|
||||
http(s):// and file:// URLs)
|
||||
|
||||
Examples:
|
||||
impeccable detect src/
|
||||
@@ -199,12 +200,17 @@ async function detectCli() {
|
||||
allFindings = await handleStdin(scanOptions);
|
||||
} else {
|
||||
const paths = targets.length > 0 ? targets : [process.cwd()];
|
||||
const urlTargetCount = paths.filter(target => /^https?:\/\//i.test(target)).length;
|
||||
// file:// URLs get the same Puppeteer-rendered pass as http(s) — the
|
||||
// real cascade, real computed styles, real layout. Callers that want a
|
||||
// browser-grade scan of a local artifact can pass file:///abs/path.html
|
||||
// instead of the bare path (which stays on the static engine).
|
||||
const urlRe = /^(?:https?|file):\/\//i;
|
||||
const urlTargetCount = paths.filter(target => urlRe.test(target)).length;
|
||||
const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
|
||||
|
||||
try {
|
||||
for (const target of paths) {
|
||||
if (/^https?:\/\//i.test(target)) {
|
||||
if (urlRe.test(target)) {
|
||||
try {
|
||||
const scanner = browserDetector
|
||||
? (url) => browserDetector.detectUrl(url, scanOptions)
|
||||
|
||||
@@ -224,6 +224,15 @@ const ANTIPATTERNS = [
|
||||
skillSection: 'Color & Contrast',
|
||||
skillGuideline: 'dark mode with glowing accents',
|
||||
},
|
||||
{
|
||||
id: 'radial-halo',
|
||||
category: 'slop',
|
||||
name: 'Radial-gradient background halo',
|
||||
description:
|
||||
'A chromatic radial-gradient wash — saturated at the center, fading to transparent — used as a decorative background glow on a dark page. Same tell as glowing shadows, drawn with a gradient instead of a shadow. Ground the surface with a solid or subtly shifted background instead.',
|
||||
skillSection: 'Color & Contrast',
|
||||
skillGuideline: 'dark mode with glowing accents',
|
||||
},
|
||||
{
|
||||
id: 'icon-tile-stack',
|
||||
category: 'slop',
|
||||
@@ -707,8 +716,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'];
|
||||
|
||||
@@ -724,10 +737,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` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -841,6 +864,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;
|
||||
@@ -1188,34 +1232,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;
|
||||
@@ -1242,6 +1288,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)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1301,7 +1422,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 = {
|
||||
@@ -1326,11 +1458,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
|
||||
@@ -1353,7 +1503,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;
|
||||
@@ -1614,6 +1764,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' });
|
||||
@@ -1718,6 +1874,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 || '';
|
||||
@@ -1730,7 +1898,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.
|
||||
@@ -1750,7 +1920,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:
|
||||
@@ -1766,13 +1937,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.
|
||||
@@ -1834,6 +2005,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 [];
|
||||
@@ -1846,7 +2035,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) {
|
||||
@@ -2104,14 +2297,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 };
|
||||
@@ -2917,7 +3199,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) {
|
||||
@@ -2944,7 +3226,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) {
|
||||
@@ -3001,6 +3287,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;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { GENERIC_FONTS, OVERUSED_FONTS } from '../../shared/constants.mjs';
|
||||
import { isNeutralColor } from '../../shared/color.mjs';
|
||||
import { extractGoogleFontFamilies } from '../../shared/fonts.mjs';
|
||||
import { checkSourceDesignSystem } from '../../design-system.mjs';
|
||||
import { scanCssTextForGlow } from '../../rules/checks.mjs';
|
||||
import { scanCssTextForGlow, scanCssTextForRadialHalo } from '../../rules/checks.mjs';
|
||||
import { isFullPage } from '../../shared/page.mjs';
|
||||
import { applyInlineIgnores } from '../../shared/inline-ignores.mjs';
|
||||
import { finding } from '../../findings.mjs';
|
||||
@@ -326,6 +326,14 @@ const REGEX_ANALYZERS = [
|
||||
const lines = content.substring(0, hits[0].index).split('\n');
|
||||
return [finding('dark-glow', filePath, hits[0].snippet, lines.length)];
|
||||
},
|
||||
// Radial-gradient background halo on a dark page (the gradient sibling
|
||||
// of the dark-glow shadow tell).
|
||||
(content, filePath) => {
|
||||
const hits = scanCssTextForRadialHalo(content);
|
||||
if (hits.length === 0) return [];
|
||||
const lines = content.substring(0, hits[0].index).split('\n');
|
||||
return [finding('radial-halo', filePath, hits[0].snippet, lines.length)];
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -425,6 +425,22 @@ function extractStaticColor(value) {
|
||||
if (!value) return '';
|
||||
const raw = String(value).trim();
|
||||
if (/^var\(/i.test(raw)) return raw;
|
||||
// color-mix(...) needs balanced-paren capture (its arguments regularly
|
||||
// contain nested var()/oklch() calls AND the keyword `transparent`, which
|
||||
// the flat regex below would otherwise pluck out of the middle of the
|
||||
// expression and report as the whole color).
|
||||
const mixStart = raw.search(/color-mix\(/i);
|
||||
if (mixStart !== -1) {
|
||||
let depth = 0;
|
||||
for (let i = raw.indexOf('(', mixStart); i < raw.length; i++) {
|
||||
if (raw[i] === '(') depth++;
|
||||
else if (raw[i] === ')') {
|
||||
depth--;
|
||||
if (depth === 0) return raw.slice(mixStart, i + 1);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
const colorLike = raw.match(/(?:rgba?\([^)]+\)|oklch\([^)]+\)|oklab\([^)]+\)|lch\([^)]+\)|lab\([^)]+\)|hsla?\([^)]+\)|hwb\([^)]+\)|#[0-9a-f]{3,8}\b|\b(?:black|white|gray|grey|silver|red|green|blue|transparent)\b)/i);
|
||||
if (!colorLike) return '';
|
||||
return colorLike[0];
|
||||
@@ -707,7 +723,20 @@ function collectStaticCssRules(cssText, csstree) {
|
||||
});
|
||||
});
|
||||
for (const selector of splitCssList(selectorText)) {
|
||||
if (selector) rules.push({ selector, declarations, specificity: staticSpecificity(selector), order: order++ });
|
||||
if (!selector) continue;
|
||||
// :hover rules can't be matched statically as-is (no interaction
|
||||
// state), but they carry real cascade weight while hovered. Tag
|
||||
// them and record a state-stripped selector so the hover pass can
|
||||
// find their targets; specificity stays computed from the ORIGINAL
|
||||
// selector (per CSS, :hover counts as a class).
|
||||
const isHover = /:hover\b/i.test(selector);
|
||||
let matchSelector = null;
|
||||
if (isHover) {
|
||||
matchSelector = selector.replace(/:hover\b/gi, '').trim();
|
||||
if (!matchSelector || /[>+~]\s*$/.test(matchSelector)) matchSelector = null;
|
||||
else matchSelector = matchSelector.replace(/(^|[\s>+~])(?=$|[\s>+~])/g, '$1*');
|
||||
}
|
||||
rules.push({ selector, declarations, specificity: staticSpecificity(selector), order: order++, isHover, matchSelector });
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -810,6 +839,7 @@ class StaticDocument {
|
||||
this.domutils = modules.domutils;
|
||||
this._wrappers = new WeakMap();
|
||||
this._styleMap = new WeakMap();
|
||||
this._hoverStyleMap = new WeakMap();
|
||||
}
|
||||
wrap(node) {
|
||||
let wrapped = this._wrappers.get(node);
|
||||
@@ -846,6 +876,12 @@ class StaticDocument {
|
||||
getStyle(el) {
|
||||
return this._styleMap.get(el.node) || makeStaticStyle();
|
||||
}
|
||||
setHoverStyle(node, style) {
|
||||
this._hoverStyleMap.set(node, style);
|
||||
}
|
||||
getHoverStyle(el) {
|
||||
return this._hoverStyleMap.get(el.node) || null;
|
||||
}
|
||||
}
|
||||
|
||||
function makeStaticStyle(values = {}) {
|
||||
@@ -861,6 +897,7 @@ function buildStaticWindow(staticDoc) {
|
||||
return {
|
||||
document: staticDoc,
|
||||
getComputedStyle: (el) => staticDoc.getStyle(el),
|
||||
getHoverStyle: (el) => staticDoc.getHoverStyle(el),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -891,6 +928,12 @@ function collectStaticCssText(root, fileDir, profile, filePath, modules) {
|
||||
|
||||
function buildStaticStyleMap(root, staticDoc, cssText, modules, profile, filePath) {
|
||||
const specified = new Map();
|
||||
// Declarations from :hover rules, matched via their state-stripped
|
||||
// selectors. Merged per-property against the resting cascade in
|
||||
// computeNode — a hover declaration only takes effect if it would win
|
||||
// the cascade while the element is hovered (all resting rules still
|
||||
// apply in that state).
|
||||
const hoverSpecified = new Map();
|
||||
const allNodes = modules.selectAll('*', root.children || []);
|
||||
const rules = profileStep(profile, {
|
||||
engine: 'static-html',
|
||||
@@ -906,9 +949,11 @@ function buildStaticStyleMap(root, staticDoc, cssText, modules, profile, filePat
|
||||
target: filePath,
|
||||
}, () => {
|
||||
for (const rule of rules) {
|
||||
const matchSelector = rule.isHover ? rule.matchSelector : rule.selector;
|
||||
if (!matchSelector) continue;
|
||||
let matched;
|
||||
try {
|
||||
matched = modules.selectAll(rule.selector, root.children || []);
|
||||
matched = modules.selectAll(matchSelector, root.children || []);
|
||||
} catch {
|
||||
recordProfileEvent(profile, {
|
||||
engine: 'static-html',
|
||||
@@ -917,13 +962,13 @@ function buildStaticStyleMap(root, staticDoc, cssText, modules, profile, filePat
|
||||
target: filePath,
|
||||
ms: 0,
|
||||
findings: 0,
|
||||
detail: rule.selector,
|
||||
detail: matchSelector,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
for (const node of matched) {
|
||||
for (const decl of rule.declarations) {
|
||||
applyStaticDeclaration(specified, node, decl.prop, decl.value, {
|
||||
applyStaticDeclaration(rule.isHover ? hoverSpecified : specified, node, decl.prop, decl.value, {
|
||||
important: decl.important,
|
||||
specificity: rule.specificity,
|
||||
order: rule.order,
|
||||
@@ -966,6 +1011,28 @@ function buildStaticStyleMap(root, staticDoc, cssText, modules, profile, filePat
|
||||
}
|
||||
const style = makeStaticStyle(values);
|
||||
staticDoc.setStyle(node, style);
|
||||
|
||||
// Hover pass: limited to the two properties the hover-contrast check
|
||||
// consumes. A hover declaration wins only if it beats the resting
|
||||
// winner for that property under normal cascade rules (specificity /
|
||||
// order / importance) — exactly what a browser computes while the
|
||||
// element is hovered.
|
||||
const hoverMap = hoverSpecified.get(node);
|
||||
if (hoverMap) {
|
||||
let hoverValues = null;
|
||||
for (const prop of ['color', 'backgroundColor']) {
|
||||
const hoverDecl = hoverMap.get(prop);
|
||||
if (!hoverDecl) continue;
|
||||
const restingDecl = specifiedMap.get(prop);
|
||||
if (!compareStaticPriority(restingDecl, hoverDecl)) continue;
|
||||
const next = normalizeStaticCssValue(prop, hoverDecl.value, customProps, parentStyle, values);
|
||||
if (next === values[prop]) continue;
|
||||
if (!hoverValues) hoverValues = { ...values };
|
||||
hoverValues[prop] = next;
|
||||
}
|
||||
if (hoverValues) staticDoc.setHoverStyle(node, makeStaticStyle(hoverValues));
|
||||
}
|
||||
|
||||
for (const child of node.children || []) {
|
||||
if (child.type === 'tag') computeNode(child, style, customProps);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
checkElementGlow,
|
||||
checkElementGptBorderShadow,
|
||||
checkElementHeroEyebrow,
|
||||
checkElementHoverContrast,
|
||||
checkElementIconTile,
|
||||
checkElementItalicSerif,
|
||||
checkElementMotion,
|
||||
@@ -90,8 +91,9 @@ function checkElementBrokenImage(el) {
|
||||
}
|
||||
|
||||
const STATIC_ELEMENT_RULES = [
|
||||
{ id: 'border-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementBorders(tag, style, null, resolveBorderRadiusPx(el, style, parseFloat(style.width) || 0, window)) },
|
||||
{ id: 'border-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementBorders(tag, style, null, resolveBorderRadiusPx(el, style, parseFloat(style.width) || 0, window), el) },
|
||||
{ id: 'color-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementColors(el, style, tag, window, customPropMap, false) },
|
||||
{ id: 'hover-color-rules', selector: '*', run: (el, tag, style, window) => checkElementHoverContrast(el, style, tag, window) },
|
||||
{ id: 'dark-glow', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementGlow(tag, style, resolveBackground(el.parentElement || el, window, customPropMap)) },
|
||||
{ id: 'motion-rules', selector: '*', run: (el, tag, style) => checkElementMotion(tag, style) },
|
||||
{ id: 'icon-tile-stack', selector: 'h1,h2,h3,h4,h5,h6', run: (el, tag, _style, window) => checkElementIconTile(el, tag, window) },
|
||||
|
||||
@@ -6,7 +6,7 @@ function getAP(id) {
|
||||
|
||||
function finding(id, filePath, snippet, line = 0) {
|
||||
const ap = getAP(id);
|
||||
return { antipattern: id, name: ap.name, description: ap.description, severity: ap.severity || 'warning', file: filePath, line, snippet };
|
||||
return { antipattern: id, name: ap.name, description: ap.description, severity: ap.severity || 'warning', category: ap.category || null, file: filePath, line, snippet };
|
||||
}
|
||||
|
||||
export { getAP, finding };
|
||||
|
||||
@@ -122,6 +122,15 @@ const ANTIPATTERNS = [
|
||||
skillSection: 'Color & Contrast',
|
||||
skillGuideline: 'dark mode with glowing accents',
|
||||
},
|
||||
{
|
||||
id: 'radial-halo',
|
||||
category: 'slop',
|
||||
name: 'Radial-gradient background halo',
|
||||
description:
|
||||
'A chromatic radial-gradient wash — saturated at the center, fading to transparent — used as a decorative background glow on a dark page. Same tell as glowing shadows, drawn with a gradient instead of a shadow. Ground the surface with a solid or subtly shifted background instead.',
|
||||
skillSection: 'Color & Contrast',
|
||||
skillGuideline: 'dark mode with glowing accents',
|
||||
},
|
||||
{
|
||||
id: 'icon-tile-stack',
|
||||
category: 'slop',
|
||||
|
||||
+369
-43
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user