data-impeccable-ignore scoped waivers + occlusion and image-backed contrast FP fixes (#559)

* Add data-impeccable-ignore scoped waivers; fix occlusion and image-backed contrast FPs

Three changes that let a page hosting deliberate anti-pattern exhibits
scan clean without losing coverage, prepared with AI assistance (Claude
Code) on maintainer instruction:

- data-impeccable-ignore="rule-a rule-b" (or "*" / bare) on any element
  suppresses matching findings for its whole subtree, in the browser
  overlay, the extension, and the static engine. The DOM twin of the
  line-based impeccable-disable comments (which a live DOM cannot
  apply) and the generalization of data-impeccable-allow-kickers.
  Applied at the addBrowserFindings choke point, at the static element
  walk, and for regex findings that carry a live selector.

- text-occlusion: an occluder whose effective opacity multiplies out to
  ~0 paints nothing. An opacity-0 range scrubber stretched over a
  before/after comparison produced 16 "100% covered by an opaque
  element" findings on one page because elementFromPoint returns it and
  its UA background-color read as opaque paint. Invisible-at-rest
  elements are also no longer probed as victims.

- Analytic contrast now skips what it cannot measure: a url() image
  layer anywhere in the background stack ends the gradient-stops walk
  (dark ink on a bright gold-leaf image measured 2.6:1 against the wash
  composited over the wrong base), and elements that are invisible at
  rest (visibility hidden, effective opacity ~0 — hidden scene decks)
  are skipped by the color checks in both engines. The static cascade
  now tracks opacity to support this.

Covered by a new scoped-ignore fixture (exact rule, star, comma list,
nested depth, wrong-rule control) tested in both engines, a scrubber
pass case in the occlusion fixture, and image-backed / photo-panel /
hidden-scene pass cases in the gradient-ground fixture. Full suite
passes; browser and extension bundles regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* CSS-scan findings carry their enclosing selector; browser pass resolves them

Page-level CSS-text findings (marquee, dark-glow, radial-halo,
repeating-stripes, codex-grid, ai-color-palette, image-hover-transform,
pseudo/inset side-tab stripes) now attach the selector of the rule that
matched, via a best-effort enclosingCssSelector() helper or the
selector already in scope. The browser pass resolves that selector
against the live DOM: pseudo segments are stripped, a selector that
renders nowhere on the page drops the finding (the CSS ships there but
the pattern never paints — the live DOM is ground truth in a browser
scan), and matches under a data-impeccable-ignore ancestor are waived.
Static scans are unchanged: partial documents keep the text-level
findings. Applied with AI assistance (Claude Code).

Covered in the scoped-ignore fixture: a live marquee under a marquee
waiver is suppressed, and dead two-axis grid CSS matching no element is
dropped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Attribute selectors on gradient-text and bounce-easing page emitters too

Same mechanism as the previous commit, extended to the three page-level
motion/text emitters that were still selector-less. Applied with AI
assistance (Claude Code).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* text-overflow: skip SVG content; scrollWidth lies there

Chrome reports arbitrary non-zero scrollWidth/clientWidth on SVG
elements (a <text> gave 78/48 while its rendered length sat inside its
box), so the box-metric delta is noise. SVG clips to its own viewport
anyway. Pass case added to the quality fixture. Applied with AI
assistance (Claude Code).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Overlay samples image-backed text at the pixel level by default

Visual contrast gains a third mode. Explicit true still runs the full
sampled pass, explicit false still disables everything (the mode the
test suites use), and unset — the default overlay run — now samples
ONLY image-backed text: the one class the analytic walk deliberately
skips, because a url() layer's pixels are unknowable without looking.

The cost is bounded and the method is precise: at most a 3x3 grid of
sample points per candidate (degrading to 3 or 1 for small rects), the
source image drawn once to a canvas with only those pixels read, and
glyph ink never pollutes the samples because the image is drawn alone.
A cross-origin image without CORS headers reports unresolved rather
than guessing. Applied with AI assistance (Claude Code).

Covered by a new fixture: white text on a near-white same-origin
data-URI image background flags via sampled pixels under default
options; dark ink on the same image passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Review fixes: root opacity, keyframe steps, static parity, attributed fixtures

Applied with AI assistance (Claude Code), addressing all seven findings
from the automated reviews:

- effectiveOpacityDOM walks through body and html: a page-fade wrapper
  with body/html opacity 0 hides every descendant (Greptile executed a
  Chromium repro of the false positive).
- enclosingCssSelector refuses `from`/`to` keyframe steps, which read
  as never-matching type selectors and got valid findings wrongly
  dropped by the zero-match rule (Bugbot, high). Regression case: an
  overshoot bezier inside a `to` step must survive as page-level.
- The static cascade now inherits visibility, so descendants of a
  hidden container compute as hidden like the browser path; a declared
  visibility:visible still overrides.
- The static engine applies scoped waivers to selector-backed
  html-pattern findings, mirroring the browser — but keeps findings
  whose selector matches nothing, since static scans see partial
  documents.
- The scoped-ignore fixture grows to the mandated matrix: 4 flag cases
  (control, other-rule waiver, sibling waiver, misspelled rule id) and
  5 waived shapes (exact rule, nested depth, star, comma list, self),
  each with a unique border width so every finding attributes to
  exactly one case in both engines' tests.
- The image-backed contrast test pins its cases via the sampled
  finding's candidate text: the white-on-light specimen must flag and
  the dark-ink control must stay clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Review fixes: image-only starvation, selector rejection class, inset stripes

Second review round, applied with AI assistance (Claude Code):

- The image-only filter moves inside the candidate collector, before the
  cap: gradient/opacity/filter candidates earlier in DOM order no longer
  consume the 12-candidate budget and starve the url()-backed texts the
  mode exists to sample (Bugbot, high). The regression fixture packs 14
  gradient decoys ahead of the photo panels, and the test now drives the
  overlay entry (impeccableDetectAsync, default options) rather than
  detectUrl's Node-side full fallback, which is where the image-only
  mode actually lives.
- enclosingCssSelector no longer rejects the child combinator or quoted
  attribute selectors; only braces and angle brackets disqualify.
- The inset box-shadow side-tab scanner attaches its selector like the
  pseudo-element scanner does, so those findings waive and dead-drop
  the same way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-08-10 16:31:46 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 8e62ab47ba
commit aee5ddd10c
12 changed files with 642 additions and 41 deletions
+56 -6
View File
@@ -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;
+175 -20
View File
@@ -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
+120 -14
View File
@@ -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,
+56 -1
View File
@@ -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}`);
@@ -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
+29
View File
@@ -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;
}
</style>
</head>
<body>
@@ -93,5 +119,8 @@
<p class="flag-muted-nested">Muted ink reached through a transparent wrapper still resolves to the body gradient and still fails against its stops.</p>
</div>
<div class="pass-hex-gradient">A non-body gradient with legacy hex stops keeps working through the original rgb/hex parser, and light text on it passes.</div>
<div class="pass-image-backed">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.</div>
<div class="pass-photo-panel">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.</div>
<div class="pass-hidden-scene"><p>Muted ink inside an opacity-zero scene deck. Nobody sees this state, so the contrast checks must not measure it.</p></div>
</body>
</html>
+51
View File
@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Fixture: image-backed text sampled at the pixel level by default</title>
<style>
body { margin: 0; padding: 40px; background: #17150f; font-family: Arial, sans-serif; }
/* A same-origin (data-URI) near-white image background. The analytic walk
cannot know its pixels, so the default overlay pass must sample the image
itself: white text on it fails, dark ink on it passes. */
.photo-panel {
width: 420px;
padding: 24px;
margin: 0 0 24px;
background: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100'><rect width='100' height='100' fill='%23f5f2ea'/></svg>") center / cover no-repeat;
}
.flag-white-on-light { color: #fdfdfd; font-size: 16px; line-height: 1.6; margin: 0; }
.pass-ink-on-light { color: #262421; font-size: 16px; line-height: 1.6; margin: 0; }
/* Fourteen gradient-backed decoys sit BEFORE the photo panels in DOM
order. They are visual-contrast candidates (gradient background) but not
image-backed; with the default 12-candidate cap they would starve the
image-only pass to zero unless the mode filters inside the collector.
Their ink is dark on a light gradient, so no analytic finding fires. */
.decoy { display: inline-block; padding: 4px 8px; margin: 0 4px 6px 0; background: linear-gradient(180deg, #f2efe6, #e8e4d8); color: #24211c; font-size: 12px; }
</style>
</head>
<body>
<div><span class="decoy-row"></span>
<div class="decoy">Decoy 1</div>
<div class="decoy">Decoy 2</div>
<div class="decoy">Decoy 3</div>
<div class="decoy">Decoy 4</div>
<div class="decoy">Decoy 5</div>
<div class="decoy">Decoy 6</div>
<div class="decoy">Decoy 7</div>
<div class="decoy">Decoy 8</div>
<div class="decoy">Decoy 9</div>
<div class="decoy">Decoy 10</div>
<div class="decoy">Decoy 11</div>
<div class="decoy">Decoy 12</div>
<div class="decoy">Decoy 13</div>
<div class="decoy">Decoy 14</div>
</div>
<div class="photo-panel">
<p class="flag-white-on-light">White text on a near-white image background is unreadable, and only pixel sampling can prove it.</p>
</div>
<div class="photo-panel">
<p class="pass-ink-on-light">Dark ink on the same image background reads fine and must not flag.</p>
</div>
</body>
</html>
+6
View File
@@ -242,5 +242,11 @@
</div>
</div>
<script src="/js/detect-antipatterns-browser.js"></script>
<!-- SVG text: Chrome reports fake scrollWidth/clientWidth on SVG content;
the text-overflow rule must skip it (pass). -->
<svg class="svg-metrics-pass" viewBox="0 0 430 60" width="430" height="60" role="img" aria-label="diagram">
<rect x="6" y="10" width="96" height="40" rx="3" fill="none" stroke="#888"/>
<text x="54" y="35" text-anchor="middle" font-size="13">impeccable</text>
</svg>
</body>
</html>
+100
View File
@@ -0,0 +1,100 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Fixture: data-impeccable-ignore scoped waivers</title>
<style>
body { margin: 0; padding: 40px; background: #faf8f4; font-family: Arial, sans-serif; color: #262421; }
/* Every case carries a UNIQUE border-left width so each finding's snippet
(`border-left: Npx`) attributes to exactly one case. Flag widths: 5, 6,
8, 12. Pass widths: 4, 7, 9, 10, 11. */
.tabbed {
width: 420px;
padding: 16px 20px;
margin: 0 0 24px;
background: #ffffff;
border: 1px solid #ddd8d0;
border-left: 6px solid #b8422e;
}
.w4 { border-left-width: 4px; }
.w5 { border-left-width: 5px; }
.w7 { border-left-width: 7px; }
.w8 { border-left-width: 8px; }
.w9 { border-left-width: 9px; }
.w10 { border-left-width: 10px; }
.w11 { border-left-width: 11px; }
.w12 { border-left-width: 12px; }
/* Marquee specimen: keyframes + an infinite loop track. The section that
hosts it carries a marquee waiver; the CSS-scan finding must resolve the
selector to the live element and honor the ancestor attribute. */
@keyframes ticker-loop { from { transform: translateX(0); } to { transform: translateX(-50%); } }
.quote-track { display: flex; width: max-content; animation: ticker-loop 80s linear infinite; }
.quote-track span { padding: 8px 16px; }
/* Dead grid CSS: this two-axis hairline grid selector matches nothing on
this page, so the browser pass must drop the codex-grid finding — the
pattern never renders here. */
.never-rendered-grid {
background:
linear-gradient(90deg, rgba(200,180,120,0.08) 1px, transparent 1px),
linear-gradient(180deg, rgba(200,180,120,0.08) 1px, transparent 1px);
background-size: 24px 24px;
}
/* Overshoot bezier inside a keyframe STEP: enclosingCssSelector must refuse
`to` as a selector (it would match nothing and wrongly drop the finding),
so this bounce-easing finding stays page-level and is retained. */
@keyframes springy {
from { transform: none; }
to { transform: translateY(-4px); animation-timing-function: cubic-bezier(0.3, 1.8, 0.6, 1); }
}
</style>
</head>
<body>
<!-- FLAG 6px — control: no waiver anywhere above. -->
<article class="tabbed control-flags">The control case has a 6px side-tab accent border and no waiver, so the side-tab rule must flag it.</article>
<!-- FLAG 8px — waiver names a different rule, so side-tab still applies. -->
<section data-impeccable-ignore="pulsing-dot">
<article class="tabbed w8 flags-other-rule-waived">The waiver names pulsing-dot, not side-tab, so this 8px case must still flag.</article>
</section>
<!-- FLAG 12px — waiver sits on a SIBLING, not an ancestor: no effect. -->
<div data-impeccable-ignore="side-tab"></div>
<article class="tabbed w12 flags-sibling-waiver">A waiver on a preceding sibling is not an ancestor; this 12px case must still flag.</article>
<!-- FLAG 5px — waiver value misspells the rule id: exact matching only. -->
<section data-impeccable-ignore="side-tabs">
<article class="tabbed w5 flags-misspelled-rule">"side-tabs" is not a rule id; the 5px case must still flag.</article>
</section>
<!-- PASS 7px — exact-rule waiver on the container: the subtree is exempt. -->
<section data-impeccable-ignore="side-tab">
<article class="tabbed w7 pass-exact-rule">Inside a container waived for exactly side-tab; must not flag.</article>
</section>
<!-- PASS 9px — two levels below the waiving container. -->
<section data-impeccable-ignore="side-tab">
<div>
<article class="tabbed w9 pass-nested-depth">Two levels below the waiving container; the ancestor walk must still find it.</article>
</div>
</section>
<!-- PASS 10px — star waiver: every rule is exempt in the subtree. -->
<section data-impeccable-ignore="*">
<article class="tabbed w10 pass-star">Inside a star-waived container; must not flag.</article>
</section>
<!-- PASS 11px — comma list including side-tab. -->
<section data-impeccable-ignore="pulsing-dot, side-tab">
<article class="tabbed w11 pass-rule-list">side-tab appears in the comma list; must not flag.</article>
</section>
<!-- PASS 4px — waiver on the flagged element itself (self counts). -->
<article class="tabbed w4 pass-self-waiver" data-impeccable-ignore="side-tab">The waiver sits on the element itself; must not flag.</article>
<!-- Live marquee inside a rule-scoped waiver: the CSS-scan marquee finding
resolves .quote-track to this element and is waived by the ancestor. -->
<section data-impeccable-ignore="marquee">
<div class="quote-track"><span>One voice.</span><span>Another voice.</span><span>One voice.</span><span>Another voice.</span></div>
</section>
</body>
</html>
+10
View File
@@ -39,6 +39,12 @@
/* ── PASS: fixed status bar over content at the fold ── */
.pass-under { margin-top: 20px; font-size: 16px; }
/* Invisible interaction layer: an opacity-0 scrubber input stretched over
text, like a before/after comparison handle. It paints nothing, so it must
not read as an occluder despite its UA background-color. */
.pass-scrubber-wrap { position: relative; margin-top: 30px; height: 80px; }
.pass-scrubber-text { position: absolute; top: 20px; left: 10px; font-size: 20px; width: 320px; }
.pass-scrubber-input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; z-index: 5; margin: 0; }
.pass-fixedbar { position: fixed; left: 0; right: 0; bottom: 0; height: 40px; background: #b22;
color: #fff; display: flex; align-items: center; padding: 0 16px; }
</style>
@@ -77,5 +83,9 @@
<p class="pass-under">Every span of every trace, kept on disk, no sampling ever applied here.</p>
</section>
<div class="pass-fixedbar">8/8 services up &middot; p99 1.20s &middot; retention infinite</div>
<div class="pass-scrubber-wrap">
<p class="pass-scrubber-text">Drag anywhere to compare versions</p>
<input class="pass-scrubber-input" type="range" min="0" max="100" value="50">
</div>
</body>
</html>