mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 06:06:37 +03:00
data-impeccable-ignore scoped waivers + occlusion and image-backed contrast FP fixes (#559)
* Add data-impeccable-ignore scoped waivers; fix occlusion and image-backed contrast FPs Three changes that let a page hosting deliberate anti-pattern exhibits scan clean without losing coverage, prepared with AI assistance (Claude Code) on maintainer instruction: - data-impeccable-ignore="rule-a rule-b" (or "*" / bare) on any element suppresses matching findings for its whole subtree, in the browser overlay, the extension, and the static engine. The DOM twin of the line-based impeccable-disable comments (which a live DOM cannot apply) and the generalization of data-impeccable-allow-kickers. Applied at the addBrowserFindings choke point, at the static element walk, and for regex findings that carry a live selector. - text-occlusion: an occluder whose effective opacity multiplies out to ~0 paints nothing. An opacity-0 range scrubber stretched over a before/after comparison produced 16 "100% covered by an opaque element" findings on one page because elementFromPoint returns it and its UA background-color read as opaque paint. Invisible-at-rest elements are also no longer probed as victims. - Analytic contrast now skips what it cannot measure: a url() image layer anywhere in the background stack ends the gradient-stops walk (dark ink on a bright gold-leaf image measured 2.6:1 against the wash composited over the wrong base), and elements that are invisible at rest (visibility hidden, effective opacity ~0 — hidden scene decks) are skipped by the color checks in both engines. The static cascade now tracks opacity to support this. Covered by a new scoped-ignore fixture (exact rule, star, comma list, nested depth, wrong-rule control) tested in both engines, a scrubber pass case in the occlusion fixture, and image-backed / photo-panel / hidden-scene pass cases in the gradient-ground fixture. Full suite passes; browser and extension bundles regenerated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * CSS-scan findings carry their enclosing selector; browser pass resolves them Page-level CSS-text findings (marquee, dark-glow, radial-halo, repeating-stripes, codex-grid, ai-color-palette, image-hover-transform, pseudo/inset side-tab stripes) now attach the selector of the rule that matched, via a best-effort enclosingCssSelector() helper or the selector already in scope. The browser pass resolves that selector against the live DOM: pseudo segments are stripped, a selector that renders nowhere on the page drops the finding (the CSS ships there but the pattern never paints — the live DOM is ground truth in a browser scan), and matches under a data-impeccable-ignore ancestor are waived. Static scans are unchanged: partial documents keep the text-level findings. Applied with AI assistance (Claude Code). Covered in the scoped-ignore fixture: a live marquee under a marquee waiver is suppressed, and dead two-axis grid CSS matching no element is dropped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Attribute selectors on gradient-text and bounce-easing page emitters too Same mechanism as the previous commit, extended to the three page-level motion/text emitters that were still selector-less. Applied with AI assistance (Claude Code). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * text-overflow: skip SVG content; scrollWidth lies there Chrome reports arbitrary non-zero scrollWidth/clientWidth on SVG elements (a <text> gave 78/48 while its rendered length sat inside its box), so the box-metric delta is noise. SVG clips to its own viewport anyway. Pass case added to the quality fixture. Applied with AI assistance (Claude Code). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Overlay samples image-backed text at the pixel level by default Visual contrast gains a third mode. Explicit true still runs the full sampled pass, explicit false still disables everything (the mode the test suites use), and unset — the default overlay run — now samples ONLY image-backed text: the one class the analytic walk deliberately skips, because a url() layer's pixels are unknowable without looking. The cost is bounded and the method is precise: at most a 3x3 grid of sample points per candidate (degrading to 3 or 1 for small rects), the source image drawn once to a canvas with only those pixels read, and glyph ink never pollutes the samples because the image is drawn alone. A cross-origin image without CORS headers reports unresolved rather than guessing. Applied with AI assistance (Claude Code). Covered by a new fixture: white text on a near-white same-origin data-URI image background flags via sampled pixels under default options; dark ink on the same image passes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Review fixes: root opacity, keyframe steps, static parity, attributed fixtures Applied with AI assistance (Claude Code), addressing all seven findings from the automated reviews: - effectiveOpacityDOM walks through body and html: a page-fade wrapper with body/html opacity 0 hides every descendant (Greptile executed a Chromium repro of the false positive). - enclosingCssSelector refuses `from`/`to` keyframe steps, which read as never-matching type selectors and got valid findings wrongly dropped by the zero-match rule (Bugbot, high). Regression case: an overshoot bezier inside a `to` step must survive as page-level. - The static cascade now inherits visibility, so descendants of a hidden container compute as hidden like the browser path; a declared visibility:visible still overrides. - The static engine applies scoped waivers to selector-backed html-pattern findings, mirroring the browser — but keeps findings whose selector matches nothing, since static scans see partial documents. - The scoped-ignore fixture grows to the mandated matrix: 4 flag cases (control, other-rule waiver, sibling waiver, misspelled rule id) and 5 waived shapes (exact rule, nested depth, star, comma list, self), each with a unique border width so every finding attributes to exactly one case in both engines' tests. - The image-backed contrast test pins its cases via the sampled finding's candidate text: the white-on-light specimen must flag and the dark-ink control must stay clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Review fixes: image-only starvation, selector rejection class, inset stripes Second review round, applied with AI assistance (Claude Code): - The image-only filter moves inside the candidate collector, before the cap: gradient/opacity/filter candidates earlier in DOM order no longer consume the 12-candidate budget and starve the url()-backed texts the mode exists to sample (Bugbot, high). The regression fixture packs 14 gradient decoys ahead of the photo panels, and the test now drives the overlay entry (impeccableDetectAsync, default options) rather than detectUrl's Node-side full fallback, which is where the image-only mode actually lives. - enclosingCssSelector no longer rejects the child combinator or quoted attribute selectors; only braces and angle brackets disqualify. - The inset box-shadow side-tab scanner attaches its selector like the pseudo-element scanner does, so those findings waive and dead-drop the same way. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
8e62ab47ba
commit
aee5ddd10c
@@ -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
|
||||
|
||||
+120
-14
@@ -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