From aee5ddd10c5afbb7a0cc4bf52689f1949987660a Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Mon, 10 Aug 2026 19:31:46 -0400 Subject: [PATCH] data-impeccable-ignore scoped waivers + occlusion and image-backed contrast FP fixes (#559) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 * text-overflow: skip SVG content; scrollWidth lies there Chrome reports arbitrary non-zero scrollWidth/clientWidth on SVG elements (a 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 * 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 * 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 * 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 --------- Co-authored-by: Claude Fable 5 --- cli/engine/browser/injected/index.mjs | 62 +++++- cli/engine/detect-antipatterns-browser.js | 195 ++++++++++++++++-- .../engines/static-html/css-cascade.mjs | 6 + .../engines/static-html/detect-html.mjs | 15 ++ cli/engine/rules/checks.mjs | 134 ++++++++++-- tests/detect-antipatterns-browser.test.mjs | 57 ++++- tests/detect-antipatterns-fixtures.test.mjs | 18 ++ .../antipatterns/dark-gradient-ground.html | 29 +++ .../antipatterns/image-backed-contrast.html | 51 +++++ tests/fixtures/antipatterns/quality.html | 6 + .../fixtures/antipatterns/scoped-ignore.html | 100 +++++++++ .../fixtures/antipatterns/text-occlusion.html | 10 + 12 files changed, 642 insertions(+), 41 deletions(-) create mode 100644 tests/fixtures/antipatterns/image-backed-contrast.html create mode 100644 tests/fixtures/antipatterns/scoped-ignore.html 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 + diff --git a/tests/fixtures/antipatterns/scoped-ignore.html b/tests/fixtures/antipatterns/scoped-ignore.html new file mode 100644 index 000000000..20c556ce3 --- /dev/null +++ b/tests/fixtures/antipatterns/scoped-ignore.html @@ -0,0 +1,100 @@ + + + + +Fixture: data-impeccable-ignore scoped waivers + + + + +
The control case has a 6px side-tab accent border and no waiver, so the side-tab rule must flag it.
+ + +
+
The waiver names pulsing-dot, not side-tab, so this 8px case must still flag.
+
+ + +
+
A waiver on a preceding sibling is not an ancestor; this 12px case must still flag.
+ + +
+
"side-tabs" is not a rule id; the 5px case must still flag.
+
+ + +
+
Inside a container waived for exactly side-tab; must not flag.
+
+ + +
+
+
Two levels below the waiving container; the ancestor walk must still find it.
+
+
+ + +
+
Inside a star-waived container; must not flag.
+
+ + +
+
side-tab appears in the comma list; must not flag.
+
+ + +
The waiver sits on the element itself; must not flag.
+ + +
+
One voice.Another voice.One voice.Another voice.
+
+ + diff --git a/tests/fixtures/antipatterns/text-occlusion.html b/tests/fixtures/antipatterns/text-occlusion.html index 15e4d1b8f..ba9139b7c 100644 --- a/tests/fixtures/antipatterns/text-occlusion.html +++ b/tests/fixtures/antipatterns/text-occlusion.html @@ -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; } @@ -77,5 +83,9 @@

Every span of every trace, kept on disk, no sampling ever applied here.

8/8 services up · p99 1.20s · retention infinite
+
+

Drag anywhere to compare versions

+ +