detector: script-error, content-hidden-at-rest, edge-flush-cards + chip contrast and inline-overflow widenings (53 -> 56)

Three new rules and three widenings, all from confirmed eval-corpus
escapes found by eye:

script-error (quality, error severity, URL engine): pageerror listener
attached before goto catches uncaught exceptions AND parse errors (a
syntax error fires during the initial parse, long before load). Deduped
by message, capped at 3. A JS typo was silently deleting whole pages.

content-hidden-at-rest (quality, error, URL engine): after the main
at-rest scan, an instant-scroll reveal sweep (bypasses scroll-behavior:
smooth, which silently defeated the first sweep design) gives every
IntersectionObserver reveal its chance to fire, returns to top, then
measures the share of text characters still at opacity 0 / visibility
hidden. display:none / [hidden] / aria-hidden subtrees stay out of the
denominator. Fires above 30% with a 200/150-char floor. Calibration on
30 corpus samples: broken repro holds 83% after the sweep, all clean
samples (including 0.75-0.93 at-rest reveal pages) drop to <= 7%.

edge-flush-cards (quality, warning, browser): cards with their own
opaque background or 2+ borders inside a horizontal scroller, flush
against one edge of the clip box at rest (< 8px, > -24px so deliberate
mid-card peeks stay exempt) while keeping a gutter on the other side.
Grouped per scroller. Repro: transit-mobile pager whose first snap
panel is 407px wide inside a 390px clip. New --viewport WxH CLI flag
makes mobile-width URL scans reachable (--viewport 390x844).

Chip/badge contrast widening: the SAFE_TAGS styled-button exception in
checkColors now covers any text-bearing element painting its own opaque
background at >= 9px font, not just a/button. The shipped miss: a span
SEV-2 chip whose white text lost a specificity fight and rendered
muted-on-red at 1.2:1. Static adapter also resolves var() own-bg via
the custom-property map so the gate engages on token backgrounds.

background:none cascade fix: the background shorthand now resets
background-color/-image when it names neither (and no var()). Exposed
by the chip widening: pre code { background: none } left an earlier
surface color standing and manufactured 1.1:1 phantom findings.

text-overflow inline-owner widening: inline elements have no client
geometry (clientWidth 0) so the scrollWidth path never saw them, and
their block parent owns no direct text. New branch measures the inline
rect against the nearest block container's padding box (16px floor,
transform-path exempt). Repro: nowrap span.v spilling 45px past its
grid cell.

The round-3 nav-CTA contrast escape (val-a22-opus obs 003 header CTA)
was verified already covered at HEAD by the earlier parseAnyColor
oklch fallback; both engines fire 3.6:1 on the repro, no change needed.

