mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
Sync generated provider output
This commit is contained in:
@@ -683,6 +683,10 @@ if (IS_BROWSER) {
|
||||
|
||||
const reasons = collectVisualContrastReasons(el, style);
|
||||
if (reasons.length === 0) continue;
|
||||
// Image-only mode filters here, inside the cap: gradient/opacity/filter
|
||||
// candidates earlier in DOM order must not consume the budget and
|
||||
// starve the url()-backed texts this mode exists to sample.
|
||||
if (options.imageOnly && !reasons.includes('image background')) continue;
|
||||
|
||||
const textColor = parseRgb(style.color);
|
||||
const fontSize = parseFloat(style.fontSize) || 16;
|
||||
@@ -1175,6 +1179,7 @@ if (IS_BROWSER) {
|
||||
}
|
||||
|
||||
async function analyzeVisualContrast(options = {}) {
|
||||
// imageOnly is enforced inside the collector, before the candidate cap.
|
||||
const candidates = collectVisualContrastCandidates(options);
|
||||
const results = [];
|
||||
const shouldScrollOffscreen = options.scrollOffscreen === true;
|
||||
@@ -1260,9 +1265,16 @@ if (IS_BROWSER) {
|
||||
|
||||
function addBrowserFindings(groupMap, el, findings) {
|
||||
if (!findings || findings.length === 0) return;
|
||||
// Element-scoped waivers: a data-impeccable-ignore ancestor suppresses
|
||||
// matching findings for its whole subtree. Applied at this choke point so
|
||||
// every per-element attribution (checks, layout, occlusion, rhythm)
|
||||
// honors it; page-level findings attributed to <body> pass through
|
||||
// untouched, since body has no ignoring ancestor.
|
||||
const kept = findings.filter(f => !scopedIgnoreActive(el, f.type));
|
||||
if (kept.length === 0) return;
|
||||
const existing = groupMap.get(el);
|
||||
if (existing) existing.push(...findings);
|
||||
else groupMap.set(el, [...findings]);
|
||||
if (existing) existing.push(...kept);
|
||||
else groupMap.set(el, [...kept]);
|
||||
}
|
||||
|
||||
function browserFindingsFromMap(groupMap) {
|
||||
@@ -1620,9 +1632,27 @@ if (IS_BROWSER) {
|
||||
for (const node of docClone.querySelectorAll('[id^="impeccable-live-"]')) {
|
||||
node.remove();
|
||||
}
|
||||
const htmlPatternFindings = checkHtmlPatterns(docClone.outerHTML);
|
||||
if (htmlPatternFindings.length > 0) {
|
||||
const mapped = htmlPatternFindings.map(f => {
|
||||
// Regex findings that name a live selector resolve against the real DOM:
|
||||
// pseudo-element/class segments are stripped (the host element is the
|
||||
// anchor), a selector that matches nothing on this page drops the finding
|
||||
// (the CSS ships here, but the pattern never renders — the live DOM is
|
||||
// ground truth in the browser), and a match under a data-impeccable-ignore
|
||||
// ancestor is waived. Selector-less findings stay page-level.
|
||||
const scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
|
||||
if (!f.selector) return true;
|
||||
const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
|
||||
if (!query || /^[,\s]*$/.test(query)) return true;
|
||||
let matches;
|
||||
try {
|
||||
matches = document.querySelectorAll(query);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
if (matches.length === 0) return false;
|
||||
return [...matches].some(el => !scopedIgnoreActive(el, f.id));
|
||||
});
|
||||
if (scopedHtmlFindings.length > 0) {
|
||||
const mapped = scopedHtmlFindings.map(f => {
|
||||
const item = { type: f.id, detail: f.snippet };
|
||||
if (f.severity) {
|
||||
item.severity = f.severity;
|
||||
@@ -1652,8 +1682,27 @@ if (IS_BROWSER) {
|
||||
};
|
||||
}
|
||||
|
||||
// Visual contrast has three modes. Explicit true runs the full sampled
|
||||
// pass; explicit false disables it entirely (the deterministic-only mode
|
||||
// the test suites use). Unset — the default overlay run — samples ONLY
|
||||
// image-backed text: the one class the analytic walk deliberately skips,
|
||||
// because a url() layer's pixels are unknowable without looking. In-page
|
||||
// sampling draws the source image alone to a canvas (glyph ink never
|
||||
// pollutes it), and a cross-origin image without CORS reports unresolved
|
||||
// instead of guessing.
|
||||
function visualContrastMode(options = {}) {
|
||||
const explicit = typeof options.visualContrast === 'boolean'
|
||||
? options.visualContrast
|
||||
: typeof window.__IMPECCABLE_CONFIG__?.visualContrast === 'boolean'
|
||||
? window.__IMPECCABLE_CONFIG__.visualContrast
|
||||
: null;
|
||||
if (explicit === true) return 'full';
|
||||
if (explicit === false) return false;
|
||||
return 'image-only';
|
||||
}
|
||||
|
||||
function shouldRunVisualContrast(options = {}) {
|
||||
return options.visualContrast === true || window.__IMPECCABLE_CONFIG__?.visualContrast === true;
|
||||
return visualContrastMode(options) !== false;
|
||||
}
|
||||
|
||||
function visualContrastOptions(options = {}) {
|
||||
@@ -1830,6 +1879,7 @@ if (IS_BROWSER) {
|
||||
return [];
|
||||
}
|
||||
const resolvedOptions = visualContrastOptions(options);
|
||||
if (visualContrastMode(options) === 'image-only') resolvedOptions.imageOnly = true;
|
||||
const analyses = await analyzeVisualContrast(resolvedOptions);
|
||||
if (runtime.generation && runtime.generation !== scanGeneration) return analyses;
|
||||
lastVisualContrastAnalyses = analyses;
|
||||
|
||||
@@ -861,6 +861,34 @@ function checkBorders(tag, widths, colors, radius, opts = {}) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ─── Scoped ignores: data-impeccable-ignore ─────────────────────────────────
|
||||
//
|
||||
// An element-scoped waiver that travels with the markup: any element carrying
|
||||
// `data-impeccable-ignore="rule-a rule-b"` (or `*`, or an empty value, for
|
||||
// every rule) suppresses matching findings from itself and its entire subtree,
|
||||
// in every engine that walks elements — the browser overlay, the extension,
|
||||
// and the static scan. This is the DOM twin of the line-based
|
||||
// `impeccable-disable` comment directives, which the browser cannot apply (a
|
||||
// live DOM has no line numbers), and the generalization of the one-off
|
||||
// `data-impeccable-allow-kickers` opt-out.
|
||||
//
|
||||
// The intended use is curated exhibits: a page that documents anti-patterns by
|
||||
// example, or renders a deliberate "before" specimen, marks the container once
|
||||
// and every engine skips it while still scanning the page around it.
|
||||
function scopedIgnoreActive(el, ruleId) {
|
||||
const rule = String(ruleId || '').toLowerCase();
|
||||
let cur = el;
|
||||
while (cur && cur.nodeType === 1) {
|
||||
const attr = typeof cur.getAttribute === 'function' ? cur.getAttribute('data-impeccable-ignore') : null;
|
||||
if (attr != null) {
|
||||
const rules = String(attr).trim().toLowerCase().split(/[\s,]+/).filter(Boolean);
|
||||
if (rules.length === 0 || rules.includes('*') || rules.includes(rule)) return true;
|
||||
}
|
||||
cur = cur.parentElement;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Returns true if the given text is composed entirely of emoji characters
|
||||
// (plus whitespace / variation selectors). Emojis render as multicolor glyphs
|
||||
// regardless of CSS `color`, so contrast checks against the element's text
|
||||
@@ -1428,6 +1456,26 @@ function cssTextHasDarkRootBg(content, customProps) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Best-effort extraction of the CSS selector whose declaration block contains
|
||||
// the given index in raw CSS text. Lets CSS-text findings carry a live-DOM
|
||||
// anchor, so the browser pass can resolve scoped ignores against the actual
|
||||
// element and drop patterns that render nowhere on the page. Returns null for
|
||||
// @-rule preludes, keyframe steps, nested blocks, and anything that does not
|
||||
// read as a selector; those findings stay page-level.
|
||||
function enclosingCssSelector(cssText, index) {
|
||||
if (!cssText || !Number.isFinite(index)) return null;
|
||||
const open = cssText.lastIndexOf('{', index);
|
||||
if (open === -1) return null;
|
||||
const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
|
||||
const raw = cssText.slice(prevClose + 1, open).trim().replace(/\s+/g, ' ');
|
||||
if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
|
||||
// Keyframe steps: percentage steps fail the digit test above, but `from`
|
||||
// and `to` would read as (never-matching) type selectors and get a valid
|
||||
// finding wrongly dropped by the zero-match rule downstream.
|
||||
if (/^(?:from|to)(?:\s*,\s*(?:from|to))*$/i.test(raw)) return null;
|
||||
return raw;
|
||||
}
|
||||
|
||||
function scanCssTextForGlow(content) {
|
||||
const customProps = collectCssCustomProps(content);
|
||||
const hasDarkBg = cssTextHasDarkRootBg(content, customProps);
|
||||
@@ -1739,6 +1787,7 @@ function scanCssTextForPseudoStripe(rawContent) {
|
||||
id: 'side-tab',
|
||||
snippet: `${selector} — absolute ${thicknessPx}px pseudo-element stripe (${edge}: 0)`,
|
||||
index: selectorStart,
|
||||
selector,
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
@@ -1801,6 +1850,7 @@ function scanCssTextForInsetStripe(content) {
|
||||
findings.push({
|
||||
id: 'side-tab',
|
||||
snippet: `${selector} — inset box-shadow ${ay === 0 ? ax : ay}px stripe (${edge})`,
|
||||
selector,
|
||||
});
|
||||
break;
|
||||
}
|
||||
@@ -1858,7 +1908,7 @@ function collectMarqueeKeyframes(content) {
|
||||
function scanCssTextForMarquee(content, markup = content) {
|
||||
const findings = [];
|
||||
if (/<marquee\b/i.test(markup)) {
|
||||
findings.push({ id: 'marquee', snippet: '<marquee> element' });
|
||||
findings.push({ id: 'marquee', snippet: '<marquee> element', selector: 'marquee' });
|
||||
}
|
||||
const marqueeKeyframes = collectMarqueeKeyframes(content);
|
||||
if (marqueeKeyframes.size === 0) return findings;
|
||||
@@ -1873,7 +1923,7 @@ function scanCssTextForMarquee(content, markup = content) {
|
||||
const key = `${selector} ${name}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
findings.push({ id: 'marquee', snippet: `${selector} — infinite horizontal loop animation "${name}"` });
|
||||
findings.push({ id: 'marquee', snippet: `${selector} — infinite horizontal loop animation "${name}"`, selector });
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
@@ -2244,8 +2294,10 @@ function checkHtmlPatterns(html, corpora) {
|
||||
const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi;
|
||||
if (purpleHexRe.test(styleText)) {
|
||||
const purpleTextRe = /(?:(?:^|;)\s*color\s*:\s*(?:.*?)(?:#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9))|gradient.*?#(?:7c3aed|8b5cf6|a855f7|764ba2|667eea))/gi;
|
||||
if (purpleTextRe.test(styleText)) {
|
||||
findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected' });
|
||||
purpleTextRe.lastIndex = 0;
|
||||
const purpleMatch = purpleTextRe.exec(styleText);
|
||||
if (purpleMatch) {
|
||||
findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected', selector: enclosingCssSelector(styleText, purpleMatch.index + 1) || undefined });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2256,7 +2308,7 @@ function checkHtmlPatterns(html, corpora) {
|
||||
const start = Math.max(0, gm.index - 200);
|
||||
const context = styleText.substring(start, gm.index + gm[0].length + 200);
|
||||
if (/gradient/i.test(context)) {
|
||||
findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
|
||||
findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient', selector: enclosingCssSelector(styleText, gm.index) || undefined });
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -2322,7 +2374,7 @@ function checkHtmlPatterns(html, corpora) {
|
||||
const animationToken = bounceMatch[1]
|
||||
.split(/[,\s]+/)
|
||||
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
|
||||
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}` });
|
||||
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}`, selector: enclosingCssSelector(styleText, bounceMatch.index) || undefined });
|
||||
}
|
||||
|
||||
// Overshoot cubic-bezier
|
||||
@@ -2331,7 +2383,7 @@ function checkHtmlPatterns(html, corpora) {
|
||||
while ((bm = bezierRe.exec(styleText)) !== null) {
|
||||
const y1 = parseFloat(bm[2]), y2 = parseFloat(bm[4]);
|
||||
if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {
|
||||
findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})` });
|
||||
findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})`, selector: enclosingCssSelector(styleText, bm.index) || undefined });
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -2364,18 +2416,21 @@ function checkHtmlPatterns(html, corpora) {
|
||||
|
||||
const glowHits = scanCssTextForGlow(styleText);
|
||||
if (glowHits.length > 0) {
|
||||
findings.push({ id: 'dark-glow', snippet: glowHits[0].snippet });
|
||||
findings.push({ id: 'dark-glow', snippet: glowHits[0].snippet, selector: enclosingCssSelector(styleText, glowHits[0].index) || undefined });
|
||||
}
|
||||
|
||||
// Radial-gradient background halo (gradient-drawn sibling of dark-glow)
|
||||
const haloHits = scanCssTextForRadialHalo(styleText);
|
||||
if (haloHits.length > 0) {
|
||||
findings.push({ id: 'radial-halo', snippet: haloHits[0].snippet });
|
||||
findings.push({ id: 'radial-halo', snippet: haloHits[0].snippet, selector: enclosingCssSelector(styleText, haloHits[0].index) || undefined });
|
||||
}
|
||||
|
||||
// --- Generated-UI tells: repeating-gradient stripes ---
|
||||
if (/repeating-(?:linear|radial|conic)-gradient\s*\(/i.test(styleText)) {
|
||||
findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes' });
|
||||
{
|
||||
const stripesMatch = /repeating-(?:linear|radial|conic)-gradient\s*\(/i.exec(styleText);
|
||||
if (stripesMatch) {
|
||||
findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes', selector: enclosingCssSelector(styleText, stripesMatch.index) || undefined });
|
||||
}
|
||||
}
|
||||
|
||||
// --- Generated-UI tells: two-axis grid-line background ---
|
||||
@@ -2393,7 +2448,7 @@ function checkHtmlPatterns(html, corpora) {
|
||||
// whole gradient layers.
|
||||
const gridHits = scanCssTextForGridBackground(styleText);
|
||||
if (gridHits.length > 0) {
|
||||
findings.push({ id: 'codex-grid-background', snippet: gridHits[0].snippet });
|
||||
findings.push({ id: 'codex-grid-background', snippet: gridHits[0].snippet, selector: enclosingCssSelector(styleText, gridHits[0].index) || undefined });
|
||||
}
|
||||
|
||||
// --- Generated-copy tells: "X theater" framing copy ---
|
||||
@@ -2413,8 +2468,11 @@ function checkHtmlPatterns(html, corpora) {
|
||||
// hover:rotate / hover:translate utility on an <img>. Each distinct
|
||||
// mechanism is its own finding.
|
||||
const imgHoverCss = /\bimg\b[^,{}]*:hover\b[^{}]*\{[^}]*\btransform\s*:\s*(?:scale|rotate|translate|matrix|skew)/i;
|
||||
if (imgHoverCss.test(styleText)) {
|
||||
findings.push({ id: 'image-hover-transform', snippet: 'img:hover { transform } rule' });
|
||||
{
|
||||
const imgHoverMatch = imgHoverCss.exec(styleText);
|
||||
if (imgHoverMatch) {
|
||||
findings.push({ id: 'image-hover-transform', snippet: 'img:hover { transform } rule', selector: enclosingCssSelector(styleText, imgHoverMatch.index + imgHoverMatch[0].indexOf('{') + 1) || undefined });
|
||||
}
|
||||
}
|
||||
const imgTagRe = /<img\b[^>]*\bclass\s*=\s*"([^"]*)"/gi;
|
||||
let im;
|
||||
@@ -2585,6 +2643,13 @@ function resolveGradientStops(el, win, customPropMap) {
|
||||
while (current && current.nodeType === 1) {
|
||||
const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
|
||||
const bgImage = style.backgroundImage || '';
|
||||
// A url() layer anywhere in the stack — alone, or alongside a gradient in
|
||||
// the same declaration (a translucent wash over a texture photo) — paints
|
||||
// pixels the analytic walk cannot know. Measuring the gradient stops over
|
||||
// the wrong base flagged dark ink sitting on a bright gold-leaf image at
|
||||
// 2.6:1; skipping beats a wrong ratio, and the screenshot subsystem owns
|
||||
// image-backed text.
|
||||
if (bgImage && bgImage !== 'none' && /url\s*\(/i.test(bgImage)) return null;
|
||||
let stops = null;
|
||||
if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
|
||||
const parsed = parseGradientColorsModern(bgImage);
|
||||
@@ -2835,6 +2900,10 @@ function checkElementColorsDOM(el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width < 10 || rect.height < 10) return [];
|
||||
const style = getComputedStyle(el);
|
||||
// Invisible at rest: hidden scene variants (opacity-0 carousels, swap
|
||||
// decks) are not user-visible, and measuring their inherited colors against
|
||||
// whatever surface happens to sit behind the stack is noise, not audit.
|
||||
if (style.visibility === 'hidden' || effectiveOpacityDOM(el) <= 0.02) return [];
|
||||
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
|
||||
const hasDirectText = directText.trim().length > 0;
|
||||
let effectiveBg = resolveBackground(el);
|
||||
@@ -4495,6 +4564,14 @@ function checkElementBorders(tag, style, overrides, resolvedRadius, el = null) {
|
||||
}
|
||||
|
||||
function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInheritRule) {
|
||||
// Invisible at rest, static twin of the browser walk's skip: opacity does
|
||||
// not inherit, so walk ancestors multiplying declared opacity down.
|
||||
if (style.visibility === 'hidden') return [];
|
||||
let effOpacity = 1;
|
||||
for (let cur = el; cur && cur.nodeType === 1 && effOpacity > 0.02; cur = cur.parentElement) {
|
||||
effOpacity *= parseFloat(window.getComputedStyle(cur).opacity || '1');
|
||||
}
|
||||
if (effOpacity <= 0.02) return [];
|
||||
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
|
||||
const hasDirectText = directText.trim().length > 0;
|
||||
|
||||
@@ -5653,6 +5730,11 @@ function isRenderedForBrowserRule(el) {
|
||||
function checkElementTextOverflowDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
|
||||
// scrollWidth/clientWidth are CSS box-model metrics; on SVG content Chrome
|
||||
// returns arbitrary non-zero values for both (a <text> reported 78/48 while
|
||||
// its rendered length sat comfortably inside its box), so the delta is
|
||||
// noise, not overflow. SVG clips to its own viewport anyway.
|
||||
if (el.namespaceURI === 'http://www.w3.org/2000/svg') return [];
|
||||
if (!isRenderedForBrowserRule(el)) return [];
|
||||
// Only the element that actually owns overflowing text — not its ancestors,
|
||||
// which inherit a wider scrollWidth from the spilling descendant.
|
||||
@@ -6037,6 +6119,22 @@ function isPaintedForOcclusion(el) {
|
||||
// path is pure geometry and runs anywhere on the page.
|
||||
const OCCLUSION_TEXT_SKIP_TAGS = new Set(['script', 'style', 'noscript', 'template', 'title']);
|
||||
|
||||
// An element whose effective opacity multiplies out to ~0 paints nothing at
|
||||
// rest: it is not user-visible, so visual findings on it (contrast, occlusion)
|
||||
// measure a state nobody sees. Browser-only — the walk needs live computed
|
||||
// styles. Cycling scenes that fade such elements in later are the screenshot
|
||||
// subsystem's territory, not the analytic walk's.
|
||||
function effectiveOpacityDOM(el) {
|
||||
let o = 1;
|
||||
// Walk all the way through body and html: `body { opacity: 0 }` page-fade
|
||||
// wrappers hide every descendant just as thoroughly as a local wrapper.
|
||||
for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) {
|
||||
o *= parseFloat(getComputedStyle(cur).opacity || '1');
|
||||
if (o <= 0.02) return 0;
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
function checkTextOcclusionDOM() {
|
||||
const findings = [];
|
||||
const seenVictims = new Set();
|
||||
@@ -6064,6 +6162,11 @@ function checkTextOcclusionDOM() {
|
||||
}
|
||||
return false;
|
||||
};
|
||||
// The classic occluder shape this rules out is an opacity-0 interaction
|
||||
// layer — a range scrubber stretched over a before/after comparison — which
|
||||
// elementFromPoint still returns and whose UA background-color otherwise
|
||||
// reads as an opaque box.
|
||||
const effectiveOpacity = effectiveOpacityDOM;
|
||||
|
||||
// Collect renderable text owners in / near the first viewport for the
|
||||
// elementFromPoint probe. SVG <text> counts too.
|
||||
@@ -6076,6 +6179,7 @@ function checkTextOcclusionDOM() {
|
||||
const text = inSvg ? (el.textContent || '').trim() : elementDirectText(el);
|
||||
if (text.length < 2) continue;
|
||||
if (!isPaintedForOcclusion(el)) continue;
|
||||
if (effectiveOpacity(el) <= 0.02) continue;
|
||||
let rect; try { rect = el.getBoundingClientRect(); } catch { continue; }
|
||||
if (rect.width < 6 || rect.height < 6) continue;
|
||||
// Viewport-bound probe: keep text whose box overlaps the live viewport.
|
||||
@@ -6109,6 +6213,7 @@ function checkTextOcclusionDOM() {
|
||||
if (top === el || el.contains(top) || top.contains(el)) continue;
|
||||
const topCs = getComputedStyle(top);
|
||||
if (isFloated(topCs) || isMarqueeish(top, topCs) || isPinnedOverlay(top)) continue;
|
||||
if (effectiveOpacity(top) <= 0.02) continue;
|
||||
const topTag = top.tagName.toLowerCase();
|
||||
// Text sitting under a raw image/video is contrast territory (deduped
|
||||
// against the pixel low-contrast rule); leave those alone here.
|
||||
@@ -7001,6 +7106,10 @@ if (IS_BROWSER) {
|
||||
|
||||
const reasons = collectVisualContrastReasons(el, style);
|
||||
if (reasons.length === 0) continue;
|
||||
// Image-only mode filters here, inside the cap: gradient/opacity/filter
|
||||
// candidates earlier in DOM order must not consume the budget and
|
||||
// starve the url()-backed texts this mode exists to sample.
|
||||
if (options.imageOnly && !reasons.includes('image background')) continue;
|
||||
|
||||
const textColor = parseRgb(style.color);
|
||||
const fontSize = parseFloat(style.fontSize) || 16;
|
||||
@@ -7493,6 +7602,7 @@ if (IS_BROWSER) {
|
||||
}
|
||||
|
||||
async function analyzeVisualContrast(options = {}) {
|
||||
// imageOnly is enforced inside the collector, before the candidate cap.
|
||||
const candidates = collectVisualContrastCandidates(options);
|
||||
const results = [];
|
||||
const shouldScrollOffscreen = options.scrollOffscreen === true;
|
||||
@@ -7578,9 +7688,16 @@ if (IS_BROWSER) {
|
||||
|
||||
function addBrowserFindings(groupMap, el, findings) {
|
||||
if (!findings || findings.length === 0) return;
|
||||
// Element-scoped waivers: a data-impeccable-ignore ancestor suppresses
|
||||
// matching findings for its whole subtree. Applied at this choke point so
|
||||
// every per-element attribution (checks, layout, occlusion, rhythm)
|
||||
// honors it; page-level findings attributed to <body> pass through
|
||||
// untouched, since body has no ignoring ancestor.
|
||||
const kept = findings.filter(f => !scopedIgnoreActive(el, f.type));
|
||||
if (kept.length === 0) return;
|
||||
const existing = groupMap.get(el);
|
||||
if (existing) existing.push(...findings);
|
||||
else groupMap.set(el, [...findings]);
|
||||
if (existing) existing.push(...kept);
|
||||
else groupMap.set(el, [...kept]);
|
||||
}
|
||||
|
||||
function browserFindingsFromMap(groupMap) {
|
||||
@@ -7938,9 +8055,27 @@ if (IS_BROWSER) {
|
||||
for (const node of docClone.querySelectorAll('[id^="impeccable-live-"]')) {
|
||||
node.remove();
|
||||
}
|
||||
const htmlPatternFindings = checkHtmlPatterns(docClone.outerHTML);
|
||||
if (htmlPatternFindings.length > 0) {
|
||||
const mapped = htmlPatternFindings.map(f => {
|
||||
// Regex findings that name a live selector resolve against the real DOM:
|
||||
// pseudo-element/class segments are stripped (the host element is the
|
||||
// anchor), a selector that matches nothing on this page drops the finding
|
||||
// (the CSS ships here, but the pattern never renders — the live DOM is
|
||||
// ground truth in the browser), and a match under a data-impeccable-ignore
|
||||
// ancestor is waived. Selector-less findings stay page-level.
|
||||
const scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
|
||||
if (!f.selector) return true;
|
||||
const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
|
||||
if (!query || /^[,\s]*$/.test(query)) return true;
|
||||
let matches;
|
||||
try {
|
||||
matches = document.querySelectorAll(query);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
if (matches.length === 0) return false;
|
||||
return [...matches].some(el => !scopedIgnoreActive(el, f.id));
|
||||
});
|
||||
if (scopedHtmlFindings.length > 0) {
|
||||
const mapped = scopedHtmlFindings.map(f => {
|
||||
const item = { type: f.id, detail: f.snippet };
|
||||
if (f.severity) {
|
||||
item.severity = f.severity;
|
||||
@@ -7970,8 +8105,27 @@ if (IS_BROWSER) {
|
||||
};
|
||||
}
|
||||
|
||||
// Visual contrast has three modes. Explicit true runs the full sampled
|
||||
// pass; explicit false disables it entirely (the deterministic-only mode
|
||||
// the test suites use). Unset — the default overlay run — samples ONLY
|
||||
// image-backed text: the one class the analytic walk deliberately skips,
|
||||
// because a url() layer's pixels are unknowable without looking. In-page
|
||||
// sampling draws the source image alone to a canvas (glyph ink never
|
||||
// pollutes it), and a cross-origin image without CORS reports unresolved
|
||||
// instead of guessing.
|
||||
function visualContrastMode(options = {}) {
|
||||
const explicit = typeof options.visualContrast === 'boolean'
|
||||
? options.visualContrast
|
||||
: typeof window.__IMPECCABLE_CONFIG__?.visualContrast === 'boolean'
|
||||
? window.__IMPECCABLE_CONFIG__.visualContrast
|
||||
: null;
|
||||
if (explicit === true) return 'full';
|
||||
if (explicit === false) return false;
|
||||
return 'image-only';
|
||||
}
|
||||
|
||||
function shouldRunVisualContrast(options = {}) {
|
||||
return options.visualContrast === true || window.__IMPECCABLE_CONFIG__?.visualContrast === true;
|
||||
return visualContrastMode(options) !== false;
|
||||
}
|
||||
|
||||
function visualContrastOptions(options = {}) {
|
||||
@@ -8148,6 +8302,7 @@ if (IS_BROWSER) {
|
||||
return [];
|
||||
}
|
||||
const resolvedOptions = visualContrastOptions(options);
|
||||
if (visualContrastMode(options) === 'image-only') resolvedOptions.imageOnly = true;
|
||||
const analyses = await analyzeVisualContrast(resolvedOptions);
|
||||
if (runtime.generation && runtime.generation !== scanGeneration) return analyses;
|
||||
lastVisualContrastAnalyses = analyses;
|
||||
|
||||
@@ -226,6 +226,10 @@ const STATIC_INHERITED_PROPS = new Set([
|
||||
'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight', 'fontVariant',
|
||||
'lineHeight', 'letterSpacing', 'textTransform', 'textAlign', 'hyphens',
|
||||
'webkitHyphens',
|
||||
// visibility inherits in real CSS, and the invisible-at-rest contrast skip
|
||||
// relies on descendants of a hidden container computing as hidden. A child
|
||||
// that declares `visibility: visible` still overrides the inherited value.
|
||||
'visibility',
|
||||
]);
|
||||
|
||||
const STATIC_DEFAULT_STYLE = {
|
||||
@@ -278,6 +282,7 @@ const STATIC_DEFAULT_STYLE = {
|
||||
marginLeft: '0px',
|
||||
position: 'static',
|
||||
visibility: 'visible',
|
||||
opacity: '1',
|
||||
top: 'auto',
|
||||
right: 'auto',
|
||||
bottom: 'auto',
|
||||
@@ -334,6 +339,7 @@ const STATIC_PROP_MAP = {
|
||||
'margin-left': 'marginLeft',
|
||||
'position': 'position',
|
||||
'visibility': 'visibility',
|
||||
'opacity': 'opacity',
|
||||
'top': 'top',
|
||||
'right': 'right',
|
||||
'bottom': 'bottom',
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
checkCreamPalette,
|
||||
checkHtmlPatterns,
|
||||
checkKickerAboveHeadingFromDoc,
|
||||
scopedIgnoreActive,
|
||||
checkNumberedSectionLabelsFromDoc,
|
||||
checkPageLayout,
|
||||
checkPageQualityFromDoc,
|
||||
@@ -182,6 +183,9 @@ async function detectHtml(filePath, options = {}) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
const style = window.getComputedStyle(el);
|
||||
for (const f of runElementCheck(rule.id, () => rule.run(el, tag, style, window, customPropMap))) {
|
||||
// Element-scoped waivers: a data-impeccable-ignore ancestor suppresses
|
||||
// matching findings for its subtree, same as the browser walk.
|
||||
if (scopedIgnoreActive(el, f.id)) continue;
|
||||
findings.push(finding(f.id, filePath, f.snippet));
|
||||
}
|
||||
}
|
||||
@@ -249,6 +253,17 @@ async function detectHtml(filePath, options = {}) {
|
||||
for (const f of runPageCheck('html-patterns', () => checkHtmlPatterns(html, patternCorpora).filter(item =>
|
||||
item.id !== 'bounce-easing' && item.id !== 'layout-transition'
|
||||
))) {
|
||||
// Selector-backed page findings honor scoped waivers here too, matching
|
||||
// the browser pass: resolve the selector and drop the finding when an
|
||||
// ignoring ancestor covers a match. Unlike the browser, an unmatched
|
||||
// selector keeps the finding — static scans see partial documents.
|
||||
if (f.selector) {
|
||||
let matches = null;
|
||||
try {
|
||||
matches = document.querySelectorAll(String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim());
|
||||
} catch { matches = null; }
|
||||
if (matches && matches.length > 0 && [...matches].every(el => scopedIgnoreActive(el, f.id))) continue;
|
||||
}
|
||||
const item = finding(f.id, filePath, f.snippet);
|
||||
// Position-aware severity promotion: checks may attach a per-finding
|
||||
// severity (e.g. a pulsing dot inside a header/nav landmark) that
|
||||
|
||||
@@ -70,6 +70,34 @@ function checkBorders(tag, widths, colors, radius, opts = {}) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ─── Scoped ignores: data-impeccable-ignore ─────────────────────────────────
|
||||
//
|
||||
// An element-scoped waiver that travels with the markup: any element carrying
|
||||
// `data-impeccable-ignore="rule-a rule-b"` (or `*`, or an empty value, for
|
||||
// every rule) suppresses matching findings from itself and its entire subtree,
|
||||
// in every engine that walks elements — the browser overlay, the extension,
|
||||
// and the static scan. This is the DOM twin of the line-based
|
||||
// `impeccable-disable` comment directives, which the browser cannot apply (a
|
||||
// live DOM has no line numbers), and the generalization of the one-off
|
||||
// `data-impeccable-allow-kickers` opt-out.
|
||||
//
|
||||
// The intended use is curated exhibits: a page that documents anti-patterns by
|
||||
// example, or renders a deliberate "before" specimen, marks the container once
|
||||
// and every engine skips it while still scanning the page around it.
|
||||
function scopedIgnoreActive(el, ruleId) {
|
||||
const rule = String(ruleId || '').toLowerCase();
|
||||
let cur = el;
|
||||
while (cur && cur.nodeType === 1) {
|
||||
const attr = typeof cur.getAttribute === 'function' ? cur.getAttribute('data-impeccable-ignore') : null;
|
||||
if (attr != null) {
|
||||
const rules = String(attr).trim().toLowerCase().split(/[\s,]+/).filter(Boolean);
|
||||
if (rules.length === 0 || rules.includes('*') || rules.includes(rule)) return true;
|
||||
}
|
||||
cur = cur.parentElement;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Returns true if the given text is composed entirely of emoji characters
|
||||
// (plus whitespace / variation selectors). Emojis render as multicolor glyphs
|
||||
// regardless of CSS `color`, so contrast checks against the element's text
|
||||
@@ -637,6 +665,26 @@ function cssTextHasDarkRootBg(content, customProps) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Best-effort extraction of the CSS selector whose declaration block contains
|
||||
// the given index in raw CSS text. Lets CSS-text findings carry a live-DOM
|
||||
// anchor, so the browser pass can resolve scoped ignores against the actual
|
||||
// element and drop patterns that render nowhere on the page. Returns null for
|
||||
// @-rule preludes, keyframe steps, nested blocks, and anything that does not
|
||||
// read as a selector; those findings stay page-level.
|
||||
function enclosingCssSelector(cssText, index) {
|
||||
if (!cssText || !Number.isFinite(index)) return null;
|
||||
const open = cssText.lastIndexOf('{', index);
|
||||
if (open === -1) return null;
|
||||
const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
|
||||
const raw = cssText.slice(prevClose + 1, open).trim().replace(/\s+/g, ' ');
|
||||
if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
|
||||
// Keyframe steps: percentage steps fail the digit test above, but `from`
|
||||
// and `to` would read as (never-matching) type selectors and get a valid
|
||||
// finding wrongly dropped by the zero-match rule downstream.
|
||||
if (/^(?:from|to)(?:\s*,\s*(?:from|to))*$/i.test(raw)) return null;
|
||||
return raw;
|
||||
}
|
||||
|
||||
function scanCssTextForGlow(content) {
|
||||
const customProps = collectCssCustomProps(content);
|
||||
const hasDarkBg = cssTextHasDarkRootBg(content, customProps);
|
||||
@@ -948,6 +996,7 @@ function scanCssTextForPseudoStripe(rawContent) {
|
||||
id: 'side-tab',
|
||||
snippet: `${selector} — absolute ${thicknessPx}px pseudo-element stripe (${edge}: 0)`,
|
||||
index: selectorStart,
|
||||
selector,
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
@@ -1010,6 +1059,7 @@ function scanCssTextForInsetStripe(content) {
|
||||
findings.push({
|
||||
id: 'side-tab',
|
||||
snippet: `${selector} — inset box-shadow ${ay === 0 ? ax : ay}px stripe (${edge})`,
|
||||
selector,
|
||||
});
|
||||
break;
|
||||
}
|
||||
@@ -1067,7 +1117,7 @@ function collectMarqueeKeyframes(content) {
|
||||
function scanCssTextForMarquee(content, markup = content) {
|
||||
const findings = [];
|
||||
if (/<marquee\b/i.test(markup)) {
|
||||
findings.push({ id: 'marquee', snippet: '<marquee> element' });
|
||||
findings.push({ id: 'marquee', snippet: '<marquee> element', selector: 'marquee' });
|
||||
}
|
||||
const marqueeKeyframes = collectMarqueeKeyframes(content);
|
||||
if (marqueeKeyframes.size === 0) return findings;
|
||||
@@ -1082,7 +1132,7 @@ function scanCssTextForMarquee(content, markup = content) {
|
||||
const key = `${selector} ${name}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
findings.push({ id: 'marquee', snippet: `${selector} — infinite horizontal loop animation "${name}"` });
|
||||
findings.push({ id: 'marquee', snippet: `${selector} — infinite horizontal loop animation "${name}"`, selector });
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
@@ -1453,8 +1503,10 @@ function checkHtmlPatterns(html, corpora) {
|
||||
const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi;
|
||||
if (purpleHexRe.test(styleText)) {
|
||||
const purpleTextRe = /(?:(?:^|;)\s*color\s*:\s*(?:.*?)(?:#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9))|gradient.*?#(?:7c3aed|8b5cf6|a855f7|764ba2|667eea))/gi;
|
||||
if (purpleTextRe.test(styleText)) {
|
||||
findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected' });
|
||||
purpleTextRe.lastIndex = 0;
|
||||
const purpleMatch = purpleTextRe.exec(styleText);
|
||||
if (purpleMatch) {
|
||||
findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected', selector: enclosingCssSelector(styleText, purpleMatch.index + 1) || undefined });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1465,7 +1517,7 @@ function checkHtmlPatterns(html, corpora) {
|
||||
const start = Math.max(0, gm.index - 200);
|
||||
const context = styleText.substring(start, gm.index + gm[0].length + 200);
|
||||
if (/gradient/i.test(context)) {
|
||||
findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
|
||||
findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient', selector: enclosingCssSelector(styleText, gm.index) || undefined });
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1531,7 +1583,7 @@ function checkHtmlPatterns(html, corpora) {
|
||||
const animationToken = bounceMatch[1]
|
||||
.split(/[,\s]+/)
|
||||
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
|
||||
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}` });
|
||||
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}`, selector: enclosingCssSelector(styleText, bounceMatch.index) || undefined });
|
||||
}
|
||||
|
||||
// Overshoot cubic-bezier
|
||||
@@ -1540,7 +1592,7 @@ function checkHtmlPatterns(html, corpora) {
|
||||
while ((bm = bezierRe.exec(styleText)) !== null) {
|
||||
const y1 = parseFloat(bm[2]), y2 = parseFloat(bm[4]);
|
||||
if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {
|
||||
findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})` });
|
||||
findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})`, selector: enclosingCssSelector(styleText, bm.index) || undefined });
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1573,18 +1625,21 @@ function checkHtmlPatterns(html, corpora) {
|
||||
|
||||
const glowHits = scanCssTextForGlow(styleText);
|
||||
if (glowHits.length > 0) {
|
||||
findings.push({ id: 'dark-glow', snippet: glowHits[0].snippet });
|
||||
findings.push({ id: 'dark-glow', snippet: glowHits[0].snippet, selector: enclosingCssSelector(styleText, glowHits[0].index) || undefined });
|
||||
}
|
||||
|
||||
// Radial-gradient background halo (gradient-drawn sibling of dark-glow)
|
||||
const haloHits = scanCssTextForRadialHalo(styleText);
|
||||
if (haloHits.length > 0) {
|
||||
findings.push({ id: 'radial-halo', snippet: haloHits[0].snippet });
|
||||
findings.push({ id: 'radial-halo', snippet: haloHits[0].snippet, selector: enclosingCssSelector(styleText, haloHits[0].index) || undefined });
|
||||
}
|
||||
|
||||
// --- Generated-UI tells: repeating-gradient stripes ---
|
||||
if (/repeating-(?:linear|radial|conic)-gradient\s*\(/i.test(styleText)) {
|
||||
findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes' });
|
||||
{
|
||||
const stripesMatch = /repeating-(?:linear|radial|conic)-gradient\s*\(/i.exec(styleText);
|
||||
if (stripesMatch) {
|
||||
findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes', selector: enclosingCssSelector(styleText, stripesMatch.index) || undefined });
|
||||
}
|
||||
}
|
||||
|
||||
// --- Generated-UI tells: two-axis grid-line background ---
|
||||
@@ -1602,7 +1657,7 @@ function checkHtmlPatterns(html, corpora) {
|
||||
// whole gradient layers.
|
||||
const gridHits = scanCssTextForGridBackground(styleText);
|
||||
if (gridHits.length > 0) {
|
||||
findings.push({ id: 'codex-grid-background', snippet: gridHits[0].snippet });
|
||||
findings.push({ id: 'codex-grid-background', snippet: gridHits[0].snippet, selector: enclosingCssSelector(styleText, gridHits[0].index) || undefined });
|
||||
}
|
||||
|
||||
// --- Generated-copy tells: "X theater" framing copy ---
|
||||
@@ -1622,8 +1677,11 @@ function checkHtmlPatterns(html, corpora) {
|
||||
// hover:rotate / hover:translate utility on an <img>. Each distinct
|
||||
// mechanism is its own finding.
|
||||
const imgHoverCss = /\bimg\b[^,{}]*:hover\b[^{}]*\{[^}]*\btransform\s*:\s*(?:scale|rotate|translate|matrix|skew)/i;
|
||||
if (imgHoverCss.test(styleText)) {
|
||||
findings.push({ id: 'image-hover-transform', snippet: 'img:hover { transform } rule' });
|
||||
{
|
||||
const imgHoverMatch = imgHoverCss.exec(styleText);
|
||||
if (imgHoverMatch) {
|
||||
findings.push({ id: 'image-hover-transform', snippet: 'img:hover { transform } rule', selector: enclosingCssSelector(styleText, imgHoverMatch.index + imgHoverMatch[0].indexOf('{') + 1) || undefined });
|
||||
}
|
||||
}
|
||||
const imgTagRe = /<img\b[^>]*\bclass\s*=\s*"([^"]*)"/gi;
|
||||
let im;
|
||||
@@ -1794,6 +1852,13 @@ function resolveGradientStops(el, win, customPropMap) {
|
||||
while (current && current.nodeType === 1) {
|
||||
const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
|
||||
const bgImage = style.backgroundImage || '';
|
||||
// A url() layer anywhere in the stack — alone, or alongside a gradient in
|
||||
// the same declaration (a translucent wash over a texture photo) — paints
|
||||
// pixels the analytic walk cannot know. Measuring the gradient stops over
|
||||
// the wrong base flagged dark ink sitting on a bright gold-leaf image at
|
||||
// 2.6:1; skipping beats a wrong ratio, and the screenshot subsystem owns
|
||||
// image-backed text.
|
||||
if (bgImage && bgImage !== 'none' && /url\s*\(/i.test(bgImage)) return null;
|
||||
let stops = null;
|
||||
if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
|
||||
const parsed = parseGradientColorsModern(bgImage);
|
||||
@@ -2044,6 +2109,10 @@ function checkElementColorsDOM(el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width < 10 || rect.height < 10) return [];
|
||||
const style = getComputedStyle(el);
|
||||
// Invisible at rest: hidden scene variants (opacity-0 carousels, swap
|
||||
// decks) are not user-visible, and measuring their inherited colors against
|
||||
// whatever surface happens to sit behind the stack is noise, not audit.
|
||||
if (style.visibility === 'hidden' || effectiveOpacityDOM(el) <= 0.02) return [];
|
||||
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
|
||||
const hasDirectText = directText.trim().length > 0;
|
||||
let effectiveBg = resolveBackground(el);
|
||||
@@ -3704,6 +3773,14 @@ function checkElementBorders(tag, style, overrides, resolvedRadius, el = null) {
|
||||
}
|
||||
|
||||
function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInheritRule) {
|
||||
// Invisible at rest, static twin of the browser walk's skip: opacity does
|
||||
// not inherit, so walk ancestors multiplying declared opacity down.
|
||||
if (style.visibility === 'hidden') return [];
|
||||
let effOpacity = 1;
|
||||
for (let cur = el; cur && cur.nodeType === 1 && effOpacity > 0.02; cur = cur.parentElement) {
|
||||
effOpacity *= parseFloat(window.getComputedStyle(cur).opacity || '1');
|
||||
}
|
||||
if (effOpacity <= 0.02) return [];
|
||||
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
|
||||
const hasDirectText = directText.trim().length > 0;
|
||||
|
||||
@@ -4862,6 +4939,11 @@ function isRenderedForBrowserRule(el) {
|
||||
function checkElementTextOverflowDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
|
||||
// scrollWidth/clientWidth are CSS box-model metrics; on SVG content Chrome
|
||||
// returns arbitrary non-zero values for both (a <text> reported 78/48 while
|
||||
// its rendered length sat comfortably inside its box), so the delta is
|
||||
// noise, not overflow. SVG clips to its own viewport anyway.
|
||||
if (el.namespaceURI === 'http://www.w3.org/2000/svg') return [];
|
||||
if (!isRenderedForBrowserRule(el)) return [];
|
||||
// Only the element that actually owns overflowing text — not its ancestors,
|
||||
// which inherit a wider scrollWidth from the spilling descendant.
|
||||
@@ -5246,6 +5328,22 @@ function isPaintedForOcclusion(el) {
|
||||
// path is pure geometry and runs anywhere on the page.
|
||||
const OCCLUSION_TEXT_SKIP_TAGS = new Set(['script', 'style', 'noscript', 'template', 'title']);
|
||||
|
||||
// An element whose effective opacity multiplies out to ~0 paints nothing at
|
||||
// rest: it is not user-visible, so visual findings on it (contrast, occlusion)
|
||||
// measure a state nobody sees. Browser-only — the walk needs live computed
|
||||
// styles. Cycling scenes that fade such elements in later are the screenshot
|
||||
// subsystem's territory, not the analytic walk's.
|
||||
function effectiveOpacityDOM(el) {
|
||||
let o = 1;
|
||||
// Walk all the way through body and html: `body { opacity: 0 }` page-fade
|
||||
// wrappers hide every descendant just as thoroughly as a local wrapper.
|
||||
for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) {
|
||||
o *= parseFloat(getComputedStyle(cur).opacity || '1');
|
||||
if (o <= 0.02) return 0;
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
function checkTextOcclusionDOM() {
|
||||
const findings = [];
|
||||
const seenVictims = new Set();
|
||||
@@ -5273,6 +5371,11 @@ function checkTextOcclusionDOM() {
|
||||
}
|
||||
return false;
|
||||
};
|
||||
// The classic occluder shape this rules out is an opacity-0 interaction
|
||||
// layer — a range scrubber stretched over a before/after comparison — which
|
||||
// elementFromPoint still returns and whose UA background-color otherwise
|
||||
// reads as an opaque box.
|
||||
const effectiveOpacity = effectiveOpacityDOM;
|
||||
|
||||
// Collect renderable text owners in / near the first viewport for the
|
||||
// elementFromPoint probe. SVG <text> counts too.
|
||||
@@ -5285,6 +5388,7 @@ function checkTextOcclusionDOM() {
|
||||
const text = inSvg ? (el.textContent || '').trim() : elementDirectText(el);
|
||||
if (text.length < 2) continue;
|
||||
if (!isPaintedForOcclusion(el)) continue;
|
||||
if (effectiveOpacity(el) <= 0.02) continue;
|
||||
let rect; try { rect = el.getBoundingClientRect(); } catch { continue; }
|
||||
if (rect.width < 6 || rect.height < 6) continue;
|
||||
// Viewport-bound probe: keep text whose box overlaps the live viewport.
|
||||
@@ -5318,6 +5422,7 @@ function checkTextOcclusionDOM() {
|
||||
if (top === el || el.contains(top) || top.contains(el)) continue;
|
||||
const topCs = getComputedStyle(top);
|
||||
if (isFloated(topCs) || isMarqueeish(top, topCs) || isPinnedOverlay(top)) continue;
|
||||
if (effectiveOpacity(top) <= 0.02) continue;
|
||||
const topTag = top.tagName.toLowerCase();
|
||||
// Text sitting under a raw image/video is contrast territory (deduped
|
||||
// against the pixel low-contrast rule); leave those alone here.
|
||||
@@ -5528,6 +5633,7 @@ export {
|
||||
CSS_NAMED_COLORS,
|
||||
checkBorders,
|
||||
isEmojiOnlyText,
|
||||
scopedIgnoreActive,
|
||||
checkColors,
|
||||
checkHoverContrast,
|
||||
checkElementHoverContrast,
|
||||
|
||||
Reference in New Issue
Block a user