mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-14 15:16:35 +03:00
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:
co-authored by
Claude Fable 5
parent
d3599d7895
commit
c98f5d42ed
@@ -1,6 +1,6 @@
|
||||
# Impeccable
|
||||
|
||||
Design guidance for AI coding agents. 1 skill, 23 commands, live browser iteration, and 53 deterministic detector rules for AI-generated frontend design.
|
||||
Design guidance for AI coding agents. 1 skill, 23 commands, live browser iteration, and 56 deterministic detector rules for AI-generated frontend design.
|
||||
|
||||
> **Quick start:** From your project root, run `npx impeccable install`, then run `/impeccable init` inside your AI coding tool. Full docs: [impeccable.style](https://impeccable.style).
|
||||
|
||||
@@ -13,7 +13,7 @@ Every model trained on the same SaaS templates. Skip the guidance and you get th
|
||||
Impeccable adds:
|
||||
- **One setup flow.** `/impeccable init` writes `PRODUCT.md` and offers `DESIGN.md`, so later commands know the audience, brand/product lane, voice, anti-references, colors, type, and components.
|
||||
- **23 commands.** A shared design vocabulary with your AI: `polish`, `audit`, `critique`, `distill`, `animate`, `bolder`, `quieter`, and more.
|
||||
- **53 deterministic detector rules** plus LLM-only critique checks. The CLI and browser extension run the deterministic rules with no LLM and no API key.
|
||||
- **56 deterministic detector rules** plus LLM-only critique checks. The CLI and browser extension run the deterministic rules with no LLM and no API key.
|
||||
|
||||
## What's Included
|
||||
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# Impeccable CLI
|
||||
|
||||
Detect UI anti-patterns and design quality issues from the command line. Scans HTML, CSS, JSX, TSX, Vue, and Svelte files for 53 deterministic rules, including AI-generated UI tells, accessibility violations, and general design quality problems.
|
||||
Detect UI anti-patterns and design quality issues from the command line. Scans HTML, CSS, JSX, TSX, Vue, and Svelte files for 56 deterministic rules, including AI-generated UI tells, accessibility violations, and general design quality problems.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -56,7 +56,7 @@ npx impeccable detect --fast src/
|
||||
|
||||
**Quality**: tiny body text, cramped padding, long line lengths, small touch targets
|
||||
|
||||
53 deterministic detector rules in total. See the full catalog at [impeccable.style/slop](https://impeccable.style/slop).
|
||||
56 deterministic detector rules in total. See the full catalog at [impeccable.style/slop](https://impeccable.style/slop).
|
||||
|
||||
## Exit Codes
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -521,7 +521,7 @@ import '../styles/testimonials.css';
|
||||
<article class="ks-bento-tile ks-bento-tile--span-6" id="why-ci">
|
||||
<span class="ks-bento-num" data-color="patina">06</span>
|
||||
<h3 class="why-panel-title">Block slop before it ships.</h3>
|
||||
<p class="why-panel-body">A detector you can wire into PR checks. 53 deterministic rules, no LLM, exit codes the build can read.</p>
|
||||
<p class="why-panel-body">A detector you can wire into PR checks. 56 deterministic rules, no LLM, exit codes the build can read.</p>
|
||||
<div class="why-visual why-visual--ci">
|
||||
<div class="why-ci-window">
|
||||
<div class="why-ci-header">
|
||||
@@ -799,7 +799,7 @@ import '../styles/testimonials.css';
|
||||
</li>
|
||||
<li>
|
||||
<strong>CLI for CI</strong>
|
||||
<span><code>npx impeccable detect src/</code> in a PR check. 53 deterministic rules. JSON output, exit codes for build gates.</span>
|
||||
<span><code>npx impeccable detect src/</code> in a PR check. 56 deterministic rules. JSON output, exit codes for build gates.</span>
|
||||
<a href="https://www.npmjs.com/package/impeccable" target="_blank" rel="noopener">View on npm →</a>
|
||||
</li>
|
||||
<li>
|
||||
|
||||
@@ -297,6 +297,7 @@ describe('detectUrl — browser-only fixtures', () => {
|
||||
}
|
||||
assert.ok(flagged.has('flag-nowrap'), 'expected the nowrap overflow case to flag');
|
||||
assert.ok(flagged.has('flag-longword'), 'expected the unbreakable-token overflow case to flag');
|
||||
assert.ok(flagged.has('flag-inline-spill'), 'expected the inline-owner overflow case to flag');
|
||||
for (const cls of [
|
||||
'pass-scroll',
|
||||
'pass-pre',
|
||||
@@ -307,10 +308,55 @@ describe('detectUrl — browser-only fixtures', () => {
|
||||
'pass-sr-only-tiny-hidden',
|
||||
'pass-sr-only-clipped-wide',
|
||||
'pass-hidden-slide-overflow',
|
||||
'pass-inline-fits',
|
||||
'pass-inline-wraps',
|
||||
]) {
|
||||
assert.ok(!flagged.has(cls), `".${cls}" should NOT be flagged as text-overflow`);
|
||||
}
|
||||
assert.equal(hits.length, 2, `expected exactly 2 text-overflow findings, got ${hits.length}: ${JSON.stringify(hits.map(h => h.snippet))}`);
|
||||
assert.equal(hits.length, 3, `expected exactly 3 text-overflow findings, got ${hits.length}: ${JSON.stringify(hits.map(h => h.snippet))}`);
|
||||
});
|
||||
|
||||
it('script-error + content-hidden-at-rest: broken reveal page flags both', async () => {
|
||||
// The fixture mirrors the real broken sample: a syntax error kills the
|
||||
// whole script block, the IntersectionObserver reveal never wires up, and
|
||||
// most of the page text stays at opacity 0 even after the reveal sweep.
|
||||
const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/script-error.html`, { visualContrast: false });
|
||||
const scriptErrors = f.filter(r => r.antipattern === 'script-error');
|
||||
assert.equal(scriptErrors.length, 1, `expected 1 script-error finding, got ${scriptErrors.length}: ${JSON.stringify(scriptErrors.map(r => r.snippet))}`);
|
||||
assert.match(scriptErrors[0].snippet, /invalid|unexpected/i);
|
||||
assert.equal(scriptErrors[0].severity, 'error');
|
||||
|
||||
const hidden = f.filter(r => r.antipattern === 'content-hidden-at-rest');
|
||||
assert.equal(hidden.length, 1, `expected 1 content-hidden finding, got ${hidden.length}: ${JSON.stringify(hidden.map(r => r.snippet))}`);
|
||||
assert.match(hidden[0].snippet, /% of page text/);
|
||||
assert.equal(hidden[0].severity, 'error');
|
||||
});
|
||||
|
||||
it('script-error + content-hidden-at-rest: working reveal page stays clean', async () => {
|
||||
// Identical markup with a working script (plus scroll-behavior: smooth,
|
||||
// hidden menus, aria-hidden and template content). The reveal sweep must
|
||||
// reveal every section and the exclusion rules must keep legitimately
|
||||
// hidden UI out of the measurement.
|
||||
const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/reveal-working.html`, { visualContrast: false });
|
||||
assert.equal(
|
||||
f.some(r => r.antipattern === 'script-error'), false,
|
||||
`working page must not produce script-error: ${JSON.stringify(f.map(r => r.snippet))}`,
|
||||
);
|
||||
assert.equal(
|
||||
f.some(r => r.antipattern === 'content-hidden-at-rest'), false,
|
||||
`working reveal page must not produce content-hidden-at-rest: ${JSON.stringify(f.map(r => r.snippet))}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('edge-flush-cards: oversized panel flags, even insets / peek / plain content pass', async () => {
|
||||
const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/edge-flush-cards.html`, { visualContrast: false });
|
||||
const hits = f.filter(r => r.antipattern === 'edge-flush-cards');
|
||||
assert.equal(hits.length, 1, `expected exactly 1 edge-flush-cards finding, got ${hits.length}: ${JSON.stringify(hits.map(h => h.snippet))}`);
|
||||
assert.match(hits[0].snippet, /flag-pager/, `finding must attach to the oversized-panel scroller: ${hits[0].snippet}`);
|
||||
assert.match(hits[0].snippet, /2 cards/, `both oversized-panel cards should count: ${hits[0].snippet}`);
|
||||
for (const cls of ['pass-even', 'pass-peek', 'pass-plain']) {
|
||||
assert.doesNotMatch(hits[0].snippet, new RegExp(cls), `".${cls}" scroller must not flag`);
|
||||
}
|
||||
});
|
||||
|
||||
it('visual contrast: browser fallback catches low contrast on image backgrounds', async () => {
|
||||
|
||||
@@ -154,6 +154,47 @@ describe('detectHtml — static HTML/CSS fixtures', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('color: text-bearing chips with their own background get contrast checks', async () => {
|
||||
// A <span> chip painting an opaque background under direct text is a real
|
||||
// contrast surface even though span sits in SAFE_TAGS. Mirrors a shipped
|
||||
// miss: a SEV-2 chip whose white text lost a specificity fight and
|
||||
// rendered muted brown on red at 1.2:1.
|
||||
const f = await detectHtml(path.join(FIXTURES, 'color.html'));
|
||||
const chipFlag = f.some(r =>
|
||||
r.antipattern === 'low-contrast' &&
|
||||
/#5c5449/i.test(r.snippet || '') &&
|
||||
/#b6322d/i.test(r.snippet || '')
|
||||
);
|
||||
assert.ok(chipFlag, 'expected low-contrast finding for the SEV-2 style chip');
|
||||
|
||||
// The properly contrasted chip must pass, and the sub-9px decorative
|
||||
// chip stays below the font floor.
|
||||
const chipOkFalsePositive = f.some(r =>
|
||||
r.antipattern === 'low-contrast' &&
|
||||
/#f5f0e8/i.test(r.snippet || '') &&
|
||||
/#141419/i.test(r.snippet || '')
|
||||
);
|
||||
assert.equal(chipOkFalsePositive, false, 'high-contrast chip must not flag');
|
||||
const sub9FalsePositive = f.some(r =>
|
||||
r.antipattern === 'low-contrast' &&
|
||||
/#963c37/i.test(r.snippet || '')
|
||||
);
|
||||
assert.equal(sub9FalsePositive, false, 'sub-9px chip must stay below the font floor');
|
||||
});
|
||||
|
||||
it('color: background none shorthand resets an earlier background-color', async () => {
|
||||
// `pre code { background: none }` after `code { background: <light> }`
|
||||
// must leave the code text transparent over the dark panel. Keeping the
|
||||
// light surface produces a phantom 1.1:1 finding the browser never paints.
|
||||
const f = await detectHtml(path.join(FIXTURES, 'color.html'));
|
||||
const phantom = f.some(r =>
|
||||
r.antipattern === 'low-contrast' &&
|
||||
/#e6e8ed/i.test(r.snippet || '') &&
|
||||
/#f6f2f4/i.test(r.snippet || '')
|
||||
);
|
||||
assert.equal(phantom, false, 'background: none must reset the earlier code background');
|
||||
});
|
||||
|
||||
it('color: emoji-only text is never flagged as low-contrast', async () => {
|
||||
// Emojis render as multicolor glyphs regardless of CSS `color`, so the
|
||||
// CSS text color is irrelevant for contrast. The fixture's emoji cards
|
||||
|
||||
+30
@@ -17,6 +17,13 @@
|
||||
.low-contrast-button { background-color: rgb(55, 65, 81); color: rgb(108, 114, 128); display: inline-block; padding: 10px 20px; border-radius: 8px; border: 0; font-size: 14px; }
|
||||
.good-pill-high { background-color: rgb(20, 20, 25); color: rgb(245, 240, 232); display: inline-block; padding: 9px 18px; border-radius: 999px; font-weight: 500; font-size: 14px; text-decoration: none; }
|
||||
.inline-link-low { color: rgb(170, 170, 170); }
|
||||
/* Chip / badge cases: text-bearing spans painting their own background. */
|
||||
.chip-sev-low { display: inline-block; background-color: rgb(182, 50, 45); color: rgb(92, 84, 73); font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 2px; }
|
||||
.chip-ok-high { display: inline-block; background-color: rgb(20, 20, 25); color: rgb(245, 240, 232); font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 2px; }
|
||||
.chip-sub9-low { display: inline-block; background-color: rgb(182, 50, 45); color: rgb(150, 60, 55); font-size: 8px; padding: 1px 4px; }
|
||||
.panel-reset { background: rgb(28, 30, 38); color: rgb(230, 232, 237); padding: 12px; }
|
||||
.panel-reset code { background: rgb(246, 242, 244); border-radius: 3px; padding: 1px 4px; }
|
||||
.panel-reset pre code { background: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -73,6 +80,14 @@
|
||||
still pass through the contrast check. -->
|
||||
<button class="low-contrast-button" data-test="low-contrast-button">Submit</button>
|
||||
|
||||
<h3>Text-bearing chip with low contrast</h3>
|
||||
<!-- A <span> chip painting its own opaque background under direct text.
|
||||
SAFE_TAGS used to skip spans categorically; the styled-control
|
||||
exception must cover any text-bearing chip or badge. Mirrors a real
|
||||
bug: a SEV-2 chip whose white text lost a specificity fight and
|
||||
rendered muted brown on red at 1.2:1. -->
|
||||
<span class="chip-sev-low" data-test="chip-sev">SEV-2</span>
|
||||
|
||||
<h3>Tailwind color anti-patterns</h3>
|
||||
<div class="bg-black text-white p-4 rounded card" style="background: black; color: white;">
|
||||
<p>bg-black — pure black bg</p>
|
||||
@@ -151,6 +166,21 @@
|
||||
let the contrast check run, but this case must clearly pass. -->
|
||||
<a href="#" class="good-pill-high" data-test="good-pill">High contrast pill</a>
|
||||
|
||||
<h3>Chip with good contrast (must not flag)</h3>
|
||||
<span class="chip-ok-high" data-test="chip-ok">HEALTHY</span>
|
||||
|
||||
<h3>Sub-9px decorative chip (below the font floor, must not flag)</h3>
|
||||
<span class="chip-sub9-low" data-test="chip-sub9">tick</span>
|
||||
|
||||
<h3>background: none resets an earlier background-color</h3>
|
||||
<!-- The `background` shorthand resets background-color. `pre code
|
||||
{ background: none }` after `code { background: <light> }` leaves
|
||||
the code text transparent over the dark panel; the cascade must not
|
||||
keep measuring it against the light surface it never paints. -->
|
||||
<div class="panel-reset" data-test="panel-reset">
|
||||
<pre><code data-test="code-reset">light text over the dark panel, not the light code surface</code></pre>
|
||||
</div>
|
||||
|
||||
<h3>Emoji on light backgrounds</h3>
|
||||
<!-- Emojis render as multicolor glyphs regardless of CSS color, so the
|
||||
CSS color is irrelevant for contrast. These should NOT be flagged. -->
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Edge-flush cards fixture (browser-only)</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; margin: 0; color: #1a1a1a; background: #f2f2f4; }
|
||||
h2 { padding: 16px 24px 0; font-size: 16px; }
|
||||
.scroller { width: 600px; margin: 8px 24px 24px; overflow-x: auto; display: flex; scroll-snap-type: x mandatory; background: #e4e4e8; }
|
||||
.panel { flex: 0 0 auto; scroll-snap-align: start; }
|
||||
.card { background: #fff; border-radius: 10px; height: 90px; padding: 12px; box-sizing: border-box; margin-bottom: 12px; }
|
||||
.plain { height: 90px; padding: 12px; box-sizing: border-box; }
|
||||
|
||||
/* FLAG: first panel is wider than the scroller's clip box, so its cards
|
||||
keep a 16px left gutter but end 4px past the right clip edge. */
|
||||
.flag-pager .panel-wide { width: 620px; }
|
||||
.flag-pager .panel-wide .card { margin-left: 16px; width: 588px; }
|
||||
.flag-pager .panel-ok { width: 600px; }
|
||||
.flag-pager .panel-ok .card { margin-left: 16px; width: 568px; }
|
||||
|
||||
/* PASS: every panel keeps a symmetric inset. */
|
||||
.pass-even .panel { width: 600px; }
|
||||
.pass-even .card { margin-left: 16px; width: 568px; }
|
||||
|
||||
/* PASS: deliberate peek carousel; the third card is cut mid-card, far
|
||||
past the clip edge, which reads as an affordance rather than a bug. */
|
||||
.pass-peek { gap: 12px; padding: 12px 0 12px 16px; }
|
||||
.pass-peek .card { flex: 0 0 auto; width: 260px; margin: 0; }
|
||||
|
||||
/* PASS: flush children without card styling are plain content, not cards. */
|
||||
.pass-plain .panel { width: 620px; }
|
||||
.pass-plain .plain { margin-left: 16px; width: 588px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Flag: first panel wider than its clip box</h2>
|
||||
<div class="scroller flag-pager">
|
||||
<div class="panel panel-wide">
|
||||
<div class="card">Card cut off at the right clip edge while keeping its left gutter.</div>
|
||||
<div class="card">Second card in the oversized panel, also flush against the edge.</div>
|
||||
</div>
|
||||
<div class="panel panel-ok">
|
||||
<div class="card">Sibling panel card with the intended inset on both sides.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Pass: symmetric insets</h2>
|
||||
<div class="scroller pass-even">
|
||||
<div class="panel"><div class="card">Card with an even gutter on both sides of the clip box.</div></div>
|
||||
<div class="panel"><div class="card">Second panel card, also properly inset.</div></div>
|
||||
</div>
|
||||
|
||||
<h2>Pass: deliberate peek</h2>
|
||||
<div class="scroller pass-peek">
|
||||
<div class="card">First peek card fully visible.</div>
|
||||
<div class="card">Second peek card fully visible.</div>
|
||||
<div class="card">Third card cut mid-card as a scroll affordance.</div>
|
||||
<div class="card">Fourth card entirely off-screen.</div>
|
||||
</div>
|
||||
|
||||
<h2>Pass: plain content, not cards</h2>
|
||||
<div class="scroller pass-plain">
|
||||
<div class="panel"><div class="plain">Unstyled text block flush against the edge is not a card.</div></div>
|
||||
<div class="panel"><div class="plain">Second plain block.</div></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Working reveal-on-scroll fixture (browser-only pass case)</title>
|
||||
<style>
|
||||
html { scroll-behavior: smooth; }
|
||||
body { font-family: system-ui, sans-serif; margin: 0; color: #1a1a1a; background: #fff; }
|
||||
main { max-width: 640px; margin: 0 auto; padding: 32px 24px; }
|
||||
section { margin: 0 0 480px; }
|
||||
.reveal { opacity: 0; transform: translateY(14px); transition: opacity 0.4s ease-out, transform 0.4s ease-out; }
|
||||
.reveal.in-view { opacity: 1; transform: none; }
|
||||
.menu { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Visible hero headline stays readable</h1>
|
||||
<p>Identical markup to the broken fixture, but the script parses and runs, so every
|
||||
reveal section becomes visible once the detector's reveal sweep scrolls it into view.
|
||||
Nothing on this page may produce a script-error or content-hidden finding.</p>
|
||||
|
||||
<!-- Legitimately hidden UI: excluded from the measurement entirely -->
|
||||
<nav class="menu">
|
||||
<a href="#one">A menu that only opens on demand</a>
|
||||
<a href="#two">Another menu entry with text</a>
|
||||
</nav>
|
||||
<div hidden>Hidden attribute content that never counts against the page.</div>
|
||||
<div aria-hidden="true">Decorative aria-hidden copy that also stays out of the denominator.</div>
|
||||
<template><p>Template content never renders and never counts.</p></template>
|
||||
|
||||
<section class="reveal" id="one">
|
||||
<h2>First revealed section</h2>
|
||||
<p>This block starts at opacity zero, exactly like a broken page would, but its
|
||||
IntersectionObserver is alive. During the sweep it gains the in-view class and
|
||||
transitions to full opacity, so the post-sweep measurement sees it as visible.</p>
|
||||
</section>
|
||||
<section class="reveal" id="two">
|
||||
<h2>Second revealed section</h2>
|
||||
<p>More reveal-on-scroll copy that carries a meaningful share of the page's
|
||||
character count. If the sweep or the exclusion rules regress, this fixture is the
|
||||
page that starts producing a false content-hidden-at-rest finding.</p>
|
||||
</section>
|
||||
<section class="reveal">
|
||||
<h2>Third revealed section</h2>
|
||||
<p>The smooth scroll-behavior on the root element is deliberate: the sweep must
|
||||
use instant scrolling or it never actually reaches these sections before it
|
||||
measures, which was the first false-positive mode found while calibrating.</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const sections = document.querySelectorAll('.reveal');
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) { entry.target.classList.add('in-view'); io.unobserve(entry.target); }
|
||||
}
|
||||
}, { threshold: 0.1 });
|
||||
sections.forEach((el) => io.observe(el));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Script error + content hidden fixture (browser-only)</title>
|
||||
<style>
|
||||
html { scroll-behavior: smooth; }
|
||||
body { font-family: system-ui, sans-serif; margin: 0; color: #1a1a1a; background: #fff; }
|
||||
main { max-width: 640px; margin: 0 auto; padding: 32px 24px; }
|
||||
section { margin: 0 0 480px; }
|
||||
.reveal { opacity: 0; transform: translateY(14px); transition: opacity 0.4s ease-out, transform 0.4s ease-out; }
|
||||
.reveal.in-view { opacity: 1; transform: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Visible hero headline stays readable</h1>
|
||||
<p>The opening paragraph renders normally, exactly like the real broken sample: the
|
||||
first viewport looks fine while everything below it never appears.</p>
|
||||
|
||||
<section class="reveal">
|
||||
<h2>First hidden section</h2>
|
||||
<p>This block waits for an IntersectionObserver that will never construct, because
|
||||
the single script tag below fails to parse. Its text ships in the document but the
|
||||
reveal class never lands, so the reader scrolls through blank space.</p>
|
||||
</section>
|
||||
<section class="reveal">
|
||||
<h2>Second hidden section</h2>
|
||||
<p>More shipped-but-invisible copy. Together the hidden sections carry well over
|
||||
thirty percent of the character count of the page, which is the failed-reveal
|
||||
signature this fixture exists to reproduce for the detector tests.</p>
|
||||
</section>
|
||||
<section class="reveal">
|
||||
<h2>Third hidden section</h2>
|
||||
<p>A final block of body copy that stays at opacity zero forever. A healthy page
|
||||
with this exact markup reveals every one of these sections during the detector's
|
||||
reveal sweep; this one cannot, because its JavaScript died at parse time.</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const sections = document.querySelectorAll('.reveal');
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) { entry.target.classList.add('in-view'); io.unobserve(entry.target); }
|
||||
}
|
||||
}, { threshold: 0.1 });
|
||||
sections.forEach((el) => io.observe(el));
|
||||
// Deliberate syntax error: an unterminated string kills the whole block,
|
||||
// including the observer wiring above.
|
||||
const label = 'unterminated
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -46,6 +46,9 @@
|
||||
<section class="col flag">
|
||||
<div class="box flag-nowrap" style="white-space: nowrap">A single long line of running text that refuses to wrap and clearly spills past its fixed-width box.</div>
|
||||
<div class="box flag-longword">Supercalifragilisticexpialidocioussupercalifragilisticexpialidocious</div>
|
||||
<!-- Inline owner: the span has no client geometry, so only the rect-vs-container
|
||||
path can catch it. The parent box has no direct text and must not flag. -->
|
||||
<div class="box"><span class="flag-inline-spill" style="white-space: nowrap">IOC-QARTOD-ARGO-PROFILES-SPILLING-WELL-PAST-THE-CELL</span></div>
|
||||
</section>
|
||||
|
||||
<!-- PASS column: real scroll regions, preformatted code, wrapping text, scroll ancestors -->
|
||||
@@ -61,6 +64,8 @@
|
||||
<div class="hidden-slide">
|
||||
<span class="pass-hidden-slide-overflow">An inactive carousel slide can contain long text that overflows while hidden.</span>
|
||||
</div>
|
||||
<div class="box"><span class="pass-inline-fits" style="white-space: nowrap">Fits fine</span></div>
|
||||
<div class="box"><span class="pass-inline-wraps">An ordinary inline span with wrapping text that stays inside its container across several lines.</span></div>
|
||||
</section>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user