Sync generated provider output

This commit is contained in:
github-actions[bot]
2026-08-10 23:32:16 +00:00
parent aee5ddd10c
commit 251135e190
75 changed files with 5580 additions and 600 deletions
@@ -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;
@@ -861,6 +861,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
@@ -1428,6 +1456,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);
@@ -1739,6 +1787,7 @@ function scanCssTextForPseudoStripe(rawContent) {
id: 'side-tab',
snippet: `${selector} — absolute ${thicknessPx}px pseudo-element stripe (${edge}: 0)`,
index: selectorStart,
selector,
});
}
return findings;
@@ -1801,6 +1850,7 @@ function scanCssTextForInsetStripe(content) {
findings.push({
id: 'side-tab',
snippet: `${selector} — inset box-shadow ${ay === 0 ? ax : ay}px stripe (${edge})`,
selector,
});
break;
}
@@ -1858,7 +1908,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;
@@ -1873,7 +1923,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;
@@ -2244,8 +2294,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 });
}
}
@@ -2256,7 +2308,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;
}
}
@@ -2322,7 +2374,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
@@ -2331,7 +2383,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;
}
}
@@ -2364,18 +2416,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 ---
@@ -2393,7 +2448,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 ---
@@ -2413,8 +2468,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;
@@ -2585,6 +2643,13 @@ function resolveGradientStops(el, win, customPropMap) {
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 = parseGradientColorsModern(bgImage);
@@ -2835,6 +2900,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;
let effectiveBg = resolveBackground(el);
@@ -4495,6 +4564,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;
@@ -5653,6 +5730,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.
@@ -6037,6 +6119,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();
@@ -6064,6 +6162,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.
@@ -6076,6 +6179,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.
@@ -6109,6 +6213,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.
@@ -7001,6 +7106,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;
@@ -7493,6 +7602,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;
@@ -7578,9 +7688,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) {
@@ -7938,9 +8055,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;
@@ -7970,8 +8105,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 = {}) {
@@ -8148,6 +8302,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,
@@ -182,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));
}
}
@@ -249,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
@@ -70,6 +70,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
@@ -637,6 +665,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);
@@ -948,6 +996,7 @@ function scanCssTextForPseudoStripe(rawContent) {
id: 'side-tab',
snippet: `${selector} — absolute ${thicknessPx}px pseudo-element stripe (${edge}: 0)`,
index: selectorStart,
selector,
});
}
return findings;
@@ -1010,6 +1059,7 @@ function scanCssTextForInsetStripe(content) {
findings.push({
id: 'side-tab',
snippet: `${selector} — inset box-shadow ${ay === 0 ? ax : ay}px stripe (${edge})`,
selector,
});
break;
}
@@ -1067,7 +1117,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;
@@ -1082,7 +1132,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;
@@ -1453,8 +1503,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 });
}
}
@@ -1465,7 +1517,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;
}
}
@@ -1531,7 +1583,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
@@ -1540,7 +1592,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;
}
}
@@ -1573,18 +1625,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 ---
@@ -1602,7 +1657,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 ---
@@ -1622,8 +1677,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;
@@ -1794,6 +1852,13 @@ function resolveGradientStops(el, win, customPropMap) {
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 = parseGradientColorsModern(bgImage);
@@ -2044,6 +2109,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;
let effectiveBg = resolveBackground(el);
@@ -3704,6 +3773,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;
@@ -4862,6 +4939,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.
@@ -5246,6 +5328,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();
@@ -5273,6 +5371,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.
@@ -5285,6 +5388,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.
@@ -5318,6 +5422,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.
@@ -5528,6 +5633,7 @@ export {
CSS_NAMED_COLORS,
checkBorders,
isEmojiOnlyText,
scopedIgnoreActive,
checkColors,
checkHoverContrast,
checkElementHoverContrast,