diff --git a/cli/engine/browser/injected/index.mjs b/cli/engine/browser/injected/index.mjs
index dfc725a8e..4cf648906 100644
--- a/cli/engine/browser/injected/index.mjs
+++ b/cli/engine/browser/injected/index.mjs
@@ -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
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;
diff --git a/cli/engine/detect-antipatterns-browser.js b/cli/engine/detect-antipatterns-browser.js
index a3422ff6c..ce5e66f0e 100644
--- a/cli/engine/detect-antipatterns-browser.js
+++ b/cli/engine/detect-antipatterns-browser.js
@@ -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 (/ element' });
+ findings.push({ id: 'marquee', snippet: ' 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 . 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 = / ]*\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 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 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 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;
diff --git a/cli/engine/engines/static-html/css-cascade.mjs b/cli/engine/engines/static-html/css-cascade.mjs
index 60e10342c..caf4aff44 100644
--- a/cli/engine/engines/static-html/css-cascade.mjs
+++ b/cli/engine/engines/static-html/css-cascade.mjs
@@ -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',
diff --git a/cli/engine/engines/static-html/detect-html.mjs b/cli/engine/engines/static-html/detect-html.mjs
index e08589440..9e7a429a2 100644
--- a/cli/engine/engines/static-html/detect-html.mjs
+++ b/cli/engine/engines/static-html/detect-html.mjs
@@ -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
diff --git a/cli/engine/rules/checks.mjs b/cli/engine/rules/checks.mjs
index 57341b017..045065c66 100644
--- a/cli/engine/rules/checks.mjs
+++ b/cli/engine/rules/checks.mjs
@@ -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 (/ element' });
+ findings.push({ id: 'marquee', snippet: ' 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 . 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 = / ]*\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 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 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,
diff --git a/tests/detect-antipatterns-browser.test.mjs b/tests/detect-antipatterns-browser.test.mjs
index 40f370231..06c5c84ee 100644
--- a/tests/detect-antipatterns-browser.test.mjs
+++ b/tests/detect-antipatterns-browser.test.mjs
@@ -20,6 +20,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createBrowserDetector, detectUrl, normalizeDesignSystem } from '../cli/engine/detect-antipatterns.mjs';
+import { launchBrowser } from '../cli/engine/engines/browser/detect-url.mjs';
import { filterDetectionFindings } from '../cli/lib/impeccable-config.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -112,6 +113,60 @@ describe('detectUrl — browser-only fixtures', () => {
}
});
+ it('image-backed text: the overlay default pass pixel-samples the image itself', async () => {
+ // Drives the OVERLAY entry (impeccableDetectAsync with default options),
+ // not detectUrl's Node-side full fallback — the image-only default mode
+ // lives in the injected bundle. Fourteen gradient decoys precede the
+ // panels: the image-only filter must apply inside the candidate cap, or
+ // they starve the pass and nothing gets sampled. The sampled finding
+ // carries the candidate's text, so the white-on-light specimen must be
+ // the one that flags and the dark-ink control must stay clean.
+ const puppeteer = await import('puppeteer');
+ const browser = await launchBrowser(puppeteer, { headless: true, args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [] });
+ try {
+ const page = await browser.newPage();
+ await page.setViewport({ width: 1280, height: 800 });
+ await page.goto(`${baseUrl}/fixtures/antipatterns/image-backed-contrast.html`, { waitUntil: 'load' });
+ await page.addScriptTag({ path: path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js') });
+ const groups = await page.evaluate(() => window.impeccableDetectAsync());
+ const contrast = groups.flatMap(g => (g.findings || []).filter(f => f.type === 'low-contrast').map(f => f.detail || f.snippet || ''));
+ const snippets = contrast.join('\n');
+ assert.match(snippets, /browser contrast/, `expected a sampled (not analytic) finding:\n${snippets}`);
+ assert.match(snippets, /White text on a near-white/, `flag case missing:\n${snippets}`);
+ assert.doesNotMatch(snippets, /Dark ink/, `pass case must not flag:\n${snippets}`);
+ assert.equal(contrast.length, 1, `expected exactly the white-on-light case, got ${contrast.length}:\n${snippets}`);
+ } finally {
+ await browser.close().catch(() => {});
+ }
+ });
+
+ it('scoped-ignore: data-impeccable-ignore waives its subtree in the browser walk', async () => {
+ // Browser twin of the static scoped-ignore test: same fixture, same
+ // expectation — only the control and the other-rule-waived case flag.
+ const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/scoped-ignore.html`, { visualContrast: false });
+ const sideTabs = f.filter(r => r.antipattern === 'side-tab');
+ const snippets = sideTabs.map(r => r.snippet || '').join('\n');
+ // Every case carries a unique border width, so each finding attributes to
+ // exactly one case: control 6, other-rule 8, sibling-waiver 12,
+ // misspelled-rule 5 must flag; the five waived shapes must not.
+ for (const w of ['5px', '6px', '8px', '12px']) {
+ assert.match(snippets, new RegExp(`border-left: ${w.replace('px', '')}px`), `flag case ${w} missing:\n${snippets}`);
+ }
+ for (const w of ['4px', '7px', '9px', '10px', '11px']) {
+ assert.doesNotMatch(snippets, new RegExp(`border-left: ${w.replace('px', '')}px`), `waived case ${w} must not flag:\n${snippets}`);
+ }
+ assert.equal(sideTabs.length, 4, `expected exactly the 4 flag cases, got ${sideTabs.length}:\n${snippets}`);
+ // CSS-scan findings resolve their selectors against the live DOM: the
+ // marquee track sits under a marquee waiver (suppressed), and the grid
+ // rule's selector renders nowhere on this page (dropped).
+ assert.equal(f.filter(r => r.antipattern === 'marquee').length, 0, 'waived marquee must not flag');
+ assert.equal(f.filter(r => r.antipattern === 'codex-grid-background').length, 0, 'dead grid CSS must not flag in the browser');
+ // The overshoot bezier sits inside a keyframe `to` step: the selector
+ // extractor must refuse `to` (matches nothing) so the finding is RETAINED
+ // as page-level rather than wrongly dropped by the zero-match rule.
+ assert.ok(f.some(r => r.antipattern === 'bounce-easing'), 'keyframe-step bezier finding must survive selector extraction');
+ });
+
it('low-contrast: a gradient body ground with oklch stops is measured, never assumed white', async () => {
// The impeccable.style FP class: `background: linear-gradient(oklch(7%…),
// oklch(4%…))` on body leaves backgroundColor transparent, and the old
@@ -400,7 +455,7 @@ describe('detectUrl — browser-only fixtures', () => {
assert.match(snippets, /flag-box-text/, `opaque box painted over text should flag: ${snippets}`);
assert.match(snippets, /flag-leak/, `inline element with leaked opaque padding should flag: ${snippets}`);
assert.match(snippets, /flag-headline/, `headline overhanging an opaque card should flag: ${snippets}`);
- for (const cls of ['pass-title', 'pass-eyebrow', 'pass-hero', 'cap', 'pass-under', 'pass-fixedbar']) {
+ for (const cls of ['pass-title', 'pass-eyebrow', 'pass-hero', 'cap', 'pass-under', 'pass-fixedbar', 'pass-scrubber']) {
assert.doesNotMatch(snippets, new RegExp(cls), `".${cls}" must not flag: ${snippets}`);
}
assert.equal(hits.length, 3, `expected exactly 3 text-occlusion findings, got ${hits.length}: ${snippets}`);
diff --git a/tests/detect-antipatterns-fixtures.test.mjs b/tests/detect-antipatterns-fixtures.test.mjs
index 0292d8166..9ead61ee3 100644
--- a/tests/detect-antipatterns-fixtures.test.mjs
+++ b/tests/detect-antipatterns-fixtures.test.mjs
@@ -239,6 +239,24 @@ describe('detectHtml — static HTML/CSS fixtures', () => {
);
});
+ it('scoped-ignore: data-impeccable-ignore waives its subtree per rule, star, and list', async () => {
+ // Six identical side-tab violations; four sit under waiving containers
+ // (exact rule, star, comma list, and one two levels deep) and must not
+ // flag. The control and the container waived for a DIFFERENT rule must.
+ const f = await detectHtml(path.join(FIXTURES, 'scoped-ignore.html'));
+ const sideTabs = f.filter(r => r.antipattern === 'side-tab');
+ const snippets = sideTabs.map(r => r.snippet || '').join('\n');
+ // Width-attributed cases: control 6, other-rule 8, sibling-waiver 12,
+ // misspelled-rule 5 flag; the five waived shapes must not.
+ for (const w of [5, 6, 8, 12]) {
+ assert.match(snippets, new RegExp(`border-left: ${w}px`), `flag case ${w}px missing:\n${snippets}`);
+ }
+ for (const w of [4, 7, 9, 10, 11]) {
+ assert.doesNotMatch(snippets, new RegExp(`border-left: ${w}px`), `waived case ${w}px must not flag:\n${snippets}`);
+ }
+ assert.equal(sideTabs.length, 4, `expected exactly the 4 flag cases, got ${sideTabs.length}:\n${snippets}`);
+ });
+
it('dark-gradient-ground: a gradient body ground is measured against its stops, never assumed white', async () => {
// Static-engine twin of the browser test: the page ground is a dark oklch
// gradient set via `background:` shorthand on body (backgroundColor stays
diff --git a/tests/fixtures/antipatterns/dark-gradient-ground.html b/tests/fixtures/antipatterns/dark-gradient-ground.html
index 28296bae0..5bb8c48d1 100644
--- a/tests/fixtures/antipatterns/dark-gradient-ground.html
+++ b/tests/fixtures/antipatterns/dark-gradient-ground.html
@@ -78,6 +78,32 @@
font-size: 15px;
line-height: 1.6;
}
+ .pass-image-backed {
+ width: 460px;
+ padding: 18px 22px;
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0.2)), url("/fixtures/antipatterns/no-such-texture.png") center / cover;
+ color: oklch(15% 0.01 95);
+ font-size: 15px;
+ line-height: 1.6;
+ }
+ .pass-photo-panel {
+ width: 460px;
+ padding: 18px 22px;
+ background-image: url("/fixtures/antipatterns/no-such-photo.jpg");
+ color: oklch(20% 0 0);
+ font-size: 15px;
+ line-height: 1.6;
+ }
+ .pass-hidden-scene {
+ opacity: 0;
+ width: 560px;
+ }
+ .pass-hidden-scene p {
+ color: oklch(30% 0 0);
+ font-size: 16px;
+ line-height: 1.7;
+ margin: 0;
+ }
@@ -93,5 +119,8 @@
Muted ink reached through a transparent wrapper still resolves to the body gradient and still fails against its stops.
A non-body gradient with legacy hex stops keeps working through the original rgb/hex parser, and light text on it passes.
+ Dark ink on a translucent wash over a texture image. The image pixels are unknowable to the analytic walk, so contrast must skip rather than composite the wash over the wrong base.
+ Dark ink on a photo-backed panel sitting over the dark body gradient. Walking past the photo to the gradient stops would wrongly flag this; the unmeasurable image layer must end the walk.
+ Muted ink inside an opacity-zero scene deck. Nobody sees this state, so the contrast checks must not measure it.
diff --git a/tests/fixtures/antipatterns/image-backed-contrast.html b/tests/fixtures/antipatterns/image-backed-contrast.html
new file mode 100644
index 000000000..ca3623d42
--- /dev/null
+++ b/tests/fixtures/antipatterns/image-backed-contrast.html
@@ -0,0 +1,51 @@
+
+
+
+
+Fixture: image-backed text sampled at the pixel level by default
+
+
+
+
+
Decoy 1
+
Decoy 2
+
Decoy 3
+
Decoy 4
+
Decoy 5
+
Decoy 6
+
Decoy 7
+
Decoy 8
+
Decoy 9
+
Decoy 10
+
Decoy 11
+
Decoy 12
+
Decoy 13
+
Decoy 14
+
+
+
White text on a near-white image background is unreadable, and only pixel sampling can prove it.
+
+
+
Dark ink on the same image background reads fine and must not flag.
+
+
+
diff --git a/tests/fixtures/antipatterns/quality.html b/tests/fixtures/antipatterns/quality.html
index 6b470fb38..f9cd827c7 100644
--- a/tests/fixtures/antipatterns/quality.html
+++ b/tests/fixtures/antipatterns/quality.html
@@ -242,5 +242,11 @@
+
+
+
+ impeccable
+