Merge origin/main into fix/browser-bg-resolution-dark-themes

Reconciles this branch's resolveBackgroundInfo contract (unresolved-surface
abstention, url-on-top handling, currentcolor substitution, glow adapter fix)
with main's #557 gradient-ground work (readCascadeBackgroundColor,
parseGradientColorsModern, resolveGradientStops rewrite) and #559's scoped
ignores and invisible-at-rest skips. Generated browser bundle rebuilt with
bun run build:browser.

Merge conflict resolution performed with AI assistance (Claude Code).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-08-11 13:56:55 -04:00
co-authored by Claude
474 changed files with 27724 additions and 6327 deletions
+193 -38
View File
@@ -77,6 +77,34 @@ function checkBorders(tag, widths, colors, radius, opts = {}) {
return findings;
}
// ─── Scoped ignores: data-impeccable-ignore ─────────────────────────────────
//
// An element-scoped waiver that travels with the markup: any element carrying
// `data-impeccable-ignore="rule-a rule-b"` (or `*`, or an empty value, for
// every rule) suppresses matching findings from itself and its entire subtree,
// in every engine that walks elements — the browser overlay, the extension,
// and the static scan. This is the DOM twin of the line-based
// `impeccable-disable` comment directives, which the browser cannot apply (a
// live DOM has no line numbers), and the generalization of the one-off
// `data-impeccable-allow-kickers` opt-out.
//
// The intended use is curated exhibits: a page that documents anti-patterns by
// example, or renders a deliberate "before" specimen, marks the container once
// and every engine skips it while still scanning the page around it.
function scopedIgnoreActive(el, ruleId) {
const rule = String(ruleId || '').toLowerCase();
let cur = el;
while (cur && cur.nodeType === 1) {
const attr = typeof cur.getAttribute === 'function' ? cur.getAttribute('data-impeccable-ignore') : null;
if (attr != null) {
const rules = String(attr).trim().toLowerCase().split(/[\s,]+/).filter(Boolean);
if (rules.length === 0 || rules.includes('*') || rules.includes(rule)) return true;
}
cur = cur.parentElement;
}
return false;
}
// Returns true if the given text is composed entirely of emoji characters
// (plus whitespace / variation selectors). Emojis render as multicolor glyphs
// regardless of CSS `color`, so contrast checks against the element's text
@@ -644,6 +672,26 @@ function cssTextHasDarkRootBg(content, customProps) {
return false;
}
// Best-effort extraction of the CSS selector whose declaration block contains
// the given index in raw CSS text. Lets CSS-text findings carry a live-DOM
// anchor, so the browser pass can resolve scoped ignores against the actual
// element and drop patterns that render nowhere on the page. Returns null for
// @-rule preludes, keyframe steps, nested blocks, and anything that does not
// read as a selector; those findings stay page-level.
function enclosingCssSelector(cssText, index) {
if (!cssText || !Number.isFinite(index)) return null;
const open = cssText.lastIndexOf('{', index);
if (open === -1) return null;
const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
const raw = cssText.slice(prevClose + 1, open).trim().replace(/\s+/g, ' ');
if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
// Keyframe steps: percentage steps fail the digit test above, but `from`
// and `to` would read as (never-matching) type selectors and get a valid
// finding wrongly dropped by the zero-match rule downstream.
if (/^(?:from|to)(?:\s*,\s*(?:from|to))*$/i.test(raw)) return null;
return raw;
}
function scanCssTextForGlow(content) {
const customProps = collectCssCustomProps(content);
const hasDarkBg = cssTextHasDarkRootBg(content, customProps);
@@ -955,6 +1003,7 @@ function scanCssTextForPseudoStripe(rawContent) {
id: 'side-tab',
snippet: `${selector} — absolute ${thicknessPx}px pseudo-element stripe (${edge}: 0)`,
index: selectorStart,
selector,
});
}
return findings;
@@ -1017,6 +1066,7 @@ function scanCssTextForInsetStripe(content) {
findings.push({
id: 'side-tab',
snippet: `${selector} — inset box-shadow ${ay === 0 ? ax : ay}px stripe (${edge})`,
selector,
});
break;
}
@@ -1074,7 +1124,7 @@ function collectMarqueeKeyframes(content) {
function scanCssTextForMarquee(content, markup = content) {
const findings = [];
if (/<marquee\b/i.test(markup)) {
findings.push({ id: 'marquee', snippet: '<marquee> element' });
findings.push({ id: 'marquee', snippet: '<marquee> element', selector: 'marquee' });
}
const marqueeKeyframes = collectMarqueeKeyframes(content);
if (marqueeKeyframes.size === 0) return findings;
@@ -1089,7 +1139,7 @@ function scanCssTextForMarquee(content, markup = content) {
const key = `${selector} ${name}`;
if (seen.has(key)) continue;
seen.add(key);
findings.push({ id: 'marquee', snippet: `${selector} — infinite horizontal loop animation "${name}"` });
findings.push({ id: 'marquee', snippet: `${selector} — infinite horizontal loop animation "${name}"`, selector });
}
}
return findings;
@@ -1460,8 +1510,10 @@ function checkHtmlPatterns(html, corpora) {
const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi;
if (purpleHexRe.test(styleText)) {
const purpleTextRe = /(?:(?:^|;)\s*color\s*:\s*(?:.*?)(?:#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9))|gradient.*?#(?:7c3aed|8b5cf6|a855f7|764ba2|667eea))/gi;
if (purpleTextRe.test(styleText)) {
findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected' });
purpleTextRe.lastIndex = 0;
const purpleMatch = purpleTextRe.exec(styleText);
if (purpleMatch) {
findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected', selector: enclosingCssSelector(styleText, purpleMatch.index + 1) || undefined });
}
}
@@ -1472,7 +1524,7 @@ function checkHtmlPatterns(html, corpora) {
const start = Math.max(0, gm.index - 200);
const context = styleText.substring(start, gm.index + gm[0].length + 200);
if (/gradient/i.test(context)) {
findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient', selector: enclosingCssSelector(styleText, gm.index) || undefined });
break;
}
}
@@ -1538,7 +1590,7 @@ function checkHtmlPatterns(html, corpora) {
const animationToken = bounceMatch[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}` });
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}`, selector: enclosingCssSelector(styleText, bounceMatch.index) || undefined });
}
// Overshoot cubic-bezier
@@ -1547,7 +1599,7 @@ function checkHtmlPatterns(html, corpora) {
while ((bm = bezierRe.exec(styleText)) !== null) {
const y1 = parseFloat(bm[2]), y2 = parseFloat(bm[4]);
if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {
findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})` });
findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})`, selector: enclosingCssSelector(styleText, bm.index) || undefined });
break;
}
}
@@ -1580,18 +1632,21 @@ function checkHtmlPatterns(html, corpora) {
const glowHits = scanCssTextForGlow(styleText);
if (glowHits.length > 0) {
findings.push({ id: 'dark-glow', snippet: glowHits[0].snippet });
findings.push({ id: 'dark-glow', snippet: glowHits[0].snippet, selector: enclosingCssSelector(styleText, glowHits[0].index) || undefined });
}
// Radial-gradient background halo (gradient-drawn sibling of dark-glow)
const haloHits = scanCssTextForRadialHalo(styleText);
if (haloHits.length > 0) {
findings.push({ id: 'radial-halo', snippet: haloHits[0].snippet });
findings.push({ id: 'radial-halo', snippet: haloHits[0].snippet, selector: enclosingCssSelector(styleText, haloHits[0].index) || undefined });
}
// --- Generated-UI tells: repeating-gradient stripes ---
if (/repeating-(?:linear|radial|conic)-gradient\s*\(/i.test(styleText)) {
findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes' });
{
const stripesMatch = /repeating-(?:linear|radial|conic)-gradient\s*\(/i.exec(styleText);
if (stripesMatch) {
findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes', selector: enclosingCssSelector(styleText, stripesMatch.index) || undefined });
}
}
// --- Generated-UI tells: two-axis grid-line background ---
@@ -1609,7 +1664,7 @@ function checkHtmlPatterns(html, corpora) {
// whole gradient layers.
const gridHits = scanCssTextForGridBackground(styleText);
if (gridHits.length > 0) {
findings.push({ id: 'codex-grid-background', snippet: gridHits[0].snippet });
findings.push({ id: 'codex-grid-background', snippet: gridHits[0].snippet, selector: enclosingCssSelector(styleText, gridHits[0].index) || undefined });
}
// --- Generated-copy tells: "X theater" framing copy ---
@@ -1629,8 +1684,11 @@ function checkHtmlPatterns(html, corpora) {
// hover:rotate / hover:translate utility on an <img>. Each distinct
// mechanism is its own finding.
const imgHoverCss = /\bimg\b[^,{}]*:hover\b[^{}]*\{[^}]*\btransform\s*:\s*(?:scale|rotate|translate|matrix|skew)/i;
if (imgHoverCss.test(styleText)) {
findings.push({ id: 'image-hover-transform', snippet: 'img:hover { transform } rule' });
{
const imgHoverMatch = imgHoverCss.exec(styleText);
if (imgHoverMatch) {
findings.push({ id: 'image-hover-transform', snippet: 'img:hover { transform } rule', selector: enclosingCssSelector(styleText, imgHoverMatch.index + imgHoverMatch[0].indexOf('{') + 1) || undefined });
}
}
const imgTagRe = /<img\b[^>]*\bclass\s*=\s*"([^"]*)"/gi;
let im;
@@ -1677,6 +1735,33 @@ function readOwnBackgroundColor(el, computedStyle) {
return bg;
}
// One element's background-color as the cascade walk sees it: computed style
// first (with the modern-color fallback), then, in static mode only,
// custom-prop resolution and the inline-shorthand peek. Shared by
// resolveBackgroundInfo and resolveGradientStops so both walks read the same
// surfaces.
function readCascadeBackgroundColor(current, style, customPropMap) {
let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
if (!DETECTOR_IS_BROWSER && (!bg || bg.a < 0.1)) {
// The static engine can return literal "var(--X)" / "oklch(...)" strings.
// Resolve through customPropMap so Tailwind v4 color tokens become RGB.
if (customPropMap) {
bg = parseColorResolved(style.backgroundColor, customPropMap);
}
if (!bg || bg.a < 0.1) {
// Inline-style fallback for colors the static cascade did not surface
// on backgroundColor.
const rawStyle = current.getAttribute?.('style') || '';
const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
const inlineBg = bgMatch ? bgMatch[1].trim() : '';
if (inlineBg && !/gradient/i.test(inlineBg) && !/url\s*\(/i.test(inlineBg)) {
bg = parseColorResolved(inlineBg, customPropMap) || parseAnyColor(inlineBg);
}
}
}
return bg;
}
// Walk up for the surface the element's text is painted on.
//
// Returns { color, unresolved }:
@@ -1717,24 +1802,7 @@ function resolveBackgroundInfo(el, win, customPropMap) {
// body backgrounds.
// Real browsers serialize wide-gamut computed values as oklab()/oklch()
// (e.g. any color-mix() result), which plain parseRgb misses.
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.
if (customPropMap) {
bg = parseColorResolved(style.backgroundColor, customPropMap);
}
if (!bg || bg.a < 0.1) {
// Inline-style fallback. jsdom doesn't decompose background
// shorthand, so colors set via inline style are otherwise invisible.
const rawStyle = current.getAttribute?.('style') || '';
const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
const inlineBg = bgMatch ? bgMatch[1].trim() : '';
if (inlineBg && !/gradient/i.test(inlineBg) && !/url\s*\(/i.test(inlineBg)) {
bg = parseColorResolved(inlineBg, customPropMap) || parseAnyColor(inlineBg);
}
}
}
let bg = readCascadeBackgroundColor(current, style, customPropMap);
// `background-color: currentcolor` paints with the element's own text
// color — real paint whose value we know. Real browsers resolve the
@@ -1794,29 +1862,73 @@ function resolveBackground(el, win, customPropMap) {
return resolveBackgroundInfo(el, win, customPropMap).color;
}
// parseGradientColors (shared) reads only the legacy serializations: rgb()
// and hex stops. Browsers keep modern-space stops in computed backgroundImage
// exactly as authored — `linear-gradient(oklch(7% 0.006 95), …)` stays oklch —
// which is what every token-driven page produces. Route those through
// parseAnyColor so a gradient ground is measurable rather than invisible.
function parseGradientColorsModern(bgImage) {
if (!bgImage || !/gradient/i.test(bgImage)) return [];
const colors = parseGradientColors(bgImage);
for (const m of bgImage.matchAll(/(?:oklch|oklab|hsla?|hwb)\(\s*[^()]*\)/gi)) {
const c = parseAnyColor(m[0]);
if (c) colors.push(c);
}
return colors;
}
// Walk parents looking for a gradient background and return its color stops.
// Used as a fallback when resolveBackground() returns null because the
// effective background is a gradient (no single solid color to compare against).
// Translucent solid layers found between the element and the gradient (frosted
// panels, glass washes) are composited over every stop, the same way
// resolveBackground flattens them over a solid base — raw stops alone would
// false-flag dark text on a light frosted wash over a dark gradient, and miss
// the inverse.
function resolveGradientStops(el, win, customPropMap) {
let current = el;
const overlays = [];
while (current && current.nodeType === 1) {
const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
const bgImage = style.backgroundImage || '';
// A url() layer anywhere in the stack — alone, or alongside a gradient in
// the same declaration (a translucent wash over a texture photo) — paints
// pixels the analytic walk cannot know. Measuring the gradient stops over
// the wrong base flagged dark ink sitting on a bright gold-leaf image at
// 2.6:1; skipping beats a wrong ratio, and the screenshot subsystem owns
// image-backed text.
if (bgImage && bgImage !== 'none' && /url\s*\(/i.test(bgImage)) return null;
let stops = null;
if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
const parsed = parseGradientColors(bgImage);
const parsed = parseGradientColorsModern(bgImage);
if (parsed.length > 0) stops = parsed;
}
if (!stops && !DETECTOR_IS_BROWSER) {
// jsdom doesn't decompose `background:` shorthand — peek at the raw inline style
// Static mode: peek at the raw inline style for gradients the cascade did not surface
const rawStyle = current.getAttribute?.('style') || '';
const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
if (bgMatch && /gradient/i.test(bgMatch[1])) {
const parsed = parseGradientColors(bgMatch[1]);
const parsed = parseGradientColorsModern(bgMatch[1]);
if (parsed.length > 0) stops = parsed;
}
}
if (stops) return compositeGradientStops(stops, current, win, customPropMap);
if (stops) {
const composited = compositeGradientStops(stops, current, win, customPropMap);
if (!composited || overlays.length === 0) return composited;
return composited.map(stop => {
let acc = stop;
for (let i = overlays.length - 1; i >= 0; i--) acc = compositeColorOver(overlays[i], acc);
return acc;
});
}
const bg = readCascadeBackgroundColor(current, style, customPropMap);
if (bg && bg.a > 0.1) {
// An opaque surface above the gradient means the gradient never shows
// through here; resolveBackground would have returned it, so reaching
// this is defensive — bail rather than measure the wrong layer.
if (bg.a >= 0.99) return null;
overlays.push(bg);
}
current = current.parentElement;
}
return null;
@@ -2036,6 +2148,10 @@ function checkElementColorsDOM(el) {
const rect = el.getBoundingClientRect();
if (rect.width < 10 || rect.height < 10) return [];
const style = getComputedStyle(el);
// Invisible at rest: hidden scene variants (opacity-0 carousels, swap
// decks) are not user-visible, and measuring their inherited colors against
// whatever surface happens to sit behind the stack is noise, not audit.
if (style.visibility === 'hidden' || effectiveOpacityDOM(el) <= 0.02) return [];
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
const bgInfo = resolveBackgroundInfo(el);
@@ -2584,11 +2700,13 @@ function checkElementGlowDOM(el) {
// null color straight through (detect-html.mjs uses resolveBackground).
let parentBg = parentBgInfo.color;
if (!parentBg && !parentBgInfo.unresolved) {
// Gradient background — sample its colors to determine if it's dark
// Gradient background — sample its colors to determine if it's dark.
// Modern-syntax parsing matters here: body-level gradients now reach this
// fallback in browser mode, and their stops usually serialize as oklch.
let cur = el.parentElement;
while (cur && cur.nodeType === 1) {
const bgImage = getComputedStyle(cur).backgroundImage || '';
const gradColors = parseGradientColors(bgImage);
const gradColors = parseGradientColorsModern(bgImage);
if (gradColors.length > 0) {
// Average the gradient colors
const avg = { r: 0, g: 0, b: 0 };
@@ -3433,6 +3551,14 @@ function checkElementBorders(tag, style, overrides, resolvedRadius, el = null) {
}
function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInheritRule) {
// Invisible at rest, static twin of the browser walk's skip: opacity does
// not inherit, so walk ancestors multiplying declared opacity down.
if (style.visibility === 'hidden') return [];
let effOpacity = 1;
for (let cur = el; cur && cur.nodeType === 1 && effOpacity > 0.02; cur = cur.parentElement) {
effOpacity *= parseFloat(window.getComputedStyle(cur).opacity || '1');
}
if (effOpacity <= 0.02) return [];
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
@@ -4595,6 +4721,11 @@ function isRenderedForBrowserRule(el) {
function checkElementTextOverflowDOM(el) {
const tag = el.tagName.toLowerCase();
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
// scrollWidth/clientWidth are CSS box-model metrics; on SVG content Chrome
// returns arbitrary non-zero values for both (a <text> reported 78/48 while
// its rendered length sat comfortably inside its box), so the delta is
// noise, not overflow. SVG clips to its own viewport anyway.
if (el.namespaceURI === 'http://www.w3.org/2000/svg') return [];
if (!isRenderedForBrowserRule(el)) return [];
// Only the element that actually owns overflowing text — not its ancestors,
// which inherit a wider scrollWidth from the spilling descendant.
@@ -4979,6 +5110,22 @@ function isPaintedForOcclusion(el) {
// path is pure geometry and runs anywhere on the page.
const OCCLUSION_TEXT_SKIP_TAGS = new Set(['script', 'style', 'noscript', 'template', 'title']);
// An element whose effective opacity multiplies out to ~0 paints nothing at
// rest: it is not user-visible, so visual findings on it (contrast, occlusion)
// measure a state nobody sees. Browser-only — the walk needs live computed
// styles. Cycling scenes that fade such elements in later are the screenshot
// subsystem's territory, not the analytic walk's.
function effectiveOpacityDOM(el) {
let o = 1;
// Walk all the way through body and html: `body { opacity: 0 }` page-fade
// wrappers hide every descendant just as thoroughly as a local wrapper.
for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) {
o *= parseFloat(getComputedStyle(cur).opacity || '1');
if (o <= 0.02) return 0;
}
return o;
}
function checkTextOcclusionDOM() {
const findings = [];
const seenVictims = new Set();
@@ -5006,6 +5153,11 @@ function checkTextOcclusionDOM() {
}
return false;
};
// The classic occluder shape this rules out is an opacity-0 interaction
// layer — a range scrubber stretched over a before/after comparison — which
// elementFromPoint still returns and whose UA background-color otherwise
// reads as an opaque box.
const effectiveOpacity = effectiveOpacityDOM;
// Collect renderable text owners in / near the first viewport for the
// elementFromPoint probe. SVG <text> counts too.
@@ -5018,6 +5170,7 @@ function checkTextOcclusionDOM() {
const text = inSvg ? (el.textContent || '').trim() : elementDirectText(el);
if (text.length < 2) continue;
if (!isPaintedForOcclusion(el)) continue;
if (effectiveOpacity(el) <= 0.02) continue;
let rect; try { rect = el.getBoundingClientRect(); } catch { continue; }
if (rect.width < 6 || rect.height < 6) continue;
// Viewport-bound probe: keep text whose box overlaps the live viewport.
@@ -5051,6 +5204,7 @@ function checkTextOcclusionDOM() {
if (top === el || el.contains(top) || top.contains(el)) continue;
const topCs = getComputedStyle(top);
if (isFloated(topCs) || isMarqueeish(top, topCs) || isPinnedOverlay(top)) continue;
if (effectiveOpacity(top) <= 0.02) continue;
const topTag = top.tagName.toLowerCase();
// Text sitting under a raw image/video is contrast territory (deduped
// against the pixel low-contrast rule); leave those alone here.
@@ -5261,6 +5415,7 @@ export {
CSS_NAMED_COLORS,
checkBorders,
isEmojiOnlyText,
scopedIgnoreActive,
checkColors,
checkHoverContrast,
checkElementHoverContrast,