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 (/