FP sweep across 36 val-a21/a22/a23 samples: new rules fire only on
their repros (script-error also catches a second genuinely broken
sample); static-engine delta is limited to the chip repro plus two
borderline-but-real chip findings on one sample.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-07-14 17:10:44 -07:00
co-authored by Claude Fable 5
parent d3599d7895
commit c98f5d42ed
17 changed files with 872 additions and 29 deletions
+10
View File
@@ -1556,6 +1556,13 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, f.el || document.body, [{ type: f.type, detail: f.detail }]);
}
// Edge-flush cards in horizontal scrollers (browser-only: needs real
// layout for the scroller clip box vs card rect math)
const edgeFlushFindings = checkEdgeFlushCardsDOM().filter(f => _ruleOk(f.type));
for (const f of edgeFlushFindings) {
addBrowserFindings(groupMap, f.el || document.body, [{ type: f.type, detail: f.detail }]);
}
// Page-level quality checks (headings, etc.)
const qualityFindings = checkPageQualityDOM().filter(f => _ruleOk(f.type));
if (qualityFindings.length > 0) {
@@ -1955,6 +1962,9 @@ if (IS_BROWSER) {
window.impeccableDetectAsync = detectAsync;
window.impeccableScan = scan;
window.impeccableScanAsync = scanAsync;
// Raw measurement for the URL engine's content-hidden-at-rest pass: it
// drives a reveal sweep from Node and thresholds the result itself.
window.impeccableMeasureHiddenText = measureHiddenTextDOM;
window.impeccableCollectVisualContrastCandidates = collectVisualContrastCandidates;
window.impeccableAnalyzeVisualContrast = analyzeVisualContrast;
window.impeccableGetLastVisualContrastAnalyses = () => lastVisualContrastAnalyses.slice();
+17
View File
@@ -96,6 +96,8 @@ Options:
--gemini Also report Gemini-specific provider tells (off by default)
--scope <name> Only report rules in the given design domain
(type, layout). Comma-separated.
--viewport <WxH> Browser viewport for URL scans (default 1280x800),
e.g. --viewport 390x844 for a mobile-width pass
--no-config Do not apply project config, detector ignores, inline
ignore comments, or DESIGN.md
--no-inline-ignores Do not honor in-file impeccable-disable* ignore comments
@@ -175,6 +177,20 @@ async function detectCli() {
args.splice(i, inline ? 1 : 2);
i -= 1;
}
let viewport = null;
for (let i = 0; i < args.length; i++) {
if (args[i] !== '--viewport' && !args[i].startsWith('--viewport=')) continue;
const inline = args[i].startsWith('--viewport=');
const value = inline ? args[i].slice('--viewport='.length) : args[i + 1];
const match = /^(\d{2,5})x(\d{2,5})$/i.exec(value || '');
if (!match) {
process.stderr.write('Error: --viewport requires a WxH value, e.g. --viewport 390x844\n');
process.exit(1);
}
viewport = { width: Number(match[1]), height: Number(match[2]) };
args.splice(i, inline ? 1 : 2);
i -= 1;
}
const unknownScopes = scopes.filter(s => !RULE_SCOPES.has(s));
if (unknownScopes.length > 0) {
process.stderr.write(
@@ -190,6 +206,7 @@ async function detectCli() {
const inlineIgnoresEnabled = configEnabled && !args.includes('--no-inline-ignores');
const scanOptions = { providers, inlineIgnores: inlineIgnoresEnabled };
if (designSystem) scanOptions.designSystem = designSystem;
if (viewport) scanOptions.viewport = viewport;
const targets = args.filter(a => !a.startsWith('--'));
if (helpMode) { printUsage(); process.exit(0); }
+235 -11
View File
@@ -372,6 +372,31 @@ const ANTIPATTERNS = [
},
// ── Quality: general design and accessibility issues ──
{
id: 'script-error',
category: 'quality',
severity: 'error',
name: 'Uncaught script error on load',
description:
'A script threw an uncaught exception or failed to parse while the page loaded. Broken JavaScript silently kills reveals, interactions, and dynamic content, and can leave most of a page invisible. Fix the error before judging anything else.',
},
{
id: 'content-hidden-at-rest',
category: 'quality',
severity: 'error',
scopes: ['layout'],
name: 'Content invisible at rest',
description:
'A large share of the page text sits at opacity 0 or visibility hidden even after every reveal handler had a chance to run. This is the failed-reveal signature: the content shipped but never becomes visible. Make content visible by default and let JavaScript enhance its entrance instead of gating its existence.',
},
{
id: 'edge-flush-cards',
category: 'quality',
scopes: ['layout'],
name: 'Cards flush against the scroller edge',
description:
'Cards inside a horizontal scroller or tab panel sit flush against the container edge at rest while keeping a gutter on the other side, so their edges and rounded corners get cut off. Usually the panel is sized wider than its clip box. Keep a consistent inset on both sides.',
},
{
id: 'gray-on-color',
category: 'quality',
@@ -817,16 +842,20 @@ function isEmojiOnlyText(text) {
function checkColors(opts) {
const { tag, textColor, bgColor, effectiveBg, effectiveBgStops, fontSize, fontWeight, hasDirectText, isEmojiOnly, bgClip, bgImage, classList } = opts;
if (SAFE_TAGS.has(tag)) {
// Exception for <a> and <button> elements styled as buttons. SAFE_TAGS
// exists to suppress contrast noise on inline links and unstyled controls,
// where the element has no own background and the contrast against the
// ancestor surface is already the intended visual. When the element has
// its own opaque background and direct text, it is a styled button — and
// contrast on its own surface is a real, frequent bug worth flagging.
const isStyledButton = (tag === 'a' || tag === 'button')
&& hasDirectText
&& bgColor && bgColor.a > 0.5;
if (!isStyledButton) return [];
// Exception for elements styled as controls or chips. SAFE_TAGS exists to
// suppress contrast noise on inline links and unstyled spans, where the
// element has no own background and the contrast against the ancestor
// surface is already the intended visual. When the element paints its own
// opaque background under direct text, it is a styled button, chip, or
// badge regardless of tag, and contrast on its own surface is a real,
// frequent bug worth flagging. (The shipped miss: a <span> severity chip
// whose white text lost a specificity fight and rendered muted-on-red at
// 1.2:1; the old a/button-only exception never looked at it.) The 9px
// font floor keeps sub-text decorations out.
const isStyledControl = hasDirectText
&& bgColor && bgColor.a > 0.5
&& fontSize >= 9;
if (!isStyledControl) return [];
}
const findings = [];
@@ -3740,10 +3769,17 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
}
}
// Own background: resolve var()/oklch() tokens through the custom-property
// map first (mirrors the textColor path above). Without this a chip whose
// background is `var(--sev)` reads as no-own-bg in the static engine and
// the styled-control contrast exception never engages.
const ownBg = (customPropMap ? parseColorResolved(style.backgroundColor, customPropMap) : null)
|| readOwnBackgroundColor(el, style);
return checkColors({
tag,
textColor,
bgColor: readOwnBackgroundColor(el, style),
bgColor: ownBg,
effectiveBg,
effectiveBgStops: effectiveBg ? null : resolveGradientStops(el, window),
fontSize: parseFloat(style.fontSize) || 16,
@@ -4877,6 +4913,29 @@ function checkElementTextOverflowDOM(el) {
if (el.clientWidth > 0 && delta >= 16) {
return [{ id: 'text-overflow', snippet: `${classSelector(el)} overflows its box by ${Math.round(delta)}px` }];
}
// Inline text owners have no client geometry (clientWidth/scrollWidth are
// both 0), so the scrollWidth path above never sees them. Their overflow
// registers only on a block ancestor, and that ancestor has no direct text
// so the ownership gate skips it. (The shipped miss: a nowrap inline
// <span> spilling 45px past its fixed-width grid cell.) Measure the inline
// box against the padding box of its nearest block container instead.
if (el.clientWidth === 0 && rect && rect.width > 0) {
let container = el.parentElement;
while (container && container.clientWidth === 0) container = container.parentElement;
if (!container) return [];
// Transforms make rect comparisons lie; skip anything on that path.
for (let p = el; p && p !== container.parentElement; p = p.parentElement) {
const t = getComputedStyle(p).transform;
if (t && t !== 'none') return [];
}
const cRect = container.getBoundingClientRect();
const contentRight = cRect.left + container.clientLeft + container.clientWidth;
const spill = rect.right - contentRight;
if (spill >= 16) {
return [{ id: 'text-overflow', snippet: `${classSelector(el)} overflows its container by ${Math.round(spill)}px` }];
}
}
return [];
}
@@ -4984,6 +5043,161 @@ function checkElementBlinkingCursorDOM(el) {
}];
}
// ---------------------------------------------------------------------------
// Content invisible at rest (browser-only, driven by the URL engine)
// ---------------------------------------------------------------------------
// Tags whose text never renders, or whose hidden state is legitimate UI
// (templates, dialogs, native select options). Text inside them stays out of
// both the numerator and the denominator.
const HIDDEN_TEXT_EXCLUDE_TAGS = new Set([
'script', 'style', 'noscript', 'template', 'title', 'head', 'meta', 'link',
'option', 'optgroup', 'select', 'datalist', 'dialog',
]);
// Measure how many text characters currently render invisible (computed
// opacity ~0 or visibility hidden anywhere on the ancestor chain) versus
// visible. display:none / [hidden] / aria-hidden subtrees are legitimately
// hidden UI (menus, tab panels, templates): they are excluded from the
// denominator entirely rather than counted as invisible.
function measureHiddenTextDOM() {
const cache = new Map();
function stateOf(el) {
if (!el || el.nodeType !== 1 || el === document.documentElement) return 'visible';
const cached = cache.get(el);
if (cached) return cached;
let state;
const tag = el.tagName.toLowerCase();
if (HIDDEN_TEXT_EXCLUDE_TAGS.has(tag)) {
state = 'excluded';
} else {
const parentState = stateOf(el.parentElement);
if (parentState === 'excluded') {
state = 'excluded';
} else {
const style = getComputedStyle(el);
if (style.display === 'none' || el.hidden || el.getAttribute('aria-hidden') === 'true'
|| String(style.contentVisibility || '').toLowerCase() === 'hidden') {
state = 'excluded';
} else if (parentState === 'invisible'
|| (parseFloat(style.opacity) || 0) <= 0.02
|| /^(hidden|collapse)$/.test(style.visibility)) {
state = 'invisible';
} else {
state = 'visible';
}
}
}
cache.set(el, state);
return state;
}
let totalChars = 0;
let hiddenChars = 0;
const hiddenSamples = [];
for (const el of document.querySelectorAll('body *')) {
let len = 0;
for (const node of el.childNodes) {
if (node.nodeType === 3) len += node.textContent.replace(/\s+/g, ' ').trim().length;
}
if (!len) continue;
const state = stateOf(el);
if (state === 'excluded') continue;
totalChars += len;
if (state === 'invisible') {
hiddenChars += len;
if (hiddenSamples.length < 3) {
const text = String(el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 40);
if (text) hiddenSamples.push(text);
}
}
}
return { totalChars, hiddenChars, hiddenSamples };
}
// Pure threshold check over a measureHiddenTextDOM() result. The URL engine
// calls it AFTER a reveal sweep (scroll through the document so every
// IntersectionObserver / scroll reveal had its chance to fire, then back to
// the top): a healthy reveal-on-scroll page drops to ~0 invisible text after
// the sweep, while a page whose reveal script died keeps most of its text at
// opacity 0 forever. Fires only when the invisible share stays above 30%
// with a real amount of text behind it.
function checkContentHiddenAtRest({ totalChars = 0, hiddenChars = 0, hiddenSamples = [] } = {}) {
if (totalChars < 200 || hiddenChars < 150) return [];
const share = hiddenChars / totalChars;
if (share <= 0.3) return [];
const sample = hiddenSamples.length ? ` (e.g. "${hiddenSamples[0]}")` : '';
return [{
id: 'content-hidden-at-rest',
snippet: `${Math.round(share * 100)}% of page text (${hiddenChars} of ${totalChars} chars) stays at opacity 0 / visibility hidden after reveal handlers ran${sample}`,
}];
}
// ---------------------------------------------------------------------------
// Edge-flush cards in horizontal scrollers (browser-only)
// ---------------------------------------------------------------------------
// A visually-defined card (own opaque background, or borders on 2+ sides)
// inside a horizontal scroller, sitting flush against one edge of the
// scroller's clip box at rest while keeping a clear gutter on the other
// side. The canonical bug: the first snap panel is sized wider than the
// scroller, so its cards end exactly at the clip edge with their rounded
// corners cut, while every sibling panel keeps its inset. Cards that extend
// far past the edge are deliberate peeks and stay exempt.
function checkEdgeFlushCardsDOM() {
const findings = [];
const vh = window.innerHeight || 800;
const isScroller = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || '');
for (const scroller of document.querySelectorAll('*')) {
const style = getComputedStyle(scroller);
if (!isScroller(style)) continue;
if (scroller.scrollWidth <= scroller.clientWidth + 8) continue;
// At rest only: a user-scrolled or snapped-forward scroller legitimately
// shows cut cards at both edges.
if (scroller.scrollLeft > 4) continue;
const scRect = scroller.getBoundingClientRect();
if (scRect.width < 120 || scRect.height < 60) continue;
// Landing-region gate: the defect matters where the page opens.
if (scRect.top + (window.scrollY || 0) > 2 * vh) continue;
const contentLeft = scRect.left + scroller.clientLeft;
const contentRight = contentLeft + scroller.clientWidth;
const flush = [];
for (const card of scroller.querySelectorAll('*')) {
if (!isRenderedForBrowserRule(card)) continue;
// Attribute cards to their nearest scroller only (nested scrollers).
let owner = card.parentElement;
while (owner && owner !== scroller && !isScroller(getComputedStyle(owner))) owner = owner.parentElement;
if (owner !== scroller) continue;
const cs = getComputedStyle(card);
const rect = card.getBoundingClientRect();
if (rect.width < 80 || rect.height < 40) continue;
const bg = parseAnyColor(cs.backgroundColor || '');
const hasBg = !!(bg && (bg.a ?? 1) > 0.5);
const borderSides = ['Top', 'Right', 'Bottom', 'Left']
.filter(side => (parseFloat(cs[`border${side}Width`]) || 0) > 0).length;
if (!hasBg && borderSides < 2) continue;
const leftGutter = rect.left - contentLeft;
const rightGap = contentRight - rect.right;
// Flush right with a left gutter, or the mirror. The -24 floor keeps
// deliberately peeking next-cards (cut mid-card) exempt.
const flushRight = leftGutter >= 6 && rightGap < 8 && rightGap > -24;
const flushLeft = rightGap >= 6 && leftGutter < 8 && leftGutter > -24;
if (!flushRight && !flushLeft) continue;
flush.push({ card, edge: flushRight ? 'right' : 'left', gap: Math.round(flushRight ? rightGap : leftGutter) });
}
if (flush.length === 0) continue;
const worst = flush.reduce((a, b) => (b.gap < a.gap ? b : a));
findings.push({
el: scroller,
type: 'edge-flush-cards',
detail: `${flush.length} card${flush.length === 1 ? '' : 's'} flush against the ${worst.edge} edge of ${classSelector(scroller)} at rest (${worst.gap}px gap, e.g. ${classSelector(worst.card)})`,
});
}
return findings;
}
// --- cli/engine/browser/injected/index.mjs ---
const IS_BROWSER = typeof window !== 'undefined';
@@ -6543,6 +6757,13 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, f.el || document.body, [{ type: f.type, detail: f.detail }]);
}
// Edge-flush cards in horizontal scrollers (browser-only: needs real
// layout for the scroller clip box vs card rect math)
const edgeFlushFindings = checkEdgeFlushCardsDOM().filter(f => _ruleOk(f.type));
for (const f of edgeFlushFindings) {
addBrowserFindings(groupMap, f.el || document.body, [{ type: f.type, detail: f.detail }]);
}
// Page-level quality checks (headings, etc.)
const qualityFindings = checkPageQualityDOM().filter(f => _ruleOk(f.type));
if (qualityFindings.length > 0) {
@@ -6942,6 +7163,9 @@ if (IS_BROWSER) {
window.impeccableDetectAsync = detectAsync;
window.impeccableScan = scan;
window.impeccableScanAsync = scanAsync;
// Raw measurement for the URL engine's content-hidden-at-rest pass: it
// drives a reveal sweep from Node and thresholds the result itself.
window.impeccableMeasureHiddenText = measureHiddenTextDOM;
window.impeccableCollectVisualContrastCandidates = collectVisualContrastCandidates;
window.impeccableAnalyzeVisualContrast = analyzeVisualContrast;
window.impeccableGetLastVisualContrastAnalyses = () => lastVisualContrastAnalyses.slice();
+61
View File
@@ -6,6 +6,34 @@ import { finding } from '../../findings.mjs';
import { filterByProviders } from '../../registry/antipatterns.mjs';
import { profileFindingsAsync, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import { captureVisualContrastCandidate } from '../visual/screenshot-contrast.mjs';
import { checkContentHiddenAtRest } from '../../rules/checks.mjs';
// Reveal sweep + invisible-text measurement for the content-hidden-at-rest
// rule. Scrolls through the document with instant jumps (bypasses CSS
// scroll-behavior: smooth) so IntersectionObserver / scroll reveal handlers
// get every chance to fire, returns to the top, lets transitions settle,
// then measures how much text still renders invisible. A healthy
// reveal-on-scroll page drops to ~0 after the sweep; a page whose reveal
// script died keeps most of its text at opacity 0.
async function measureContentHiddenAfterReveal(page) {
await page.evaluate(async () => {
const step = Math.max(200, Math.floor(window.innerHeight * 0.7));
const max = Math.max(
document.documentElement.scrollHeight || 0,
document.body?.scrollHeight || 0,
);
for (let y = 0; y <= max; y += step) {
window.scrollTo({ top: y, left: 0, behavior: 'instant' });
await new Promise(resolve => requestAnimationFrame(() => setTimeout(resolve, 40)));
}
window.scrollTo({ top: 0, left: 0, behavior: 'instant' });
await new Promise(resolve => setTimeout(resolve, 700));
});
return page.evaluate(() => {
if (typeof window.impeccableMeasureHiddenText !== 'function') return null;
return window.impeccableMeasureHiddenText();
});
}
function serializeDesignSystemForBrowser(designSystem) {
if (!designSystem?.present) return null;
@@ -158,6 +186,19 @@ async function detectUrl(url, options = {}) {
ruleId: 'new-page',
target: url,
}, () => browser.newPage());
// Uncaught exceptions and parse errors surface as pageerror events. The
// listener must attach before goto: a syntax error fires during the
// initial parse, long before the load event. Dedupe by message; a single
// broken loop can otherwise throw hundreds of identical errors.
const pageErrors = [];
if (options?.scriptErrors !== false) {
page.on('pageerror', (err) => {
const message = String(err?.message || err).split('\n')[0].trim().slice(0, 160);
if (message && !pageErrors.includes(message)) pageErrors.push(message);
});
}
let results = [];
try {
await profileStepAsync(profile, {
@@ -216,6 +257,26 @@ async function detectUrl(url, options = {}) {
findings.map(f => ({ id: f.type, snippet: f.detail, ignoreValue: f.ignoreValue || '' }))
);
});
// Content invisible at rest: reveal sweep, then re-measure. Runs after
// the main scan (which must see the true at-rest state) and before the
// visual contrast fallback (the sweep restores scroll to the top).
if (options?.contentHidden !== false) {
const hiddenFindings = await profileFindingsAsync(profile, {
engine: 'browser',
phase: 'scan',
ruleId: 'content-hidden-at-rest',
target: url,
}, async () => {
const measured = await measureContentHiddenAfterReveal(page);
return measured ? checkContentHiddenAtRest(measured) : [];
});
results.push(...hiddenFindings);
}
for (const message of pageErrors.slice(0, 3)) {
results.push({ id: 'script-error', snippet: message });
}
const visualFindings = await runVisualContrastFallback(page, serializedGroups, options, profile, url);
results.push(...visualFindings);
} finally {
@@ -553,6 +553,15 @@ function expandStaticDeclaration(prop, value) {
const beforeImage = hasImage ? v.split(/(?:repeating-)?(?:linear|radial|conic)-gradient\(|url\(/i)[0] : v;
const color = extractStaticColor(hasImage ? beforeImage : v);
if (color) out.push(['backgroundColor', color]);
// The `background` shorthand resets every longhand it does not set.
// Without this, `pre code { background: none }` leaves an earlier
// `background: var(--surface)` color standing and the contrast checks
// measure text against a surface the browser never paints. var() values
// stay untouched: they may resolve to a color later in the pipeline.
if (!color && !hasImage && !/var\(/i.test(v)) {
out.push(['backgroundColor', 'rgba(0, 0, 0, 0)']);
out.push(['backgroundImage', 'none']);
}
return out;
}
if (p === 'border') {
+25
View File
@@ -270,6 +270,31 @@ const ANTIPATTERNS = [
},
// ── Quality: general design and accessibility issues ──
{
id: 'script-error',
category: 'quality',
severity: 'error',
name: 'Uncaught script error on load',
description:
'A script threw an uncaught exception or failed to parse while the page loaded. Broken JavaScript silently kills reveals, interactions, and dynamic content, and can leave most of a page invisible. Fix the error before judging anything else.',
},
{
id: 'content-hidden-at-rest',
category: 'quality',
severity: 'error',
scopes: ['layout'],
name: 'Content invisible at rest',
description:
'A large share of the page text sits at opacity 0 or visibility hidden even after every reveal handler had a chance to run. This is the failed-reveal signature: the content shipped but never becomes visible. Make content visible by default and let JavaScript enhance its entrance instead of gating its existence.',
},
{
id: 'edge-flush-cards',
category: 'quality',
scopes: ['layout'],
name: 'Cards flush against the scroller edge',
description:
'Cards inside a horizontal scroller or tab panel sit flush against the container edge at rest while keeping a gutter on the other side, so their edges and rounded corners get cut off. Usually the panel is sized wider than its clip box. Keep a consistent inset on both sides.',
},
{
id: 'gray-on-color',
category: 'quality',
+203 -11
View File
@@ -80,16 +80,20 @@ function isEmojiOnlyText(text) {
function checkColors(opts) {
const { tag, textColor, bgColor, effectiveBg, effectiveBgStops, fontSize, fontWeight, hasDirectText, isEmojiOnly, bgClip, bgImage, classList } = opts;
if (SAFE_TAGS.has(tag)) {
// Exception for <a> and <button> elements styled as buttons. SAFE_TAGS
// exists to suppress contrast noise on inline links and unstyled controls,
// where the element has no own background and the contrast against the
// ancestor surface is already the intended visual. When the element has
// its own opaque background and direct text, it is a styled button — and
// contrast on its own surface is a real, frequent bug worth flagging.
const isStyledButton = (tag === 'a' || tag === 'button')
&& hasDirectText
&& bgColor && bgColor.a > 0.5;
if (!isStyledButton) return [];
// Exception for elements styled as controls or chips. SAFE_TAGS exists to
// suppress contrast noise on inline links and unstyled spans, where the
// element has no own background and the contrast against the ancestor
// surface is already the intended visual. When the element paints its own
// opaque background under direct text, it is a styled button, chip, or
// badge regardless of tag, and contrast on its own surface is a real,
// frequent bug worth flagging. (The shipped miss: a <span> severity chip
// whose white text lost a specificity fight and rendered muted-on-red at
// 1.2:1; the old a/button-only exception never looked at it.) The 9px
// font floor keeps sub-text decorations out.
const isStyledControl = hasDirectText
&& bgColor && bgColor.a > 0.5
&& fontSize >= 9;
if (!isStyledControl) return [];
}
const findings = [];
@@ -3003,10 +3007,17 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
}
}
// Own background: resolve var()/oklch() tokens through the custom-property
// map first (mirrors the textColor path above). Without this a chip whose
// background is `var(--sev)` reads as no-own-bg in the static engine and
// the styled-control contrast exception never engages.
const ownBg = (customPropMap ? parseColorResolved(style.backgroundColor, customPropMap) : null)
|| readOwnBackgroundColor(el, style);
return checkColors({
tag,
textColor,
bgColor: readOwnBackgroundColor(el, style),
bgColor: ownBg,
effectiveBg,
effectiveBgStops: effectiveBg ? null : resolveGradientStops(el, window),
fontSize: parseFloat(style.fontSize) || 16,
@@ -4140,6 +4151,29 @@ function checkElementTextOverflowDOM(el) {
if (el.clientWidth > 0 && delta >= 16) {
return [{ id: 'text-overflow', snippet: `${classSelector(el)} overflows its box by ${Math.round(delta)}px` }];
}
// Inline text owners have no client geometry (clientWidth/scrollWidth are
// both 0), so the scrollWidth path above never sees them. Their overflow
// registers only on a block ancestor, and that ancestor has no direct text
// so the ownership gate skips it. (The shipped miss: a nowrap inline
// <span> spilling 45px past its fixed-width grid cell.) Measure the inline
// box against the padding box of its nearest block container instead.
if (el.clientWidth === 0 && rect && rect.width > 0) {
let container = el.parentElement;
while (container && container.clientWidth === 0) container = container.parentElement;
if (!container) return [];
// Transforms make rect comparisons lie; skip anything on that path.
for (let p = el; p && p !== container.parentElement; p = p.parentElement) {
const t = getComputedStyle(p).transform;
if (t && t !== 'none') return [];
}
const cRect = container.getBoundingClientRect();
const contentRight = cRect.left + container.clientLeft + container.clientWidth;
const spill = rect.right - contentRight;
if (spill >= 16) {
return [{ id: 'text-overflow', snippet: `${classSelector(el)} overflows its container by ${Math.round(spill)}px` }];
}
}
return [];
}
@@ -4247,6 +4281,161 @@ function checkElementBlinkingCursorDOM(el) {
}];
}
// ---------------------------------------------------------------------------
// Content invisible at rest (browser-only, driven by the URL engine)
// ---------------------------------------------------------------------------
// Tags whose text never renders, or whose hidden state is legitimate UI
// (templates, dialogs, native select options). Text inside them stays out of
// both the numerator and the denominator.
const HIDDEN_TEXT_EXCLUDE_TAGS = new Set([
'script', 'style', 'noscript', 'template', 'title', 'head', 'meta', 'link',
'option', 'optgroup', 'select', 'datalist', 'dialog',
]);
// Measure how many text characters currently render invisible (computed
// opacity ~0 or visibility hidden anywhere on the ancestor chain) versus
// visible. display:none / [hidden] / aria-hidden subtrees are legitimately
// hidden UI (menus, tab panels, templates): they are excluded from the
// denominator entirely rather than counted as invisible.
function measureHiddenTextDOM() {
const cache = new Map();
function stateOf(el) {
if (!el || el.nodeType !== 1 || el === document.documentElement) return 'visible';
const cached = cache.get(el);
if (cached) return cached;
let state;
const tag = el.tagName.toLowerCase();
if (HIDDEN_TEXT_EXCLUDE_TAGS.has(tag)) {
state = 'excluded';
} else {
const parentState = stateOf(el.parentElement);
if (parentState === 'excluded') {
state = 'excluded';
} else {
const style = getComputedStyle(el);
if (style.display === 'none' || el.hidden || el.getAttribute('aria-hidden') === 'true'
|| String(style.contentVisibility || '').toLowerCase() === 'hidden') {
state = 'excluded';
} else if (parentState === 'invisible'
|| (parseFloat(style.opacity) || 0) <= 0.02
|| /^(hidden|collapse)$/.test(style.visibility)) {
state = 'invisible';
} else {
state = 'visible';
}
}
}
cache.set(el, state);
return state;
}
let totalChars = 0;
let hiddenChars = 0;
const hiddenSamples = [];
for (const el of document.querySelectorAll('body *')) {
let len = 0;
for (const node of el.childNodes) {
if (node.nodeType === 3) len += node.textContent.replace(/\s+/g, ' ').trim().length;
}
if (!len) continue;
const state = stateOf(el);
if (state === 'excluded') continue;
totalChars += len;
if (state === 'invisible') {
hiddenChars += len;
if (hiddenSamples.length < 3) {
const text = String(el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 40);
if (text) hiddenSamples.push(text);
}
}
}
return { totalChars, hiddenChars, hiddenSamples };
}
// Pure threshold check over a measureHiddenTextDOM() result. The URL engine
// calls it AFTER a reveal sweep (scroll through the document so every
// IntersectionObserver / scroll reveal had its chance to fire, then back to
// the top): a healthy reveal-on-scroll page drops to ~0 invisible text after
// the sweep, while a page whose reveal script died keeps most of its text at
// opacity 0 forever. Fires only when the invisible share stays above 30%
// with a real amount of text behind it.
function checkContentHiddenAtRest({ totalChars = 0, hiddenChars = 0, hiddenSamples = [] } = {}) {
if (totalChars < 200 || hiddenChars < 150) return [];
const share = hiddenChars / totalChars;
if (share <= 0.3) return [];
const sample = hiddenSamples.length ? ` (e.g. "${hiddenSamples[0]}")` : '';
return [{
id: 'content-hidden-at-rest',
snippet: `${Math.round(share * 100)}% of page text (${hiddenChars} of ${totalChars} chars) stays at opacity 0 / visibility hidden after reveal handlers ran${sample}`,
}];
}
// ---------------------------------------------------------------------------
// Edge-flush cards in horizontal scrollers (browser-only)
// ---------------------------------------------------------------------------
// A visually-defined card (own opaque background, or borders on 2+ sides)
// inside a horizontal scroller, sitting flush against one edge of the
// scroller's clip box at rest while keeping a clear gutter on the other
// side. The canonical bug: the first snap panel is sized wider than the
// scroller, so its cards end exactly at the clip edge with their rounded
// corners cut, while every sibling panel keeps its inset. Cards that extend
// far past the edge are deliberate peeks and stay exempt.
function checkEdgeFlushCardsDOM() {
const findings = [];
const vh = window.innerHeight || 800;
const isScroller = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || '');
for (const scroller of document.querySelectorAll('*')) {
const style = getComputedStyle(scroller);
if (!isScroller(style)) continue;
if (scroller.scrollWidth <= scroller.clientWidth + 8) continue;
// At rest only: a user-scrolled or snapped-forward scroller legitimately
// shows cut cards at both edges.
if (scroller.scrollLeft > 4) continue;
const scRect = scroller.getBoundingClientRect();
if (scRect.width < 120 || scRect.height < 60) continue;
// Landing-region gate: the defect matters where the page opens.
if (scRect.top + (window.scrollY || 0) > 2 * vh) continue;
const contentLeft = scRect.left + scroller.clientLeft;
const contentRight = contentLeft + scroller.clientWidth;
const flush = [];
for (const card of scroller.querySelectorAll('*')) {
if (!isRenderedForBrowserRule(card)) continue;
// Attribute cards to their nearest scroller only (nested scrollers).
let owner = card.parentElement;
while (owner && owner !== scroller && !isScroller(getComputedStyle(owner))) owner = owner.parentElement;
if (owner !== scroller) continue;
const cs = getComputedStyle(card);
const rect = card.getBoundingClientRect();
if (rect.width < 80 || rect.height < 40) continue;
const bg = parseAnyColor(cs.backgroundColor || '');
const hasBg = !!(bg && (bg.a ?? 1) > 0.5);
const borderSides = ['Top', 'Right', 'Bottom', 'Left']
.filter(side => (parseFloat(cs[`border${side}Width`]) || 0) > 0).length;
if (!hasBg && borderSides < 2) continue;
const leftGutter = rect.left - contentLeft;
const rightGap = contentRight - rect.right;
// Flush right with a left gutter, or the mirror. The -24 floor keeps
// deliberately peeking next-cards (cut mid-card) exempt.
const flushRight = leftGutter >= 6 && rightGap < 8 && rightGap > -24;
const flushLeft = rightGap >= 6 && leftGutter < 8 && leftGutter > -24;
if (!flushRight && !flushLeft) continue;
flush.push({ card, edge: flushRight ? 'right' : 'left', gap: Math.round(flushRight ? rightGap : leftGutter) });
}
if (flush.length === 0) continue;
const worst = flush.reduce((a, b) => (b.gap < a.gap ? b : a));
findings.push({
el: scroller,
type: 'edge-flush-cards',
detail: `${flush.length} card${flush.length === 1 ? '' : 's'} flush against the ${worst.edge} edge of ${classSelector(scroller)} at rest (${worst.gap}px gap, e.g. ${classSelector(worst.card)})`,
});
}
return findings;
}
export {
checkBorders,
isEmojiOnlyText,
@@ -4344,4 +4533,7 @@ export {
checkElementTextOverflowDOM,
checkHeadingRhythmDOM,
checkElementBlinkingCursorDOM,
measureHiddenTextDOM,
checkContentHiddenAtRest,
checkEdgeFlushCardsDOM,
};