From 1a4b5c2fa2173d9e80a75e3cc29cae353d267228 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Sun, 12 Jul 2026 18:18:33 -0700 Subject: [PATCH] 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 --- cli/engine/cli/main.mjs | 12 +- cli/engine/detect-antipatterns-browser.js | 416 ++++++++++++++++-- cli/engine/engines/regex/detect-text.mjs | 10 +- .../engines/static-html/css-cascade.mjs | 75 +++- .../engines/static-html/detect-html.mjs | 4 +- cli/engine/findings.mjs | 2 +- cli/engine/registry/antipatterns.mjs | 9 + cli/engine/rules/checks.mjs | 412 +++++++++++++++-- tests/detect-antipatterns-fixtures.test.mjs | 4 +- tests/detect-antipatterns.test.js | 167 +++++++ .../antipatterns/border-baseline.html | 34 +- tests/fixtures/antipatterns/should-pass.html | 4 +- 12 files changed, 1043 insertions(+), 106 deletions(-) diff --git a/cli/engine/cli/main.mjs b/cli/engine/cli/main.mjs index 69a61e775..1f046257a 100644 --- a/cli/engine/cli/main.mjs +++ b/cli/engine/cli/main.mjs @@ -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) diff --git a/cli/engine/detect-antipatterns-browser.js b/cli/engine/detect-antipatterns-browser.js index d8b072748..24e211241 100644 --- a/cli/engine/detect-antipatterns-browser.js +++ b/cli/engine/detect-antipatterns-browser.js @@ -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 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
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 ) 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 ) +// 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(/]*\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(/]*\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 `) 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, N%, transparent)` — where the +// result is simply 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; diff --git a/cli/engine/engines/regex/detect-text.mjs b/cli/engine/engines/regex/detect-text.mjs index 52a2966b9..4c279a468 100644 --- a/cli/engine/engines/regex/detect-text.mjs +++ b/cli/engine/engines/regex/detect-text.mjs @@ -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)]; + }, ]; // --------------------------------------------------------------------------- diff --git a/cli/engine/engines/static-html/css-cascade.mjs b/cli/engine/engines/static-html/css-cascade.mjs index 9e7a69e5e..60a9d57c9 100644 --- a/cli/engine/engines/static-html/css-cascade.mjs +++ b/cli/engine/engines/static-html/css-cascade.mjs @@ -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); } diff --git a/cli/engine/engines/static-html/detect-html.mjs b/cli/engine/engines/static-html/detect-html.mjs index 7c6748e17..a0f66c031 100644 --- a/cli/engine/engines/static-html/detect-html.mjs +++ b/cli/engine/engines/static-html/detect-html.mjs @@ -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) }, diff --git a/cli/engine/findings.mjs b/cli/engine/findings.mjs index 5a30b4f75..a7b0139c3 100644 --- a/cli/engine/findings.mjs +++ b/cli/engine/findings.mjs @@ -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 }; diff --git a/cli/engine/registry/antipatterns.mjs b/cli/engine/registry/antipatterns.mjs index 712bf5024..f6dd0a9a9 100644 --- a/cli/engine/registry/antipatterns.mjs +++ b/cli/engine/registry/antipatterns.mjs @@ -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', diff --git a/cli/engine/rules/checks.mjs b/cli/engine/rules/checks.mjs index ee82a1f0c..6e084eed7 100644 --- a/cli/engine/rules/checks.mjs +++ b/cli/engine/rules/checks.mjs @@ -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 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
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 ) 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 ) +// 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(/]*\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(/]*\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 `) 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, N%, transparent)` — where the +// result is simply 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, diff --git a/tests/detect-antipatterns-fixtures.test.mjs b/tests/detect-antipatterns-fixtures.test.mjs index 0c502f9bd..597adc45d 100644 --- a/tests/detect-antipatterns-fixtures.test.mjs +++ b/tests/detect-antipatterns-fixtures.test.mjs @@ -35,8 +35,8 @@ describe('detectHtml — static HTML/CSS fixtures', () => { const accents = f.filter(r => r.antipattern === 'border-accent-on-rounded'); assert.equal( sideTabs.length, - 4, - `expected 4 side-tab findings, got ${sideTabs.length}: ${sideTabs.map(r => r.snippet).join('; ')}` + 6, + `expected 6 side-tab findings, got ${sideTabs.length}: ${sideTabs.map(r => r.snippet).join('; ')}` ); assert.equal( accents.length, diff --git a/tests/detect-antipatterns.test.js b/tests/detect-antipatterns.test.js index 4c721d89b..f728c73e1 100644 --- a/tests/detect-antipatterns.test.js +++ b/tests/detect-antipatterns.test.js @@ -13,10 +13,14 @@ import { import { filterByScopes } from '../cli/engine/registry/antipatterns.mjs'; import { checkElementTextOverflowDOM, + checkHoverContrast, checkPageTypography, isScreenReaderOnlyTextStyle, + parseAnyColor, + parseColorMix, scanCssTextForPseudoStripe, scanCssTextForPulsingDot, + scanCssTextForRadialHalo, } from '../cli/engine/rules/checks.mjs'; const FIXTURES = path.join(import.meta.dir, 'fixtures', 'antipatterns'); @@ -1048,6 +1052,169 @@ describe('side-tab — pseudo-element stripe variant', () => { const css = '.hero::after { position: absolute; inset: 0; background: #3b82f6; }'; expect(scanCssTextForPseudoStripe(css)).toHaveLength(0); }); + + // Horizontal (top/bottom) stripe variant + test('detects top-anchored full-width pseudo stripe', () => { + const css = '.stat-card::before { content: ""; position: absolute; top: 0; left: 0; right: 0; height: 4px; background: #e04a3a; }'; + const f = scanCssTextForPseudoStripe(css); + expect(f).toHaveLength(1); + expect(f[0].snippet).toContain('(top: 0)'); + }); + + test('detects bottom-anchored width:100% pseudo stripe', () => { + const css = '.promo::after { content: ""; position: absolute; bottom: 0; left: 0; width: 100%; height: 5px; background: oklch(0.62 0.2 30); }'; + const f = scanCssTextForPseudoStripe(css); + expect(f).toHaveLength(1); + expect(f[0].snippet).toContain('(bottom: 0)'); + }); + + test('skips link/button underline affordances (horizontal variant)', () => { + const link = '.nav-link::after { position: absolute; bottom: 0; left: 0; right: 0; height: 3px; background: #e04a3a; }'; + const anchor = 'a.cta::after { position: absolute; bottom: 0; left: 0; width: 100%; height: 3px; background: #e04a3a; }'; + const btn = '.cta-btn::after { position: absolute; bottom: 0; left: 0; right: 0; height: 3px; background: #e04a3a; }'; + expect(scanCssTextForPseudoStripe(link)).toHaveLength(0); + expect(scanCssTextForPseudoStripe(anchor)).toHaveLength(0); + expect(scanCssTextForPseudoStripe(btn)).toHaveLength(0); + }); + + test('skips tab/selected-state underlines (horizontal variant)', () => { + const tab = '[role="tab"][aria-selected="true"]::after { position: absolute; bottom: 0; left: 0; right: 0; height: 3px; background: #4a7de0; }'; + const tabs = '.tabs .item::after { position: absolute; bottom: 0; left: 0; right: 0; height: 3px; background: #4a7de0; }'; + expect(scanCssTextForPseudoStripe(tab)).toHaveLength(0); + expect(scanCssTextForPseudoStripe(tabs)).toHaveLength(0); + }); + + test('skips hover-state underline affordance (horizontal variant)', () => { + const css = '.item:hover::after { position: absolute; bottom: 0; left: 0; right: 0; height: 3px; background: #e04a3a; }'; + expect(scanCssTextForPseudoStripe(css)).toHaveLength(0); + }); + + test('skips 2px and 16px horizontal bars (thickness gates)', () => { + const thin = '.card::before { position: absolute; top: 0; left: 0; right: 0; height: 2px; background: #e04a3a; }'; + const band = '.card::before { position: absolute; top: 0; left: 0; right: 0; height: 16px; background: #e04a3a; }'; + expect(scanCssTextForPseudoStripe(thin)).toHaveLength(0); + expect(scanCssTextForPseudoStripe(band)).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// Radial-gradient background halo +// --------------------------------------------------------------------------- + +describe('radial-halo', () => { + const darkRoot = 'body { background: oklch(0.085 0.020 262); }'; + + test('flags chromatic halo fading to transparent on a dark page', () => { + const css = `${darkRoot} body { background: radial-gradient(120% 80% at 50% -10%, oklch(0.240 0.045 268) 0%, transparent 55%), oklch(0.085 0.020 262); }`; + const f = scanCssTextForRadialHalo(css); + expect(f).toHaveLength(1); + expect(f[0].snippet).toContain('radial-gradient halo'); + }); + + test('skips achromatic vignette with no transparent stop', () => { + const css = `${darkRoot} body { background: radial-gradient(120% 90% at 50% -10%, oklch(0.19 0.02 264) 0%, oklch(0.075 0.01 262) 100%); }`; + expect(scanCssTextForRadialHalo(css)).toHaveLength(0); + }); + + test('skips panel sheen fading to an opaque surface color', () => { + const css = `${darkRoot} .hero { background: radial-gradient(120% 90% at 85% 0%, oklch(0.255 0.034 262), oklch(0.205 0.032 262) 60%); }`; + expect(scanCssTextForRadialHalo(css)).toHaveLength(0); + }); + + test('skips px-sized dot texture patterns', () => { + const css = `${darkRoot} .device::before { background-image: radial-gradient(oklch(1 0 0 / 0.018) 1px, transparent 1.4px); }`; + expect(scanCssTextForRadialHalo(css)).toHaveLength(0); + }); + + test('skips translucent light-scene washes (inner alpha below 0.7)', () => { + const css = `${darkRoot} .hero .light { background: radial-gradient(closest-side, oklch(0.62 0.10 255 / 0.55), oklch(0.42 0.09 258 / 0.22) 45%, transparent 72%); }`; + expect(scanCssTextForRadialHalo(css)).toHaveLength(0); + }); + + test('skips halos on light pages', () => { + const css = 'body { background: #faf7f2; } .hero { background: radial-gradient(60% 40% at 50% 0%, #7c3aed 0%, transparent 70%); }'; + expect(scanCssTextForRadialHalo(css)).toHaveLength(0); + }); + + test('skips declarations that include photographic url() layers', () => { + const css = `${darkRoot} .hero { background: url(cover.jpg), radial-gradient(60% 40% at 50% 0%, #7c3aed 0%, transparent 70%); }`; + expect(scanCssTextForRadialHalo(css)).toHaveLength(0); + }); + + test('resolves var() color stops', () => { + const css = `:root { --glow: oklch(0.5 0.18 300); } ${darkRoot} .bg { background: radial-gradient(80% 60% at 50% 0%, var(--glow) 0%, transparent 60%); }`; + expect(scanCssTextForRadialHalo(css)).toHaveLength(1); + }); +}); + +// --------------------------------------------------------------------------- +// Hover-state contrast + color-mix parsing +// --------------------------------------------------------------------------- + +describe('hover contrast + color-mix', () => { + test('parseColorMix: mix with transparent keeps color, scales alpha', () => { + const c = parseColorMix('color-mix(in oklab, rgb(230, 68, 37) 16%, transparent)'); + expect(c.r).toBe(230); + expect(c.g).toBe(68); + expect(c.b).toBe(37); + expect(c.a).toBeCloseTo(0.16, 2); + }); + + test('parseColorMix: 50/50 opaque mix averages channels', () => { + const c = parseColorMix('color-mix(in srgb, rgb(0, 0, 0), rgb(255, 255, 255))'); + expect(c.a).toBe(1); + expect(Math.abs(c.r - 128)).toBeLessThanOrEqual(1); + }); + + test('parseAnyColor routes color-mix expressions', () => { + const c = parseAnyColor('color-mix(in oklab, oklch(0.625 0.205 33) 16%, transparent)'); + expect(c).not.toBeNull(); + expect(c.a).toBeCloseTo(0.16, 2); + }); + + test('checkHoverContrast flags a failing hover pair on a styled control', () => { + const f = checkHoverContrast({ + tag: 'a', + textColor: { r: 239, g: 236, b: 233, a: 1 }, + bg: { r: 215, g: 56, b: 23, a: 1 }, + ownBgAlpha: 1, + fontSize: 13.6, + fontWeight: 500, + hasDirectText: true, + isEmojiOnly: false, + }); + expect(f).toHaveLength(1); + expect(f[0].id).toBe('low-contrast'); + expect(f[0].snippet).toContain(':hover'); + }); + + test('checkHoverContrast skips plain links without their own background', () => { + const f = checkHoverContrast({ + tag: 'a', + textColor: { r: 120, g: 120, b: 120, a: 1 }, + bg: { r: 128, g: 128, b: 128, a: 1 }, + ownBgAlpha: null, + fontSize: 16, + fontWeight: 400, + hasDirectText: true, + isEmojiOnly: false, + }); + expect(f).toHaveLength(0); + }); + + test('checkHoverContrast passes a compliant hover pair', () => { + const f = checkHoverContrast({ + tag: 'a', + textColor: { r: 255, g: 255, b: 255, a: 1 }, + bg: { r: 20, g: 20, b: 20, a: 1 }, + ownBgAlpha: 1, + fontSize: 14, + fontWeight: 500, + hasDirectText: true, + isEmojiOnly: false, + }); + expect(f).toHaveLength(0); + }); }); // --------------------------------------------------------------------------- diff --git a/tests/fixtures/antipatterns/border-baseline.html b/tests/fixtures/antipatterns/border-baseline.html index c81013ae3..637f97960 100644 --- a/tests/fixtures/antipatterns/border-baseline.html +++ b/tests/fixtures/antipatterns/border-baseline.html @@ -112,16 +112,26 @@ border-radius: 10px; } - .pass-square-top { + .flag-square-top { border-top: 4px solid #7c3aed; border-radius: 0; } - .pass-square-bottom { + .flag-square-bottom { border-bottom: 3px solid #ea580c; border-radius: 0; } + .pass-tab-underline { + border-bottom: 3px solid #2563eb; + border-radius: 0; + } + + .pass-thick-band { + border-top: 16px solid #7c3aed; + border-radius: 0; + } + .pass-dark-uniform { background: #161a22; color: #f4f7fb; @@ -191,14 +201,26 @@

A 1px colored rule is below the decorative side-tab threshold.

-
+

Square top border

-

A top rule without rounded card corners is treated as a structural divider.

+

A chromatic top stripe on a card is the horizontal variant of the side tab.

-
+

Square bottom border

-

A bottom rule without rounded card corners is also allowed.

+

A chromatic bottom stripe on a badge or card is the same decorative tell.

+
+ +
+ +
+ +
+

Thick color band

+

A band well past stripe thickness is a compositional block, not an accent stripe.

diff --git a/tests/fixtures/antipatterns/should-pass.html b/tests/fixtures/antipatterns/should-pass.html index 5ea9f3baf..6c2f03e83 100644 --- a/tests/fixtures/antipatterns/should-pass.html +++ b/tests/fixtures/antipatterns/should-pass.html @@ -33,9 +33,9 @@

Subtle 1px border all around. Clean and intentional.

-
+

Top border, no radius

-

Top accent without rounded corners is a clean section divider.

+

A hairline top rule without rounded corners is a clean section divider.