mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 22:26:38 +03:00
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:
@@ -683,6 +683,10 @@ if (IS_BROWSER) {
|
||||
|
||||
const reasons = collectVisualContrastReasons(el, style);
|
||||
if (reasons.length === 0) continue;
|
||||
// Image-only mode filters here, inside the cap: gradient/opacity/filter
|
||||
// candidates earlier in DOM order must not consume the budget and
|
||||
// starve the url()-backed texts this mode exists to sample.
|
||||
if (options.imageOnly && !reasons.includes('image background')) continue;
|
||||
|
||||
const textColor = parseRgb(style.color);
|
||||
const fontSize = parseFloat(style.fontSize) || 16;
|
||||
@@ -1175,6 +1179,7 @@ if (IS_BROWSER) {
|
||||
}
|
||||
|
||||
async function analyzeVisualContrast(options = {}) {
|
||||
// imageOnly is enforced inside the collector, before the candidate cap.
|
||||
const candidates = collectVisualContrastCandidates(options);
|
||||
const results = [];
|
||||
const shouldScrollOffscreen = options.scrollOffscreen === true;
|
||||
@@ -1260,9 +1265,16 @@ if (IS_BROWSER) {
|
||||
|
||||
function addBrowserFindings(groupMap, el, findings) {
|
||||
if (!findings || findings.length === 0) return;
|
||||
// Element-scoped waivers: a data-impeccable-ignore ancestor suppresses
|
||||
// matching findings for its whole subtree. Applied at this choke point so
|
||||
// every per-element attribution (checks, layout, occlusion, rhythm)
|
||||
// honors it; page-level findings attributed to <body> pass through
|
||||
// untouched, since body has no ignoring ancestor.
|
||||
const kept = findings.filter(f => !scopedIgnoreActive(el, f.type));
|
||||
if (kept.length === 0) return;
|
||||
const existing = groupMap.get(el);
|
||||
if (existing) existing.push(...findings);
|
||||
else groupMap.set(el, [...findings]);
|
||||
if (existing) existing.push(...kept);
|
||||
else groupMap.set(el, [...kept]);
|
||||
}
|
||||
|
||||
function browserFindingsFromMap(groupMap) {
|
||||
@@ -1620,9 +1632,27 @@ if (IS_BROWSER) {
|
||||
for (const node of docClone.querySelectorAll('[id^="impeccable-live-"]')) {
|
||||
node.remove();
|
||||
}
|
||||
const htmlPatternFindings = checkHtmlPatterns(docClone.outerHTML);
|
||||
if (htmlPatternFindings.length > 0) {
|
||||
const mapped = htmlPatternFindings.map(f => {
|
||||
// Regex findings that name a live selector resolve against the real DOM:
|
||||
// pseudo-element/class segments are stripped (the host element is the
|
||||
// anchor), a selector that matches nothing on this page drops the finding
|
||||
// (the CSS ships here, but the pattern never renders — the live DOM is
|
||||
// ground truth in the browser), and a match under a data-impeccable-ignore
|
||||
// ancestor is waived. Selector-less findings stay page-level.
|
||||
const scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
|
||||
if (!f.selector) return true;
|
||||
const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
|
||||
if (!query || /^[,\s]*$/.test(query)) return true;
|
||||
let matches;
|
||||
try {
|
||||
matches = document.querySelectorAll(query);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
if (matches.length === 0) return false;
|
||||
return [...matches].some(el => !scopedIgnoreActive(el, f.id));
|
||||
});
|
||||
if (scopedHtmlFindings.length > 0) {
|
||||
const mapped = scopedHtmlFindings.map(f => {
|
||||
const item = { type: f.id, detail: f.snippet };
|
||||
if (f.severity) {
|
||||
item.severity = f.severity;
|
||||
@@ -1652,8 +1682,27 @@ if (IS_BROWSER) {
|
||||
};
|
||||
}
|
||||
|
||||
// Visual contrast has three modes. Explicit true runs the full sampled
|
||||
// pass; explicit false disables it entirely (the deterministic-only mode
|
||||
// the test suites use). Unset — the default overlay run — samples ONLY
|
||||
// image-backed text: the one class the analytic walk deliberately skips,
|
||||
// because a url() layer's pixels are unknowable without looking. In-page
|
||||
// sampling draws the source image alone to a canvas (glyph ink never
|
||||
// pollutes it), and a cross-origin image without CORS reports unresolved
|
||||
// instead of guessing.
|
||||
function visualContrastMode(options = {}) {
|
||||
const explicit = typeof options.visualContrast === 'boolean'
|
||||
? options.visualContrast
|
||||
: typeof window.__IMPECCABLE_CONFIG__?.visualContrast === 'boolean'
|
||||
? window.__IMPECCABLE_CONFIG__.visualContrast
|
||||
: null;
|
||||
if (explicit === true) return 'full';
|
||||
if (explicit === false) return false;
|
||||
return 'image-only';
|
||||
}
|
||||
|
||||
function shouldRunVisualContrast(options = {}) {
|
||||
return options.visualContrast === true || window.__IMPECCABLE_CONFIG__?.visualContrast === true;
|
||||
return visualContrastMode(options) !== false;
|
||||
}
|
||||
|
||||
function visualContrastOptions(options = {}) {
|
||||
@@ -1830,6 +1879,7 @@ if (IS_BROWSER) {
|
||||
return [];
|
||||
}
|
||||
const resolvedOptions = visualContrastOptions(options);
|
||||
if (visualContrastMode(options) === 'image-only') resolvedOptions.imageOnly = true;
|
||||
const analyses = await analyzeVisualContrast(resolvedOptions);
|
||||
if (runtime.generation && runtime.generation !== scanGeneration) return analyses;
|
||||
lastVisualContrastAnalyses = analyses;
|
||||
|
||||
@@ -1311,6 +1311,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
|
||||
@@ -1878,6 +1906,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);
|
||||
@@ -2189,6 +2237,7 @@ function scanCssTextForPseudoStripe(rawContent) {
|
||||
id: 'side-tab',
|
||||
snippet: `${selector} — absolute ${thicknessPx}px pseudo-element stripe (${edge}: 0)`,
|
||||
index: selectorStart,
|
||||
selector,
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
@@ -2251,6 +2300,7 @@ function scanCssTextForInsetStripe(content) {
|
||||
findings.push({
|
||||
id: 'side-tab',
|
||||
snippet: `${selector} — inset box-shadow ${ay === 0 ? ax : ay}px stripe (${edge})`,
|
||||
selector,
|
||||
});
|
||||
break;
|
||||
}
|
||||
@@ -2308,7 +2358,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;
|
||||
@@ -2323,7 +2373,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;
|
||||
@@ -2694,8 +2744,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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2706,7 +2758,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;
|
||||
}
|
||||
}
|
||||
@@ -2772,7 +2824,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
|
||||
@@ -2781,7 +2833,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;
|
||||
}
|
||||
}
|
||||
@@ -2814,18 +2866,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 ---
|
||||
@@ -2843,7 +2898,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 ---
|
||||
@@ -2863,8 +2918,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;
|
||||
@@ -2911,6 +2969,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 }:
|
||||
@@ -2951,24 +3036,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
|
||||
@@ -3028,29 +3096,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;
|
||||
@@ -3270,6 +3382,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);
|
||||
@@ -3818,11 +3934,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 };
|
||||
@@ -4667,6 +4785,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;
|
||||
|
||||
@@ -5829,6 +5955,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.
|
||||
@@ -6213,6 +6344,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();
|
||||
@@ -6240,6 +6387,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.
|
||||
@@ -6252,6 +6404,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.
|
||||
@@ -6285,6 +6438,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.
|
||||
@@ -7177,6 +7331,10 @@ if (IS_BROWSER) {
|
||||
|
||||
const reasons = collectVisualContrastReasons(el, style);
|
||||
if (reasons.length === 0) continue;
|
||||
// Image-only mode filters here, inside the cap: gradient/opacity/filter
|
||||
// candidates earlier in DOM order must not consume the budget and
|
||||
// starve the url()-backed texts this mode exists to sample.
|
||||
if (options.imageOnly && !reasons.includes('image background')) continue;
|
||||
|
||||
const textColor = parseRgb(style.color);
|
||||
const fontSize = parseFloat(style.fontSize) || 16;
|
||||
@@ -7669,6 +7827,7 @@ if (IS_BROWSER) {
|
||||
}
|
||||
|
||||
async function analyzeVisualContrast(options = {}) {
|
||||
// imageOnly is enforced inside the collector, before the candidate cap.
|
||||
const candidates = collectVisualContrastCandidates(options);
|
||||
const results = [];
|
||||
const shouldScrollOffscreen = options.scrollOffscreen === true;
|
||||
@@ -7754,9 +7913,16 @@ if (IS_BROWSER) {
|
||||
|
||||
function addBrowserFindings(groupMap, el, findings) {
|
||||
if (!findings || findings.length === 0) return;
|
||||
// Element-scoped waivers: a data-impeccable-ignore ancestor suppresses
|
||||
// matching findings for its whole subtree. Applied at this choke point so
|
||||
// every per-element attribution (checks, layout, occlusion, rhythm)
|
||||
// honors it; page-level findings attributed to <body> pass through
|
||||
// untouched, since body has no ignoring ancestor.
|
||||
const kept = findings.filter(f => !scopedIgnoreActive(el, f.type));
|
||||
if (kept.length === 0) return;
|
||||
const existing = groupMap.get(el);
|
||||
if (existing) existing.push(...findings);
|
||||
else groupMap.set(el, [...findings]);
|
||||
if (existing) existing.push(...kept);
|
||||
else groupMap.set(el, [...kept]);
|
||||
}
|
||||
|
||||
function browserFindingsFromMap(groupMap) {
|
||||
@@ -8114,9 +8280,27 @@ if (IS_BROWSER) {
|
||||
for (const node of docClone.querySelectorAll('[id^="impeccable-live-"]')) {
|
||||
node.remove();
|
||||
}
|
||||
const htmlPatternFindings = checkHtmlPatterns(docClone.outerHTML);
|
||||
if (htmlPatternFindings.length > 0) {
|
||||
const mapped = htmlPatternFindings.map(f => {
|
||||
// Regex findings that name a live selector resolve against the real DOM:
|
||||
// pseudo-element/class segments are stripped (the host element is the
|
||||
// anchor), a selector that matches nothing on this page drops the finding
|
||||
// (the CSS ships here, but the pattern never renders — the live DOM is
|
||||
// ground truth in the browser), and a match under a data-impeccable-ignore
|
||||
// ancestor is waived. Selector-less findings stay page-level.
|
||||
const scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
|
||||
if (!f.selector) return true;
|
||||
const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
|
||||
if (!query || /^[,\s]*$/.test(query)) return true;
|
||||
let matches;
|
||||
try {
|
||||
matches = document.querySelectorAll(query);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
if (matches.length === 0) return false;
|
||||
return [...matches].some(el => !scopedIgnoreActive(el, f.id));
|
||||
});
|
||||
if (scopedHtmlFindings.length > 0) {
|
||||
const mapped = scopedHtmlFindings.map(f => {
|
||||
const item = { type: f.id, detail: f.snippet };
|
||||
if (f.severity) {
|
||||
item.severity = f.severity;
|
||||
@@ -8146,8 +8330,27 @@ if (IS_BROWSER) {
|
||||
};
|
||||
}
|
||||
|
||||
// Visual contrast has three modes. Explicit true runs the full sampled
|
||||
// pass; explicit false disables it entirely (the deterministic-only mode
|
||||
// the test suites use). Unset — the default overlay run — samples ONLY
|
||||
// image-backed text: the one class the analytic walk deliberately skips,
|
||||
// because a url() layer's pixels are unknowable without looking. In-page
|
||||
// sampling draws the source image alone to a canvas (glyph ink never
|
||||
// pollutes it), and a cross-origin image without CORS reports unresolved
|
||||
// instead of guessing.
|
||||
function visualContrastMode(options = {}) {
|
||||
const explicit = typeof options.visualContrast === 'boolean'
|
||||
? options.visualContrast
|
||||
: typeof window.__IMPECCABLE_CONFIG__?.visualContrast === 'boolean'
|
||||
? window.__IMPECCABLE_CONFIG__.visualContrast
|
||||
: null;
|
||||
if (explicit === true) return 'full';
|
||||
if (explicit === false) return false;
|
||||
return 'image-only';
|
||||
}
|
||||
|
||||
function shouldRunVisualContrast(options = {}) {
|
||||
return options.visualContrast === true || window.__IMPECCABLE_CONFIG__?.visualContrast === true;
|
||||
return visualContrastMode(options) !== false;
|
||||
}
|
||||
|
||||
function visualContrastOptions(options = {}) {
|
||||
@@ -8324,6 +8527,7 @@ if (IS_BROWSER) {
|
||||
return [];
|
||||
}
|
||||
const resolvedOptions = visualContrastOptions(options);
|
||||
if (visualContrastMode(options) === 'image-only') resolvedOptions.imageOnly = true;
|
||||
const analyses = await analyzeVisualContrast(resolvedOptions);
|
||||
if (runtime.generation && runtime.generation !== scanGeneration) return analyses;
|
||||
lastVisualContrastAnalyses = analyses;
|
||||
|
||||
@@ -226,6 +226,10 @@ const STATIC_INHERITED_PROPS = new Set([
|
||||
'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight', 'fontVariant',
|
||||
'lineHeight', 'letterSpacing', 'textTransform', 'textAlign', 'hyphens',
|
||||
'webkitHyphens',
|
||||
// visibility inherits in real CSS, and the invisible-at-rest contrast skip
|
||||
// relies on descendants of a hidden container computing as hidden. A child
|
||||
// that declares `visibility: visible` still overrides the inherited value.
|
||||
'visibility',
|
||||
]);
|
||||
|
||||
const STATIC_DEFAULT_STYLE = {
|
||||
@@ -278,6 +282,7 @@ const STATIC_DEFAULT_STYLE = {
|
||||
marginLeft: '0px',
|
||||
position: 'static',
|
||||
visibility: 'visible',
|
||||
opacity: '1',
|
||||
top: 'auto',
|
||||
right: 'auto',
|
||||
bottom: 'auto',
|
||||
@@ -334,6 +339,7 @@ const STATIC_PROP_MAP = {
|
||||
'margin-left': 'marginLeft',
|
||||
'position': 'position',
|
||||
'visibility': 'visibility',
|
||||
'opacity': 'opacity',
|
||||
'top': 'top',
|
||||
'right': 'right',
|
||||
'bottom': 'bottom',
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
checkCreamPalette,
|
||||
checkHtmlPatterns,
|
||||
checkKickerAboveHeadingFromDoc,
|
||||
scopedIgnoreActive,
|
||||
checkNumberedSectionLabelsFromDoc,
|
||||
checkPageLayout,
|
||||
checkPageQualityFromDoc,
|
||||
@@ -138,10 +139,21 @@ async function detectHtml(filePath, options = {}) {
|
||||
domutils,
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
return detectText(html, filePath, options);
|
||||
} catch (err) {
|
||||
if (!globalThis.__impeccableStaticHtmlWarned) {
|
||||
globalThis.__impeccableStaticHtmlWarned = true;
|
||||
|
||||
process.stderr.write(
|
||||
'impeccable detect: DEGRADED - HTML parser modules unavailable ' +
|
||||
'(htmlparser2, css-select, css-tree, domutils).\n' +
|
||||
'Falling back to regex matching. Custom properties, selector matching and computed ' +
|
||||
'contrast are NOT evaluated; findings are an undercount, not a clean bill of health.\n'
|
||||
);
|
||||
}
|
||||
|
||||
return detectText(html, filePath, options);
|
||||
}
|
||||
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
const fileDir = path.dirname(resolvedPath);
|
||||
const root = profileStep(profile, {
|
||||
@@ -171,6 +183,9 @@ async function detectHtml(filePath, options = {}) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
const style = window.getComputedStyle(el);
|
||||
for (const f of runElementCheck(rule.id, () => rule.run(el, tag, style, window, customPropMap))) {
|
||||
// Element-scoped waivers: a data-impeccable-ignore ancestor suppresses
|
||||
// matching findings for its subtree, same as the browser walk.
|
||||
if (scopedIgnoreActive(el, f.id)) continue;
|
||||
findings.push(finding(f.id, filePath, f.snippet));
|
||||
}
|
||||
}
|
||||
@@ -238,6 +253,17 @@ async function detectHtml(filePath, options = {}) {
|
||||
for (const f of runPageCheck('html-patterns', () => checkHtmlPatterns(html, patternCorpora).filter(item =>
|
||||
item.id !== 'bounce-easing' && item.id !== 'layout-transition'
|
||||
))) {
|
||||
// Selector-backed page findings honor scoped waivers here too, matching
|
||||
// the browser pass: resolve the selector and drop the finding when an
|
||||
// ignoring ancestor covers a match. Unlike the browser, an unmatched
|
||||
// selector keeps the finding — static scans see partial documents.
|
||||
if (f.selector) {
|
||||
let matches = null;
|
||||
try {
|
||||
matches = document.querySelectorAll(String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim());
|
||||
} catch { matches = null; }
|
||||
if (matches && matches.length > 0 && [...matches].every(el => scopedIgnoreActive(el, f.id))) continue;
|
||||
}
|
||||
const item = finding(f.id, filePath, f.snippet);
|
||||
// Position-aware severity promotion: checks may attach a per-finding
|
||||
// severity (e.g. a pulsing dot inside a header/nav landmark) that
|
||||
|
||||
+193
-38
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user