diff --git a/.agents/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.agents/skills/impeccable/scripts/detector/browser/injected/index.mjs index dfc725a8e..4cf648906 100644 --- a/.agents/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.agents/skills/impeccable/scripts/detector/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/.agents/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.agents/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index a3422ff6c..ce5e66f0e 100644 --- a/.agents/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.agents/skills/impeccable/scripts/detector/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/.agents/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.agents/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index 60e10342c..caf4aff44 100644 --- a/.agents/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.agents/skills/impeccable/scripts/detector/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/.agents/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.agents/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index e08589440..9e7a429a2 100644 --- a/.agents/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.agents/skills/impeccable/scripts/detector/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/.agents/skills/impeccable/scripts/detector/rules/checks.mjs b/.agents/skills/impeccable/scripts/detector/rules/checks.mjs index 57341b017..045065c66 100644 --- a/.agents/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.agents/skills/impeccable/scripts/detector/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/.claude/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.claude/skills/impeccable/scripts/detector/browser/injected/index.mjs index dfc725a8e..4cf648906 100644 --- a/.claude/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.claude/skills/impeccable/scripts/detector/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/.claude/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.claude/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index a3422ff6c..ce5e66f0e 100644 --- a/.claude/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.claude/skills/impeccable/scripts/detector/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/.claude/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.claude/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index 60e10342c..caf4aff44 100644 --- a/.claude/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.claude/skills/impeccable/scripts/detector/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/.claude/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.claude/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index e08589440..9e7a429a2 100644 --- a/.claude/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.claude/skills/impeccable/scripts/detector/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/.claude/skills/impeccable/scripts/detector/rules/checks.mjs b/.claude/skills/impeccable/scripts/detector/rules/checks.mjs index 57341b017..045065c66 100644 --- a/.claude/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.claude/skills/impeccable/scripts/detector/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/.cursor/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.cursor/skills/impeccable/scripts/detector/browser/injected/index.mjs index dfc725a8e..4cf648906 100644 --- a/.cursor/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.cursor/skills/impeccable/scripts/detector/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/.cursor/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.cursor/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index a3422ff6c..ce5e66f0e 100644 --- a/.cursor/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.cursor/skills/impeccable/scripts/detector/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/.cursor/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.cursor/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index 60e10342c..caf4aff44 100644 --- a/.cursor/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.cursor/skills/impeccable/scripts/detector/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/.cursor/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.cursor/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index e08589440..9e7a429a2 100644 --- a/.cursor/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.cursor/skills/impeccable/scripts/detector/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/.cursor/skills/impeccable/scripts/detector/rules/checks.mjs b/.cursor/skills/impeccable/scripts/detector/rules/checks.mjs index 57341b017..045065c66 100644 --- a/.cursor/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.cursor/skills/impeccable/scripts/detector/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/.gemini/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.gemini/skills/impeccable/scripts/detector/browser/injected/index.mjs index dfc725a8e..4cf648906 100644 --- a/.gemini/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.gemini/skills/impeccable/scripts/detector/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/.gemini/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.gemini/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index a3422ff6c..ce5e66f0e 100644 --- a/.gemini/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.gemini/skills/impeccable/scripts/detector/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/.gemini/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.gemini/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index 60e10342c..caf4aff44 100644 --- a/.gemini/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.gemini/skills/impeccable/scripts/detector/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/.gemini/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.gemini/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index e08589440..9e7a429a2 100644 --- a/.gemini/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.gemini/skills/impeccable/scripts/detector/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/.gemini/skills/impeccable/scripts/detector/rules/checks.mjs b/.gemini/skills/impeccable/scripts/detector/rules/checks.mjs index 57341b017..045065c66 100644 --- a/.gemini/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.gemini/skills/impeccable/scripts/detector/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/.github/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.github/skills/impeccable/scripts/detector/browser/injected/index.mjs index dfc725a8e..4cf648906 100644 --- a/.github/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.github/skills/impeccable/scripts/detector/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/.github/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.github/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index a3422ff6c..ce5e66f0e 100644 --- a/.github/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.github/skills/impeccable/scripts/detector/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/.github/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.github/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index 60e10342c..caf4aff44 100644 --- a/.github/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.github/skills/impeccable/scripts/detector/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/.github/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.github/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index e08589440..9e7a429a2 100644 --- a/.github/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.github/skills/impeccable/scripts/detector/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/.github/skills/impeccable/scripts/detector/rules/checks.mjs b/.github/skills/impeccable/scripts/detector/rules/checks.mjs index 57341b017..045065c66 100644 --- a/.github/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.github/skills/impeccable/scripts/detector/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/.grok/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.grok/skills/impeccable/scripts/detector/browser/injected/index.mjs index dfc725a8e..4cf648906 100644 --- a/.grok/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.grok/skills/impeccable/scripts/detector/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/.grok/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.grok/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index a3422ff6c..ce5e66f0e 100644 --- a/.grok/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.grok/skills/impeccable/scripts/detector/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/.grok/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.grok/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index 60e10342c..caf4aff44 100644 --- a/.grok/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.grok/skills/impeccable/scripts/detector/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/.grok/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.grok/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index e08589440..9e7a429a2 100644 --- a/.grok/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.grok/skills/impeccable/scripts/detector/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/.grok/skills/impeccable/scripts/detector/rules/checks.mjs b/.grok/skills/impeccable/scripts/detector/rules/checks.mjs index 57341b017..045065c66 100644 --- a/.grok/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.grok/skills/impeccable/scripts/detector/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/.kiro/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.kiro/skills/impeccable/scripts/detector/browser/injected/index.mjs index dfc725a8e..4cf648906 100644 --- a/.kiro/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.kiro/skills/impeccable/scripts/detector/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/.kiro/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.kiro/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index a3422ff6c..ce5e66f0e 100644 --- a/.kiro/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.kiro/skills/impeccable/scripts/detector/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/.kiro/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.kiro/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index 60e10342c..caf4aff44 100644 --- a/.kiro/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.kiro/skills/impeccable/scripts/detector/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/.kiro/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.kiro/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index e08589440..9e7a429a2 100644 --- a/.kiro/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.kiro/skills/impeccable/scripts/detector/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/.kiro/skills/impeccable/scripts/detector/rules/checks.mjs b/.kiro/skills/impeccable/scripts/detector/rules/checks.mjs index 57341b017..045065c66 100644 --- a/.kiro/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.kiro/skills/impeccable/scripts/detector/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/.opencode/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.opencode/skills/impeccable/scripts/detector/browser/injected/index.mjs index dfc725a8e..4cf648906 100644 --- a/.opencode/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.opencode/skills/impeccable/scripts/detector/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/.opencode/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.opencode/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index a3422ff6c..ce5e66f0e 100644 --- a/.opencode/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.opencode/skills/impeccable/scripts/detector/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/.opencode/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.opencode/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index 60e10342c..caf4aff44 100644 --- a/.opencode/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.opencode/skills/impeccable/scripts/detector/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/.opencode/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.opencode/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index e08589440..9e7a429a2 100644 --- a/.opencode/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.opencode/skills/impeccable/scripts/detector/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/.opencode/skills/impeccable/scripts/detector/rules/checks.mjs b/.opencode/skills/impeccable/scripts/detector/rules/checks.mjs index 57341b017..045065c66 100644 --- a/.opencode/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.opencode/skills/impeccable/scripts/detector/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/.pi/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.pi/skills/impeccable/scripts/detector/browser/injected/index.mjs index dfc725a8e..4cf648906 100644 --- a/.pi/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.pi/skills/impeccable/scripts/detector/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/.pi/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.pi/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index a3422ff6c..ce5e66f0e 100644 --- a/.pi/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.pi/skills/impeccable/scripts/detector/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/.pi/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.pi/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index 60e10342c..caf4aff44 100644 --- a/.pi/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.pi/skills/impeccable/scripts/detector/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/.pi/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.pi/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index e08589440..9e7a429a2 100644 --- a/.pi/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.pi/skills/impeccable/scripts/detector/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/.pi/skills/impeccable/scripts/detector/rules/checks.mjs b/.pi/skills/impeccable/scripts/detector/rules/checks.mjs index 57341b017..045065c66 100644 --- a/.pi/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.pi/skills/impeccable/scripts/detector/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/.qoder/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.qoder/skills/impeccable/scripts/detector/browser/injected/index.mjs index dfc725a8e..4cf648906 100644 --- a/.qoder/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.qoder/skills/impeccable/scripts/detector/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/.qoder/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.qoder/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index a3422ff6c..ce5e66f0e 100644 --- a/.qoder/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.qoder/skills/impeccable/scripts/detector/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/.qoder/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.qoder/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index 60e10342c..caf4aff44 100644 --- a/.qoder/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.qoder/skills/impeccable/scripts/detector/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/.qoder/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.qoder/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index e08589440..9e7a429a2 100644 --- a/.qoder/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.qoder/skills/impeccable/scripts/detector/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/.qoder/skills/impeccable/scripts/detector/rules/checks.mjs b/.qoder/skills/impeccable/scripts/detector/rules/checks.mjs index 57341b017..045065c66 100644 --- a/.qoder/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.qoder/skills/impeccable/scripts/detector/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/.rovodev/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.rovodev/skills/impeccable/scripts/detector/browser/injected/index.mjs index dfc725a8e..4cf648906 100644 --- a/.rovodev/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.rovodev/skills/impeccable/scripts/detector/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/.rovodev/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.rovodev/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index a3422ff6c..ce5e66f0e 100644 --- a/.rovodev/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.rovodev/skills/impeccable/scripts/detector/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/.rovodev/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.rovodev/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index 60e10342c..caf4aff44 100644 --- a/.rovodev/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.rovodev/skills/impeccable/scripts/detector/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/.rovodev/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.rovodev/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index e08589440..9e7a429a2 100644 --- a/.rovodev/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.rovodev/skills/impeccable/scripts/detector/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/.rovodev/skills/impeccable/scripts/detector/rules/checks.mjs b/.rovodev/skills/impeccable/scripts/detector/rules/checks.mjs index 57341b017..045065c66 100644 --- a/.rovodev/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.rovodev/skills/impeccable/scripts/detector/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/.trae-cn/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.trae-cn/skills/impeccable/scripts/detector/browser/injected/index.mjs index dfc725a8e..4cf648906 100644 --- a/.trae-cn/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.trae-cn/skills/impeccable/scripts/detector/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/.trae-cn/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.trae-cn/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index a3422ff6c..ce5e66f0e 100644 --- a/.trae-cn/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.trae-cn/skills/impeccable/scripts/detector/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/.trae-cn/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.trae-cn/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index 60e10342c..caf4aff44 100644 --- a/.trae-cn/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.trae-cn/skills/impeccable/scripts/detector/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/.trae-cn/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.trae-cn/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index e08589440..9e7a429a2 100644 --- a/.trae-cn/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.trae-cn/skills/impeccable/scripts/detector/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/.trae-cn/skills/impeccable/scripts/detector/rules/checks.mjs b/.trae-cn/skills/impeccable/scripts/detector/rules/checks.mjs index 57341b017..045065c66 100644 --- a/.trae-cn/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.trae-cn/skills/impeccable/scripts/detector/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/.trae/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.trae/skills/impeccable/scripts/detector/browser/injected/index.mjs index dfc725a8e..4cf648906 100644 --- a/.trae/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.trae/skills/impeccable/scripts/detector/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/.trae/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.trae/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index a3422ff6c..ce5e66f0e 100644 --- a/.trae/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.trae/skills/impeccable/scripts/detector/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/.trae/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.trae/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index 60e10342c..caf4aff44 100644 --- a/.trae/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.trae/skills/impeccable/scripts/detector/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/.trae/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.trae/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index e08589440..9e7a429a2 100644 --- a/.trae/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.trae/skills/impeccable/scripts/detector/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/.trae/skills/impeccable/scripts/detector/rules/checks.mjs b/.trae/skills/impeccable/scripts/detector/rules/checks.mjs index 57341b017..045065c66 100644 --- a/.trae/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.trae/skills/impeccable/scripts/detector/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/.vibe/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.vibe/skills/impeccable/scripts/detector/browser/injected/index.mjs index dfc725a8e..4cf648906 100644 --- a/.vibe/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.vibe/skills/impeccable/scripts/detector/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/.vibe/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.vibe/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index a3422ff6c..ce5e66f0e 100644 --- a/.vibe/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.vibe/skills/impeccable/scripts/detector/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/.vibe/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.vibe/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index 60e10342c..caf4aff44 100644 --- a/.vibe/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/.vibe/skills/impeccable/scripts/detector/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/.vibe/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.vibe/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index e08589440..9e7a429a2 100644 --- a/.vibe/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/.vibe/skills/impeccable/scripts/detector/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/.vibe/skills/impeccable/scripts/detector/rules/checks.mjs b/.vibe/skills/impeccable/scripts/detector/rules/checks.mjs index 57341b017..045065c66 100644 --- a/.vibe/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/.vibe/skills/impeccable/scripts/detector/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/plugin/skills/impeccable/scripts/detector/browser/injected/index.mjs b/plugin/skills/impeccable/scripts/detector/browser/injected/index.mjs index dfc725a8e..4cf648906 100644 --- a/plugin/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/plugin/skills/impeccable/scripts/detector/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/plugin/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/plugin/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index a3422ff6c..ce5e66f0e 100644 --- a/plugin/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/plugin/skills/impeccable/scripts/detector/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/plugin/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/plugin/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs index 60e10342c..caf4aff44 100644 --- a/plugin/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +++ b/plugin/skills/impeccable/scripts/detector/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/plugin/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/plugin/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs index e08589440..9e7a429a2 100644 --- a/plugin/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +++ b/plugin/skills/impeccable/scripts/detector/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/plugin/skills/impeccable/scripts/detector/rules/checks.mjs b/plugin/skills/impeccable/scripts/detector/rules/checks.mjs index 57341b017..045065c66 100644 --- a/plugin/skills/impeccable/scripts/detector/rules/checks.mjs +++ b/plugin/skills/impeccable/scripts/detector/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,