detector: hero pulsing-dot promotion, nav-CTA contrast gap closure, shape-assembled-illustration (56 -> 57)

Item 1 (hero liveness theater):
- pulsing-dot now merges declarations per selector across rule blocks
  (cascade-approximate), descends into media queries, and strips
  prefers-reduced-motion: reduce overrides before the predicate runs.
  Catches the shipped split-block constructions (size in the base rule,
  animation added later or inside a no-preference media block).
- Dots whose element sits inside a header/nav landmark are promoted to
  error severity (string-level landmark ranges in both engines); the
  browser engine additionally promotes dots resting in the first ~900px.
- blinking-cursor findings in the first ~900px or inside header/nav are
  promoted from advisory to warning.
- Per-finding severity overrides now flow through static-html,
  browser-injected serialization, and detect-url.

Item 2 (nav-CTA contrast constructions):
- The a24-opus 01/002 header CTA already fires (specificity cascade +
  oklch + var() all resolved); systematic sweep found two remaining
  escapes and closes both:
  - own gradient background on a SAFE_TAGS element (checkColors styled-
    control exception now treats an own gradient as an own surface,
    contrast measured against the worst stop)
  - ::before/::after full-cover surface (static cascade marks pseudo
    surfaces; browser adapter reads the pseudo computed style) so text is
    measured against the surface the browser actually paints
- nav-cta-constructions fixture locks all eight computable construction
  families; background-image: url() remains unflaggable by design.

Item 3 (shape-assembled-illustration, slop/advisory):
- New rule for large inline SVGs composing a pictorial scene from >= 8
  primitive shapes at >= 200x200 intrinsic size with >= 3 distinct fills.
  Charts (axis labels), stroke-only technical drawings, icons/logos
  (small explicit size), and pattern-tiled backgrounds are exempt.
  1.8 percent fire rate over the 3069-sample eval corpus, all verified
  pictorial scenes; zero fires across val-a22/val-a24.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-07-14 19:29:29 -07:00
co-authored by Claude Fable 5
parent 1734f13a2e
commit dc0b25d393
14 changed files with 928 additions and 36 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
# Impeccable
Design guidance for AI coding agents. 1 skill, 23 commands, live browser iteration, and 56 deterministic detector rules for AI-generated frontend design.
Design guidance for AI coding agents. 1 skill, 23 commands, live browser iteration, and 57 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.
- **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.
- **57 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
View File
@@ -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 56 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 57 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
56 deterministic detector rules in total. See the full catalog at [impeccable.style/slop](https://impeccable.style/slop).
57 deterministic detector rules in total. See the full catalog at [impeccable.style/slop](https://impeccable.style/slop).
## Exit Codes
+21 -3
View File
@@ -1222,7 +1222,7 @@ if (IS_BROWSER) {
return {
type: f.type || f.id,
category: ap ? ap.category : 'quality',
severity: ap?.severity || 'warning',
severity: f.severity || ap?.severity || 'warning',
detail: f.detail || f.snippet,
ignoreValue: f.ignoreValue || f.value || '',
name: ap ? ap.name : (f.type || f.id),
@@ -1489,7 +1489,7 @@ if (IS_BROWSER) {
...checkElementClippedOverflowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
...checkElementGptBorderShadowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
...checkElementTextOverflowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
...checkElementBlinkingCursorDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
...checkElementBlinkingCursorDOM(el).map(f => ({ type: f.id, detail: f.snippet, ...(f.severity ? { severity: f.severity } : {}) })),
...checkElementDesignSystemDOM(el, designSystem, designSeen),
].filter(f => _ruleOk(f.type));
@@ -1588,7 +1588,25 @@ if (IS_BROWSER) {
}
const htmlPatternFindings = checkHtmlPatterns(docClone.outerHTML);
if (htmlPatternFindings.length > 0) {
const mapped = htmlPatternFindings.map(f => ({ type: f.id, detail: f.snippet })).filter(f => _ruleOk(f.type));
const mapped = htmlPatternFindings.map(f => {
const item = { type: f.id, detail: f.snippet };
if (f.severity) {
item.severity = f.severity;
} else if (f.id === 'pulsing-dot' && f.selector) {
// The string scan promotes header/nav dots on its own; with a live
// layout also promote dots resting in the first ~900px of the page
// (the hero region), which the source scan cannot measure.
try {
const dotEl = document.querySelector(f.selector);
if (dotEl) {
const rect = dotEl.getBoundingClientRect();
const pageTop = rect.top + (window.scrollY || 0);
if (pageTop <= 900) item.severity = 'error';
}
} catch { /* unresolvable selector: keep registry severity */ }
}
return item;
}).filter(f => _ruleOk(f.type));
pageLevelFindings.push(...mapped);
addBrowserFindings(groupMap, document.body, mapped);
}
+272 -13
View File
@@ -224,6 +224,15 @@ const ANTIPATTERNS = [
'A blinking text cursor animated into a hero or landing section simulates typing where no input exists. It borrows the dev-tool aesthetic as decoration. Real editable fields draw their own caret; anywhere else, let the composition hold attention without a fake prompt.',
skillSection: 'Motion',
},
{
id: 'shape-assembled-illustration',
category: 'slop',
severity: 'advisory',
name: 'Shape-assembled illustration',
description:
'A large inline SVG that builds a pictorial scene from a pile of primitive shapes reads as placeholder clip art, not illustration. Icons, logos, and data graphics are fine at their scale; a hero-sized visual deserves real artwork, a photograph, or a deliberately drawn graphic.',
skillSection: 'Imagery',
},
{
id: 'dark-glow',
category: 'slop',
@@ -853,7 +862,13 @@ function checkColors(opts) {
// 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
&& ((bgColor && bgColor.a > 0.5)
// A gradient painted on the element itself is an own surface the
// same way a solid background is. Without this branch a nav CTA
// built as `<a>` with `background: linear-gradient(…)` and a text
// color that fails against every stop sails through on the
// SAFE_TAGS suppression (the shipped escape).
|| (bgImage && /gradient/i.test(bgImage)))
&& fontSize >= 9;
if (!isStyledControl) return [];
}
@@ -1805,22 +1820,129 @@ function isRoundDotRadius(radiusValue, w, h) {
return px >= 999 || px >= 0.4 * Math.min(w, h);
}
// Remove @media blocks whose condition is prefers-reduced-motion: reduce.
// Those blocks describe the accessibility fallback, not the default
// experience that ships — an `animation: none` reset inside one must not
// mask the resting-state animation the page plays for everyone else.
function stripReducedMotionBlocks(content) {
const re = /@media[^{]*prefers-reduced-motion\s*:\s*reduce[^{]*\{/gi;
let out = '';
let last = 0;
let m;
while ((m = re.exec(content)) !== null) {
let depth = 1;
let i = re.lastIndex;
while (i < content.length && depth > 0) {
const ch = content.charCodeAt(i);
if (ch === 0x7b /* { */) depth++;
else if (ch === 0x7d /* } */) depth--;
i++;
}
out += content.slice(last, m.index);
last = i;
re.lastIndex = i;
}
return out + content.slice(last);
}
// Source-index ranges of <header> and <nav> landmark elements in an HTML
// string. Lets string-level scans decide whether a matched element sits in
// the page chrome (the hero/nav region) without needing a DOM.
function landmarkSourceRanges(content) {
const ranges = [];
for (const tag of ['header', 'nav']) {
const re = new RegExp(`<${tag}\\b|</${tag}\\s*>`, 'gi');
const stack = [];
let m;
while ((m = re.exec(content)) !== null) {
if (m[0].charAt(1) === '/') {
const start = stack.pop();
if (start != null) ranges.push([start, m.index]);
} else {
stack.push(m.index);
}
}
}
return ranges;
}
function indexInSourceRanges(index, ranges) {
return ranges.some(([start, end]) => index >= start && index < end);
}
// Does any element targeted by the final compound of `selector` appear
// inside a header/nav landmark range of the HTML source? Resolves the last
// .class or #id token of the selector against class/id attributes; a
// tag-only compound is never resolvable this way and returns false
// (conservative: no promotion without placement evidence).
function selectorHitsLandmark(content, selector, ranges) {
if (!ranges || ranges.length === 0) return false;
const last = selector.split(/[\s>+~]+/).filter(Boolean).pop() || '';
const idMatch = last.match(/#([A-Za-z_][\w-]*)/);
const classMatch = last.match(/\.([A-Za-z_][\w-]*)/);
let attrRe = null;
if (idMatch) {
const id = idMatch[1].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
attrRe = new RegExp(`<[a-zA-Z][^>]*\\bid\\s*=\\s*["']${id}["']`, 'gi');
} else if (classMatch) {
const cls = classMatch[1].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
attrRe = new RegExp(`<[a-zA-Z][^>]*\\bclass\\s*=\\s*["'][^"']*(?<![\\w-])${cls}(?![\\w-])[^"']*["']`, 'gi');
}
if (!attrRe) return false;
let m;
while ((m = attrRe.exec(content)) !== null) {
if (indexInSourceRanges(m.index, ranges)) return true;
}
return false;
}
// Small circular indicator bound to an infinite pulse animation — the
// decorative "live" dot. Gates: tiny (<= 16px square-ish), round
// (border-radius >= 40% or pill values), and an infinite animation whose
// keyframes vary opacity/scale/box-shadow (or a pulse/blink/ping name when
// the keyframes aren't in the scanned text). Rotation-only animations
// (spinners) never flag.
//
// Declarations for one selector are merged across rule blocks before the
// predicate runs: size in the base rule plus the animation added in a
// second block (or inside a matching @media block) is the construction
// that ships. prefers-reduced-motion: reduce overrides are stripped first
// so their animation resets don't mask the default experience. A dot whose
// element sits inside a header/nav landmark is the hero liveness cliché
// and is promoted to error severity; occurrences elsewhere keep the
// registry default severity.
function scanCssTextForPulsingDot(content) {
const customProps = collectCssCustomProps(content);
const keyframes = collectPulseKeyframes(content);
const heroRanges = landmarkSourceRanges(content);
const findings = [];
const seen = new Set();
// Merge declarations per selector across rule blocks, approximating the
// cascade: later declarations for the same property win. Comma lists are
// split so `.a, .b { … }` contributes to both selectors. Comments are
// stripped first so they neither pollute selector keys nor smuggle a
// comma into the selector-list split.
const scanText = stripReducedMotionBlocks(content).replace(/\/\*[\s\S]*?\*\//g, ' ');
const merged = new Map();
const ruleRe = new RegExp(CSS_RULE_BLOCK_SOURCE, 'g');
let m;
while ((m = ruleRe.exec(content)) !== null) {
const selector = m[1].trim();
while ((m = ruleRe.exec(scanText)) !== null) {
const decls = parseCssDeclBlock(m[2]);
if (decls.size === 0) continue;
for (const rawSelector of m[1].split(',')) {
const selector = rawSelector.trim();
if (!selector || selector.startsWith('@')) continue;
let acc = merged.get(selector);
if (!acc) {
acc = new Map();
merged.set(selector, acc);
}
for (const [prop, value] of decls) acc.set(prop, value);
}
}
for (const [selector, decls] of merged) {
const names = infiniteAnimationNames(decls);
if (names.length === 0) continue;
const pulseName = names.find(n => {
@@ -1841,9 +1963,12 @@ function scanCssTextForPulsingDot(content) {
if (seen.has(selector)) continue;
seen.add(selector);
const inLandmark = selectorHitsLandmark(content, selector, heroRanges);
findings.push({
id: 'pulsing-dot',
snippet: `${selector}${w}x${h}px dot with infinite "${pulseName}" animation`,
snippet: `${selector}${w}x${h}px dot with infinite "${pulseName}" animation${inLandmark ? ' in header/nav' : ''}`,
selector,
...(inLandmark ? { severity: 'error' } : {}),
});
}
@@ -1860,15 +1985,78 @@ function scanCssTextForPulsingDot(content) {
const key = `tw:${cls}`;
if (seen.has(key)) continue;
seen.add(key);
const inLandmark = indexInSourceRanges(cm.index, heroRanges);
findings.push({
id: 'pulsing-dot',
snippet: `animate-${anim[1]} on tiny rounded-full element`,
snippet: `animate-${anim[1]} on tiny rounded-full element${inLandmark ? ' in header/nav' : ''}`,
...(inLandmark ? { severity: 'error' } : {}),
});
}
return findings;
}
// Shape-assembled illustration: a large inline SVG composing a pictorial
// scene from many primitive shapes (rect / circle / ellipse / polygon) in
// several fill colors — the clip-art hero mascot. Gates keep the legitimate
// SVG population out:
// • icons and logos: intrinsic size gate (>= 200px on both axes, from
// width/height attributes or the viewBox when no explicit size is set)
// • charts / labeled diagrams: more than two <text>/<tspan> nodes exempts
// the graphic (axis labels, callouts)
// • line drawings / technical diagrams: primitive count < 8 or fewer
// than 3 distinct fills never qualifies (stroke-only art has no fills)
// • tiling background textures: any <pattern> definition exempts
function scanHtmlForShapeAssembledIllustration(html) {
const findings = [];
const svgRe = /<svg\b[^>]*>[\s\S]*?<\/svg>/gi;
let m;
while ((m = svgRe.exec(html)) !== null) {
const block = m[0];
const openTag = (block.match(/^<svg\b[^>]*>/i) || [''])[0];
// Data-bearing or annotated graphics: axis labels and callout text
// mark a chart or diagram, not a mascot.
const textCount = (block.match(/<(?:text|tspan)\b/gi) || []).length;
if (textCount > 2) continue;
// Tiling texture definitions are decorative backgrounds, not scenes.
if (/<pattern\b/i.test(block)) continue;
const primitives = (block.match(/<(?:rect|circle|ellipse|polygon)\b/gi) || []).length;
if (primitives < 8) continue;
// Intrinsic size: explicit width/height attributes win; fall back to
// the viewBox box. Percentage or missing sizes stay unresolvable on
// that axis and the viewBox speaks for them.
const attrDim = (name) => {
// (?<![-\w]) keeps compound attributes like stroke-width from
// masquerading as the svg's own width.
const am = openTag.match(new RegExp(`(?<![-\\w])${name}\\s*=\\s*["']\\s*([\\d.]+)(?:px)?\\s*["']`, 'i'));
return am ? parseFloat(am[1]) : null;
};
const vb = openTag.match(/\bviewBox\s*=\s*["']\s*[-\d.]+[\s,]+[-\d.]+[\s,]+([\d.]+)[\s,]+([\d.]+)\s*["']/i);
const w = attrDim('width') ?? (vb ? parseFloat(vb[1]) : null);
const h = attrDim('height') ?? (vb ? parseFloat(vb[2]) : null);
if (w == null || h == null || w < 200 || h < 200) continue;
// Distinct fill paints (attributes and inline styles), excluding
// non-paints. Multiple fills are what turn a shape pile into a scene.
const fills = new Set();
for (const fm of block.matchAll(/\bfill\s*[:=]\s*["']?\s*([^"';>}\s]+)/gi)) {
const paint = fm[1].trim().toLowerCase();
if (!paint || ['none', 'transparent', 'currentcolor', 'inherit'].includes(paint)) continue;
fills.add(paint);
}
if (fills.size < 3) continue;
findings.push({
id: 'shape-assembled-illustration',
snippet: `inline <svg> scene: ${primitives} primitive shapes, ~${Math.round(w)}x${Math.round(h)}px, ${fills.size} fill colors`,
});
}
return findings;
}
/**
* Regex-on-HTML checks shared between browser and Node page-level detection.
* These don't need DOM access, just the raw HTML string.
@@ -1990,6 +2178,9 @@ function checkHtmlPatterns(html) {
// Pulsing status dots (tiny circular elements on infinite pulse animations)
findings.push(...scanCssTextForPulsingDot(html));
// Shape-assembled illustrations (large pictorial SVGs built from primitives)
findings.push(...scanHtmlForShapeAssembledIllustration(html));
// Auto-scrolling marquees (<marquee> or infinite horizontal loop animations)
findings.push(...scanCssTextForMarquee(html));
@@ -2382,6 +2573,28 @@ function checkElementPseudoStripeDOM(el) {
return findings;
}
// Full-cover surface pseudo (browser): a ::before/::after positioned
// absolute/fixed whose box covers (nearly) the whole host and carries an
// opaque background. That pseudo is the element's visible surface even
// though the element's own background-color reads transparent — the nav-CTA
// construction that otherwise escapes every own-background contrast gate.
function readPseudoSurfaceDOM(el, rect) {
for (const which of ['::before', '::after']) {
let ps;
try { ps = getComputedStyle(el, which); } catch { continue; }
if (!ps || ps.content === 'none' || ps.content === '') continue;
if (ps.position !== 'absolute' && ps.position !== 'fixed') continue;
if (ps.display === 'none' || (parseFloat(ps.opacity) || 1) < 0.9) continue;
const w = parseFloat(ps.width) || 0;
const h = parseFloat(ps.height) || 0;
if (w < rect.width - 4 || h < rect.height - 4) continue;
const bg = parseRgb(ps.backgroundColor) || parseAnyColor(ps.backgroundColor);
if (!bg || (bg.a ?? 1) < 0.9) continue;
return bg;
}
return null;
}
function checkElementColorsDOM(el) {
const tag = el.tagName.toLowerCase();
// No early SAFE_TAGS bail here — checkColors() does its own gating that
@@ -2392,7 +2605,15 @@ function checkElementColorsDOM(el) {
const style = getComputedStyle(el);
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
const effectiveBg = resolveBackground(el);
let effectiveBg = resolveBackground(el);
let ownBg = readOwnBackgroundColor(el, style);
if (!ownBg || (ownBg.a ?? 1) <= 0.5) {
const pseudoSurface = readPseudoSurfaceDOM(el, rect);
if (pseudoSurface) {
ownBg = pseudoSurface;
effectiveBg = pseudoSurface;
}
}
return checkColors({
tag,
// Chrome serializes computed colors specified in modern spaces as
@@ -2401,7 +2622,7 @@ function checkElementColorsDOM(el) {
// silently never run (the shipped miss: a nav CTA whose text color was
// an oklch token near its own oklch background).
textColor: parseRgb(style.color) || parseAnyColor(style.color),
bgColor: readOwnBackgroundColor(el, style),
bgColor: ownBg,
effectiveBg,
effectiveBgStops: effectiveBg ? null : resolveGradientStops(el),
fontSize: parseFloat(style.fontSize) || 16,
@@ -3773,15 +3994,28 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
// 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)
let ownBg = (customPropMap ? parseColorResolved(style.backgroundColor, customPropMap) : null)
|| readOwnBackgroundColor(el, style);
// Full-cover surface pseudo (static): the cascade pass marks elements
// whose ::before/::after paints an opaque covering surface. When the
// element itself has no usable own background, that pseudo is the real
// surface for contrast purposes.
let finalEffectiveBg = effectiveBg;
if ((!ownBg || (ownBg.a ?? 1) <= 0.5) && typeof window.getPseudoSurface === 'function') {
const pseudoSurface = window.getPseudoSurface(el);
if (pseudoSurface) {
ownBg = pseudoSurface;
finalEffectiveBg = pseudoSurface;
}
}
return checkColors({
tag,
textColor,
bgColor: ownBg,
effectiveBg,
effectiveBgStops: effectiveBg ? null : resolveGradientStops(el, window),
effectiveBg: finalEffectiveBg,
effectiveBgStops: finalEffectiveBg ? null : resolveGradientStops(el, window),
fontSize: parseFloat(style.fontSize) || 16,
fontWeight: parseInt(style.fontWeight) || 400,
hasDirectText,
@@ -5037,9 +5271,16 @@ function checkElementBlinkingCursorDOM(el) {
}
if (!glyphCursor && !blockCursor) return [];
// Hero-region promotion: a fake caret blinking in the first ~900px or
// inside the page chrome is the shipped hero cliché, not an incidental
// flourish. Promote those from the registry's advisory to warning;
// lower first-viewport occurrences keep the default severity.
const inHeroRegion = pageTop <= 900
|| !!(el.closest && el.closest('header, nav, [role="banner"], [role="navigation"]'));
return [{
id: 'blinking-cursor',
snippet: `${classSelector(el)}${Math.round(rect.width)}x${Math.round(rect.height)}px blinking cursor (animation "${blinkName}") in the first viewport`,
...(inHeroRegion ? { severity: 'warning' } : {}),
}];
}
@@ -6423,7 +6664,7 @@ if (IS_BROWSER) {
return {
type: f.type || f.id,
category: ap ? ap.category : 'quality',
severity: ap?.severity || 'warning',
severity: f.severity || ap?.severity || 'warning',
detail: f.detail || f.snippet,
ignoreValue: f.ignoreValue || f.value || '',
name: ap ? ap.name : (f.type || f.id),
@@ -6690,7 +6931,7 @@ if (IS_BROWSER) {
...checkElementClippedOverflowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
...checkElementGptBorderShadowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
...checkElementTextOverflowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
...checkElementBlinkingCursorDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
...checkElementBlinkingCursorDOM(el).map(f => ({ type: f.id, detail: f.snippet, ...(f.severity ? { severity: f.severity } : {}) })),
...checkElementDesignSystemDOM(el, designSystem, designSeen),
].filter(f => _ruleOk(f.type));
@@ -6789,7 +7030,25 @@ if (IS_BROWSER) {
}
const htmlPatternFindings = checkHtmlPatterns(docClone.outerHTML);
if (htmlPatternFindings.length > 0) {
const mapped = htmlPatternFindings.map(f => ({ type: f.id, detail: f.snippet })).filter(f => _ruleOk(f.type));
const mapped = htmlPatternFindings.map(f => {
const item = { type: f.id, detail: f.snippet };
if (f.severity) {
item.severity = f.severity;
} else if (f.id === 'pulsing-dot' && f.selector) {
// The string scan promotes header/nav dots on its own; with a live
// layout also promote dots resting in the first ~900px of the page
// (the hero region), which the source scan cannot measure.
try {
const dotEl = document.querySelector(f.selector);
if (dotEl) {
const rect = dotEl.getBoundingClientRect();
const pageTop = rect.top + (window.scrollY || 0);
if (pageTop <= 900) item.severity = 'error';
}
} catch { /* unresolvable selector: keep registry severity */ }
}
return item;
}).filter(f => _ruleOk(f.type));
pageLevelFindings.push(...mapped);
addBrowserFindings(groupMap, document.body, mapped);
}
+4 -1
View File
@@ -254,7 +254,7 @@ async function detectUrl(url, options = {}) {
return window.impeccableDetect({ decorate: false, serialize: true });
});
return serializedGroups.flatMap(({ findings }) =>
findings.map(f => ({ id: f.type, snippet: f.detail, ignoreValue: f.ignoreValue || '' }))
findings.map(f => ({ id: f.type, snippet: f.detail, ignoreValue: f.ignoreValue || '', severity: f.severity || '' }))
);
});
// Content invisible at rest: reveal sweep, then re-measure. Runs after
@@ -298,6 +298,9 @@ async function detectUrl(url, options = {}) {
return filterByProviders(results.map(f => {
const item = finding(f.id, url, f.snippet);
if (f.ignoreValue) item.ignoreValue = f.ignoreValue;
// Per-finding severity promotion (e.g. hero-region pulsing dot)
// overrides the registry default carried by finding().
if (f.severity && f.severity !== item.severity) item.severity = f.severity;
return item;
}), options.providers);
}
@@ -850,6 +850,11 @@ class StaticDocument {
this._styleMap = new WeakMap();
this._hoverStyleMap = new WeakMap();
this._accentDashPseudo = new WeakSet();
// Elements whose ::before/::after paints a full-cover opaque surface
// (position absolute/fixed + inset 0 + solid background). The pseudo is
// the element's visible background for contrast purposes even though it
// never joins the element cascade.
this._pseudoSurface = new WeakMap();
}
wrap(node) {
let wrapped = this._wrappers.get(node);
@@ -898,6 +903,12 @@ class StaticDocument {
hasAccentDashPseudo(el) {
return this._accentDashPseudo.has(el.node);
}
setPseudoSurface(node, color) {
this._pseudoSurface.set(node, color);
}
getPseudoSurface(el) {
return this._pseudoSurface.get(el.node) || null;
}
}
function makeStaticStyle(values = {}) {
@@ -915,6 +926,7 @@ function buildStaticWindow(staticDoc) {
getComputedStyle: (el) => staticDoc.getStyle(el),
getHoverStyle: (el) => staticDoc.getHoverStyle(el),
hasAccentDashPseudo: (el) => staticDoc.hasAccentDashPseudo(el),
getPseudoSurface: (el) => staticDoc.getPseudoSurface(el),
};
}
@@ -991,6 +1003,33 @@ function buildStaticStyleMap(root, staticDoc, cssText, modules, profile, filePat
} catch { /* unsupported base selector */ }
}
}
// Full-cover surface pseudo: the CTA construction where the
// element itself stays transparent and a ::before/::after with
// position absolute/fixed + inset 0 (or all four sides 0, or
// 100% width and height) plus an opaque background paints the
// visible surface. Mark base-selector matches so the contrast
// checks measure text against the surface the browser renders.
const pseudoPos = String(decls.get('position') || '').toLowerCase();
if (pseudoPos === 'absolute' || pseudoPos === 'fixed') {
const zeroLen = v => v != null && /^0(?:px)?$/.test(String(v).trim());
const insetRaw = String(decls.get('inset') || '').trim();
const coversBox = (insetRaw !== '' && insetRaw.split(/\s+/).every(t => /^0(?:px)?$/.test(t)))
|| ['top', 'right', 'bottom', 'left'].every(side => zeroLen(decls.get(side)))
|| (String(decls.get('width') || '').trim() === '100%'
&& String(decls.get('height') || '').trim() === '100%');
if (coversBox && decls.has('content')) {
const surfRaw = String(resolveVarRefs(decls.get('background-color') || decls.get('background') || '', rootCustomProps));
const surfToken = surfRaw.match(/(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color-mix)\([^)]*(?:\([^)]*\))?[^)]*\)|#[0-9a-f]{3,8}\b/i);
const surf = parseAnyColor(surfToken ? surfToken[0] : surfRaw);
if (surf && (surf.a ?? 1) >= 0.9 && !/gradient/i.test(surfRaw)) {
try {
for (const node of modules.selectAll(pm[1], root.children || [])) {
staticDoc.setPseudoSurface(node, surf);
}
} catch { /* unsupported base selector */ }
}
}
}
continue;
}
}
@@ -222,7 +222,12 @@ async function detectHtml(filePath, options = {}) {
for (const f of runPageCheck('html-patterns', () => checkHtmlPatterns(html).filter(item =>
item.id !== 'bounce-easing' && item.id !== 'layout-transition'
))) {
findings.push(finding(f.id, filePath, f.snippet));
const item = finding(f.id, filePath, f.snippet);
// Position-aware severity promotion: checks may attach a per-finding
// severity (e.g. a pulsing dot inside a header/nav landmark) that
// overrides the registry default.
if (f.severity) item.severity = f.severity;
findings.push(item);
}
// Text-content analyzers (em-dash overuse, marketing buzzwords,
// numbered section markers, aphoristic cadence) live in the regex
+9
View File
@@ -122,6 +122,15 @@ const ANTIPATTERNS = [
'A blinking text cursor animated into a hero or landing section simulates typing where no input exists. It borrows the dev-tool aesthetic as decoration. Real editable fields draw their own caret; anywhere else, let the composition hold attention without a fake prompt.',
skillSection: 'Motion',
},
{
id: 'shape-assembled-illustration',
category: 'slop',
severity: 'advisory',
name: 'Shape-assembled illustration',
description:
'A large inline SVG that builds a pictorial scene from a pile of primitive shapes reads as placeholder clip art, not illustration. Icons, logos, and data graphics are fine at their scale; a hero-sized visual deserves real artwork, a photograph, or a deliberately drawn graphic.',
skillSection: 'Imagery',
},
{
id: 'dark-glow',
category: 'slop',
+243 -10
View File
@@ -91,7 +91,13 @@ function checkColors(opts) {
// 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
&& ((bgColor && bgColor.a > 0.5)
// A gradient painted on the element itself is an own surface the
// same way a solid background is. Without this branch a nav CTA
// built as `<a>` with `background: linear-gradient(…)` and a text
// color that fails against every stop sails through on the
// SAFE_TAGS suppression (the shipped escape).
|| (bgImage && /gradient/i.test(bgImage)))
&& fontSize >= 9;
if (!isStyledControl) return [];
}
@@ -1043,22 +1049,129 @@ function isRoundDotRadius(radiusValue, w, h) {
return px >= 999 || px >= 0.4 * Math.min(w, h);
}
// Remove @media blocks whose condition is prefers-reduced-motion: reduce.
// Those blocks describe the accessibility fallback, not the default
// experience that ships — an `animation: none` reset inside one must not
// mask the resting-state animation the page plays for everyone else.
function stripReducedMotionBlocks(content) {
const re = /@media[^{]*prefers-reduced-motion\s*:\s*reduce[^{]*\{/gi;
let out = '';
let last = 0;
let m;
while ((m = re.exec(content)) !== null) {
let depth = 1;
let i = re.lastIndex;
while (i < content.length && depth > 0) {
const ch = content.charCodeAt(i);
if (ch === 0x7b /* { */) depth++;
else if (ch === 0x7d /* } */) depth--;
i++;
}
out += content.slice(last, m.index);
last = i;
re.lastIndex = i;
}
return out + content.slice(last);
}
// Source-index ranges of <header> and <nav> landmark elements in an HTML
// string. Lets string-level scans decide whether a matched element sits in
// the page chrome (the hero/nav region) without needing a DOM.
function landmarkSourceRanges(content) {
const ranges = [];
for (const tag of ['header', 'nav']) {
const re = new RegExp(`<${tag}\\b|</${tag}\\s*>`, 'gi');
const stack = [];
let m;
while ((m = re.exec(content)) !== null) {
if (m[0].charAt(1) === '/') {
const start = stack.pop();
if (start != null) ranges.push([start, m.index]);
} else {
stack.push(m.index);
}
}
}
return ranges;
}
function indexInSourceRanges(index, ranges) {
return ranges.some(([start, end]) => index >= start && index < end);
}
// Does any element targeted by the final compound of `selector` appear
// inside a header/nav landmark range of the HTML source? Resolves the last
// .class or #id token of the selector against class/id attributes; a
// tag-only compound is never resolvable this way and returns false
// (conservative: no promotion without placement evidence).
function selectorHitsLandmark(content, selector, ranges) {
if (!ranges || ranges.length === 0) return false;
const last = selector.split(/[\s>+~]+/).filter(Boolean).pop() || '';
const idMatch = last.match(/#([A-Za-z_][\w-]*)/);
const classMatch = last.match(/\.([A-Za-z_][\w-]*)/);
let attrRe = null;
if (idMatch) {
const id = idMatch[1].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
attrRe = new RegExp(`<[a-zA-Z][^>]*\\bid\\s*=\\s*["']${id}["']`, 'gi');
} else if (classMatch) {
const cls = classMatch[1].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
attrRe = new RegExp(`<[a-zA-Z][^>]*\\bclass\\s*=\\s*["'][^"']*(?<![\\w-])${cls}(?![\\w-])[^"']*["']`, 'gi');
}
if (!attrRe) return false;
let m;
while ((m = attrRe.exec(content)) !== null) {
if (indexInSourceRanges(m.index, ranges)) return true;
}
return false;
}
// Small circular indicator bound to an infinite pulse animation — the
// decorative "live" dot. Gates: tiny (<= 16px square-ish), round
// (border-radius >= 40% or pill values), and an infinite animation whose
// keyframes vary opacity/scale/box-shadow (or a pulse/blink/ping name when
// the keyframes aren't in the scanned text). Rotation-only animations
// (spinners) never flag.
//
// Declarations for one selector are merged across rule blocks before the
// predicate runs: size in the base rule plus the animation added in a
// second block (or inside a matching @media block) is the construction
// that ships. prefers-reduced-motion: reduce overrides are stripped first
// so their animation resets don't mask the default experience. A dot whose
// element sits inside a header/nav landmark is the hero liveness cliché
// and is promoted to error severity; occurrences elsewhere keep the
// registry default severity.
function scanCssTextForPulsingDot(content) {
const customProps = collectCssCustomProps(content);
const keyframes = collectPulseKeyframes(content);
const heroRanges = landmarkSourceRanges(content);
const findings = [];
const seen = new Set();
// Merge declarations per selector across rule blocks, approximating the
// cascade: later declarations for the same property win. Comma lists are
// split so `.a, .b { … }` contributes to both selectors. Comments are
// stripped first so they neither pollute selector keys nor smuggle a
// comma into the selector-list split.
const scanText = stripReducedMotionBlocks(content).replace(/\/\*[\s\S]*?\*\//g, ' ');
const merged = new Map();
const ruleRe = new RegExp(CSS_RULE_BLOCK_SOURCE, 'g');
let m;
while ((m = ruleRe.exec(content)) !== null) {
const selector = m[1].trim();
while ((m = ruleRe.exec(scanText)) !== null) {
const decls = parseCssDeclBlock(m[2]);
if (decls.size === 0) continue;
for (const rawSelector of m[1].split(',')) {
const selector = rawSelector.trim();
if (!selector || selector.startsWith('@')) continue;
let acc = merged.get(selector);
if (!acc) {
acc = new Map();
merged.set(selector, acc);
}
for (const [prop, value] of decls) acc.set(prop, value);
}
}
for (const [selector, decls] of merged) {
const names = infiniteAnimationNames(decls);
if (names.length === 0) continue;
const pulseName = names.find(n => {
@@ -1079,9 +1192,12 @@ function scanCssTextForPulsingDot(content) {
if (seen.has(selector)) continue;
seen.add(selector);
const inLandmark = selectorHitsLandmark(content, selector, heroRanges);
findings.push({
id: 'pulsing-dot',
snippet: `${selector}${w}x${h}px dot with infinite "${pulseName}" animation`,
snippet: `${selector}${w}x${h}px dot with infinite "${pulseName}" animation${inLandmark ? ' in header/nav' : ''}`,
selector,
...(inLandmark ? { severity: 'error' } : {}),
});
}
@@ -1098,15 +1214,78 @@ function scanCssTextForPulsingDot(content) {
const key = `tw:${cls}`;
if (seen.has(key)) continue;
seen.add(key);
const inLandmark = indexInSourceRanges(cm.index, heroRanges);
findings.push({
id: 'pulsing-dot',
snippet: `animate-${anim[1]} on tiny rounded-full element`,
snippet: `animate-${anim[1]} on tiny rounded-full element${inLandmark ? ' in header/nav' : ''}`,
...(inLandmark ? { severity: 'error' } : {}),
});
}
return findings;
}
// Shape-assembled illustration: a large inline SVG composing a pictorial
// scene from many primitive shapes (rect / circle / ellipse / polygon) in
// several fill colors — the clip-art hero mascot. Gates keep the legitimate
// SVG population out:
// • icons and logos: intrinsic size gate (>= 200px on both axes, from
// width/height attributes or the viewBox when no explicit size is set)
// • charts / labeled diagrams: more than two <text>/<tspan> nodes exempts
// the graphic (axis labels, callouts)
// • line drawings / technical diagrams: primitive count < 8 or fewer
// than 3 distinct fills never qualifies (stroke-only art has no fills)
// • tiling background textures: any <pattern> definition exempts
function scanHtmlForShapeAssembledIllustration(html) {
const findings = [];
const svgRe = /<svg\b[^>]*>[\s\S]*?<\/svg>/gi;
let m;
while ((m = svgRe.exec(html)) !== null) {
const block = m[0];
const openTag = (block.match(/^<svg\b[^>]*>/i) || [''])[0];
// Data-bearing or annotated graphics: axis labels and callout text
// mark a chart or diagram, not a mascot.
const textCount = (block.match(/<(?:text|tspan)\b/gi) || []).length;
if (textCount > 2) continue;
// Tiling texture definitions are decorative backgrounds, not scenes.
if (/<pattern\b/i.test(block)) continue;
const primitives = (block.match(/<(?:rect|circle|ellipse|polygon)\b/gi) || []).length;
if (primitives < 8) continue;
// Intrinsic size: explicit width/height attributes win; fall back to
// the viewBox box. Percentage or missing sizes stay unresolvable on
// that axis and the viewBox speaks for them.
const attrDim = (name) => {
// (?<![-\w]) keeps compound attributes like stroke-width from
// masquerading as the svg's own width.
const am = openTag.match(new RegExp(`(?<![-\\w])${name}\\s*=\\s*["']\\s*([\\d.]+)(?:px)?\\s*["']`, 'i'));
return am ? parseFloat(am[1]) : null;
};
const vb = openTag.match(/\bviewBox\s*=\s*["']\s*[-\d.]+[\s,]+[-\d.]+[\s,]+([\d.]+)[\s,]+([\d.]+)\s*["']/i);
const w = attrDim('width') ?? (vb ? parseFloat(vb[1]) : null);
const h = attrDim('height') ?? (vb ? parseFloat(vb[2]) : null);
if (w == null || h == null || w < 200 || h < 200) continue;
// Distinct fill paints (attributes and inline styles), excluding
// non-paints. Multiple fills are what turn a shape pile into a scene.
const fills = new Set();
for (const fm of block.matchAll(/\bfill\s*[:=]\s*["']?\s*([^"';>}\s]+)/gi)) {
const paint = fm[1].trim().toLowerCase();
if (!paint || ['none', 'transparent', 'currentcolor', 'inherit'].includes(paint)) continue;
fills.add(paint);
}
if (fills.size < 3) continue;
findings.push({
id: 'shape-assembled-illustration',
snippet: `inline <svg> scene: ${primitives} primitive shapes, ~${Math.round(w)}x${Math.round(h)}px, ${fills.size} fill colors`,
});
}
return findings;
}
/**
* Regex-on-HTML checks shared between browser and Node page-level detection.
* These don't need DOM access, just the raw HTML string.
@@ -1228,6 +1407,9 @@ function checkHtmlPatterns(html) {
// Pulsing status dots (tiny circular elements on infinite pulse animations)
findings.push(...scanCssTextForPulsingDot(html));
// Shape-assembled illustrations (large pictorial SVGs built from primitives)
findings.push(...scanHtmlForShapeAssembledIllustration(html));
// Auto-scrolling marquees (<marquee> or infinite horizontal loop animations)
findings.push(...scanCssTextForMarquee(html));
@@ -1620,6 +1802,28 @@ function checkElementPseudoStripeDOM(el) {
return findings;
}
// Full-cover surface pseudo (browser): a ::before/::after positioned
// absolute/fixed whose box covers (nearly) the whole host and carries an
// opaque background. That pseudo is the element's visible surface even
// though the element's own background-color reads transparent — the nav-CTA
// construction that otherwise escapes every own-background contrast gate.
function readPseudoSurfaceDOM(el, rect) {
for (const which of ['::before', '::after']) {
let ps;
try { ps = getComputedStyle(el, which); } catch { continue; }
if (!ps || ps.content === 'none' || ps.content === '') continue;
if (ps.position !== 'absolute' && ps.position !== 'fixed') continue;
if (ps.display === 'none' || (parseFloat(ps.opacity) || 1) < 0.9) continue;
const w = parseFloat(ps.width) || 0;
const h = parseFloat(ps.height) || 0;
if (w < rect.width - 4 || h < rect.height - 4) continue;
const bg = parseRgb(ps.backgroundColor) || parseAnyColor(ps.backgroundColor);
if (!bg || (bg.a ?? 1) < 0.9) continue;
return bg;
}
return null;
}
function checkElementColorsDOM(el) {
const tag = el.tagName.toLowerCase();
// No early SAFE_TAGS bail here — checkColors() does its own gating that
@@ -1630,7 +1834,15 @@ function checkElementColorsDOM(el) {
const style = getComputedStyle(el);
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
const effectiveBg = resolveBackground(el);
let effectiveBg = resolveBackground(el);
let ownBg = readOwnBackgroundColor(el, style);
if (!ownBg || (ownBg.a ?? 1) <= 0.5) {
const pseudoSurface = readPseudoSurfaceDOM(el, rect);
if (pseudoSurface) {
ownBg = pseudoSurface;
effectiveBg = pseudoSurface;
}
}
return checkColors({
tag,
// Chrome serializes computed colors specified in modern spaces as
@@ -1639,7 +1851,7 @@ function checkElementColorsDOM(el) {
// silently never run (the shipped miss: a nav CTA whose text color was
// an oklch token near its own oklch background).
textColor: parseRgb(style.color) || parseAnyColor(style.color),
bgColor: readOwnBackgroundColor(el, style),
bgColor: ownBg,
effectiveBg,
effectiveBgStops: effectiveBg ? null : resolveGradientStops(el),
fontSize: parseFloat(style.fontSize) || 16,
@@ -3011,15 +3223,28 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
// 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)
let ownBg = (customPropMap ? parseColorResolved(style.backgroundColor, customPropMap) : null)
|| readOwnBackgroundColor(el, style);
// Full-cover surface pseudo (static): the cascade pass marks elements
// whose ::before/::after paints an opaque covering surface. When the
// element itself has no usable own background, that pseudo is the real
// surface for contrast purposes.
let finalEffectiveBg = effectiveBg;
if ((!ownBg || (ownBg.a ?? 1) <= 0.5) && typeof window.getPseudoSurface === 'function') {
const pseudoSurface = window.getPseudoSurface(el);
if (pseudoSurface) {
ownBg = pseudoSurface;
finalEffectiveBg = pseudoSurface;
}
}
return checkColors({
tag,
textColor,
bgColor: ownBg,
effectiveBg,
effectiveBgStops: effectiveBg ? null : resolveGradientStops(el, window),
effectiveBg: finalEffectiveBg,
effectiveBgStops: finalEffectiveBg ? null : resolveGradientStops(el, window),
fontSize: parseFloat(style.fontSize) || 16,
fontWeight: parseInt(style.fontWeight) || 400,
hasDirectText,
@@ -4275,9 +4500,16 @@ function checkElementBlinkingCursorDOM(el) {
}
if (!glyphCursor && !blockCursor) return [];
// Hero-region promotion: a fake caret blinking in the first ~900px or
// inside the page chrome is the shipped hero cliché, not an incidental
// flourish. Promote those from the registry's advisory to warning;
// lower first-viewport occurrences keep the default severity.
const inHeroRegion = pageTop <= 900
|| !!(el.closest && el.closest('header, nav, [role="banner"], [role="navigation"]'));
return [{
id: 'blinking-cursor',
snippet: `${classSelector(el)}${Math.round(rect.width)}x${Math.round(rect.height)}px blinking cursor (animation "${blinkName}") in the first viewport`,
...(inHeroRegion ? { severity: 'warning' } : {}),
}];
}
@@ -4462,6 +4694,7 @@ export {
collectCssCustomProps,
cssLengthToPx,
scanCssTextForPulsingDot,
scanHtmlForShapeAssembledIllustration,
checkHtmlPatterns,
readOwnBackgroundColor,
resolveBackground,
+4 -2
View File
@@ -498,6 +498,7 @@ import '../styles/testimonials.css';
<span class="ks-bento-num" data-color="kinpaku">05</span>
<h3 class="why-panel-title">Brand work is not product UI.</h3>
<p class="why-panel-body">A landing page and a dashboard play by different rules. Impeccable runs in two registers, <em>brand</em> or <em>product</em>, and every command knows which.</p>
<div class="why-visual why-visual--registers">
<div class="why-register why-register--brand">
<span class="why-register-label">Brand mode</span>
@@ -515,13 +516,14 @@ import '../styles/testimonials.css';
</div>
</div>
</div>
</article>
<!-- T6: medium. CI/CD-ready. -->
<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. 56 deterministic rules, no LLM, exit codes the build can read.</p>
<p class="why-panel-body">A detector you can wire into PR checks. 57 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 +801,7 @@ import '../styles/testimonials.css';
</li>
<li>
<strong>CLI for CI</strong>
<span><code>npx impeccable detect src/</code> in a PR check. 56 deterministic rules. JSON output, exit codes for build gates.</span>
<span><code>npx impeccable detect src/</code> in a PR check. 57 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>
+140 -2
View File
@@ -28,6 +28,7 @@ import {
scanCssTextForPseudoStripe,
scanCssTextForPulsingDot,
scanCssTextForRadialHalo,
scanHtmlForShapeAssembledIllustration,
} from '../cli/engine/rules/checks.mjs';
const FIXTURES = path.join(import.meta.dir, 'fixtures', 'antipatterns');
@@ -1539,14 +1540,15 @@ describe('hero-eyebrow dash-prefix branch', () => {
// ---------------------------------------------------------------------------
describe('pulsing-dot', () => {
test('fixture flags the four pulsing dots and none of the passes', async () => {
test('fixture flags the five pulsing dots and none of the passes', async () => {
const f = await detectHtml(path.join(FIXTURES, 'pulsing-dot.html'));
const dots = f.filter(r => r.antipattern === 'pulsing-dot');
const snippets = dots.map(r => r.snippet).join(' | ');
expect(dots).toHaveLength(4);
expect(dots).toHaveLength(5);
expect(snippets).toContain('.live-dot');
expect(snippets).toContain('.status .dot');
expect(snippets).toContain('.beacon');
expect(snippets).toContain('.rec-mark');
expect(snippets).toContain('animate-ping');
expect(snippets).not.toContain('spinner');
expect(snippets).not.toContain('fake-pulse');
@@ -1554,6 +1556,57 @@ describe('pulsing-dot', () => {
expect(snippets).not.toContain('square-badge');
});
test('header dot is promoted to error severity; body dots keep the default', async () => {
const f = await detectHtml(path.join(FIXTURES, 'pulsing-dot.html'));
const dots = f.filter(r => r.antipattern === 'pulsing-dot');
const headerDot = dots.find(r => r.snippet.includes('.rec-mark'));
expect(headerDot.severity).toBe('error');
expect(headerDot.snippet).toContain('in header/nav');
const bodyDot = dots.find(r => r.snippet.includes('.live-dot'));
expect(bodyDot.severity).toBe('warning');
expect(bodyDot.snippet).not.toContain('in header/nav');
});
test('merges size and animation declared in separate rule blocks for one selector', () => {
const css = `
.dot { width: 8px; height: 8px; border-radius: 50%; }
.dot { animation: pulse 2s infinite; }
@keyframes pulse { 50% { opacity: 0.3; } }
`;
const f = scanCssTextForPulsingDot(css);
expect(f).toHaveLength(1);
expect(f[0].selector).toBe('.dot');
});
test('descends into matching media queries for the animation half', () => {
const css = `
.dot { width: 7px; height: 7px; border-radius: 50%; }
@media (prefers-reduced-motion: no-preference) {
.dot { animation: pulseDot 2.4s ease-in-out infinite; }
}
@keyframes pulseDot { 0%, 100% { opacity: 1; } 50% { opacity: 0.45; } }
`;
expect(scanCssTextForPulsingDot(css)).toHaveLength(1);
});
test('prefers-reduced-motion: reduce resets never mask the default animation', () => {
const css = `
.dot { width: 8px; height: 8px; border-radius: 50%; animation: pulse 2s infinite; }
@media (prefers-reduced-motion: reduce) { .dot { animation: none; } }
@keyframes pulse { 50% { opacity: 0.3; } }
`;
expect(scanCssTextForPulsingDot(css)).toHaveLength(1);
});
test('a later animation: none outside reduced-motion disables the dot', () => {
const css = `
.dot { width: 8px; height: 8px; border-radius: 50%; animation: pulse 2s infinite; }
.dot { animation: none; }
@keyframes pulse { 50% { opacity: 0.3; } }
`;
expect(scanCssTextForPulsingDot(css)).toHaveLength(0);
});
test('detects tiny circle with infinite opacity-pulse keyframes', () => {
const css = `
.dot { width: 8px; height: 8px; border-radius: 50%; animation: pulse 2s infinite; }
@@ -1628,6 +1681,91 @@ describe('pulsing-dot', () => {
});
});
// ---------------------------------------------------------------------------
// Shape-assembled illustrations
// ---------------------------------------------------------------------------
describe('shape-assembled-illustration', () => {
test('fixture flags only the hero mascot scene', async () => {
const f = await detectHtml(path.join(FIXTURES, 'shape-assembled-illustration.html'));
const hits = f.filter(r => r.antipattern === 'shape-assembled-illustration');
expect(hits).toHaveLength(1);
expect(hits[0].severity).toBe('advisory');
expect(hits[0].snippet).toContain('primitive shapes');
});
test('flags a large multi-fill primitive scene', () => {
const shapes = Array.from({ length: 10 }, (_, i) =>
`<rect x="${i * 30}" y="40" width="24" height="60" fill="#c${i % 4}${i % 8}"/>`).join('');
const html = `<svg viewBox="0 0 400 300">${shapes}<circle cx="60" cy="40" r="20" fill="#123456"/></svg>`;
expect(scanHtmlForShapeAssembledIllustration(html)).toHaveLength(1);
});
test('small explicit size wins over a large viewBox (icons, logos)', () => {
const shapes = Array.from({ length: 10 }, (_, i) =>
`<rect x="${i * 30}" y="40" width="24" height="60" fill="#c${i % 4}${i % 8}"/>`).join('');
const html = `<svg viewBox="0 0 400 300" width="32" height="32">${shapes}</svg>`;
expect(scanHtmlForShapeAssembledIllustration(html)).toHaveLength(0);
});
test('axis-labeled charts never flag', () => {
const bars = Array.from({ length: 9 }, (_, i) =>
`<rect x="${i * 40}" y="${100 + i * 10}" width="30" height="${200 - i * 10}" fill="#${i % 3}${i % 3}${i % 3}abc"/>`).join('');
const labels = '<text x="0" y="380">Q1</text><text x="120" y="380">Q2</text><text x="240" y="380">Q3</text>';
const html = `<svg viewBox="0 0 400 400">${bars}${labels}</svg>`;
expect(scanHtmlForShapeAssembledIllustration(html)).toHaveLength(0);
});
test('stroke-only line drawings (no fills) never flag', () => {
const shapes = Array.from({ length: 10 }, (_, i) =>
`<circle cx="${40 + i * 30}" cy="150" r="20"/>`).join('');
const html = `<svg viewBox="0 0 400 300" fill="none" stroke="currentColor">${shapes}</svg>`;
expect(scanHtmlForShapeAssembledIllustration(html)).toHaveLength(0);
});
test('tiling pattern backgrounds never flag', () => {
const shapes = Array.from({ length: 10 }, (_, i) =>
`<rect x="${i * 30}" y="40" width="24" height="60" fill="#c${i % 4}${i % 8}"/>`).join('');
const html = `<svg viewBox="0 0 1200 600"><defs><pattern id="p" width="10" height="10"><rect width="5" height="5" fill="#eee"/></pattern></defs>${shapes}</svg>`;
expect(scanHtmlForShapeAssembledIllustration(html)).toHaveLength(0);
});
test('fewer than eight primitives never flags (path-heavy real illustration)', () => {
const html = `<svg viewBox="0 0 600 400">
<path d="M0 0 C 10 10, 20 20, 30 30" fill="#111"/>
<path d="M5 5 C 15 15, 25 25, 35 35" fill="#222"/>
<rect x="10" y="10" width="40" height="40" fill="#333"/>
<circle cx="100" cy="100" r="30" fill="#444"/>
</svg>`;
expect(scanHtmlForShapeAssembledIllustration(html)).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// Nav CTA contrast construction sweep
// ---------------------------------------------------------------------------
describe('nav CTA contrast constructions', () => {
// One fixture per construction family that has shipped (or could ship) a
// low-contrast header CTA past the detector: solid own bg, specificity
// cascade, var() indirection, own gradient bg, pseudo-element surface,
// inherited color, alpha-composited bg, oklch serialization. The
// background-image: url(...) variant stays unflaggable by design (no
// computable surface color).
test('every computable construction produces a low-contrast finding', async () => {
const f = await detectHtml(path.join(FIXTURES, 'nav-cta-constructions.html'));
const lows = f.filter(r => r.antipattern === 'low-contrast').map(r => r.snippet).join(' | ');
expect(lows).toContain('#e8b84b'); // v1 solid bg + own color
expect(lows).toContain('#e5a93f'); // v2 specificity cascade
expect(lows).toContain('#f0b64e'); // v3 var() indirection
expect(lows).toContain('#f2b854'); // v4 own gradient bg (worst stop)
expect(lows).toContain('#efb352'); // v5 pseudo-element surface
expect(lows).toContain('#eab04a'); // v6 inherited color
expect(lows).toContain('#cf9c48'); // v7 alpha bg composited over header
expect(lows).toContain('#fcb442'); // v8 oklch on both sides
});
});
// ---------------------------------------------------------------------------
// ANTIPATTERNS registry
+73
View File
@@ -0,0 +1,73 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Nav CTA contrast construction sweep</title>
<style>
:root{
--v3bg: #f0b64e;
--v3fg: #cfd9e9;
}
body{ margin:0; font-family: Georgia, serif; color:#1c2430; background:#ffffff; }
header{ display:flex; align-items:center; gap:2rem; padding:1rem 2rem; background:#12213a; }
.nav{ display:flex; gap:1.5rem; align-items:center; }
.nav a{ font-size:0.9rem; text-decoration:none; }
.cta{ display:inline-block; padding:0.6em 1.1em; border-radius:6px; font-weight:600; }
/* v1: solid bg + own low-contrast color, both on the button class */
a.cta.v1{ background:#e8b84b; color:#c9d6ea; }
/* v2: specificity cascade — .nav a (0,1,1) beats .v2 (0,1,0) for color */
.nav a{ color:#b7c6de; }
.v2{ background:#e5a93f; color:#26313f; }
/* v3: var() indirection for both colors */
a.cta.v3{ background:var(--v3bg); color:var(--v3fg); }
/* v4: gradient background, low-contrast text against every stop */
a.cta.v4{ background:linear-gradient(90deg,#f2b854,#e8a83e); color:#d3dcec; }
/* v5: pseudo-element paints the surface; button itself transparent */
a.cta.v5{ position:relative; background:transparent; color:#d0daea; }
.v5::before{ content:""; position:absolute; inset:0; border-radius:6px; background:#efb352; z-index:-1; }
/* v6: color inherited from header, button only sets bg */
header{ color:#ccd8ea; }
.v6{ background:#eab04a; }
/* v7: alpha-composited own bg over the dark header */
a.cta.v7{ background:rgb(240 178 74 / 0.85); color:#d2dcec; }
/* v8: oklch serialization for both sides */
a.cta.v8{ background:oklch(0.82 0.15 75); color:oklch(0.80 0.05 258); }
/* v9: background-image url only (no computable color) + low contrast vs header */
a.cta.v9{ background-image:url("data:image/gif;base64,R0lGODlhAQABAIAAAP7uSwAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw=="); color:#d5deed; }
</style>
</head>
<body>
<header>
<span style="color:#f4f7fb;font-weight:700">Acme</span>
<nav class="nav" aria-label="Primary">
<a href="#a">Product</a>
<a href="#b">Docs</a>
<a class="cta v1" href="#s">Start now v1</a>
<a class="cta v2" href="#s">Start now v2</a>
<a class="cta v3" href="#s">Start now v3</a>
<a class="cta v4" href="#s">Start now v4</a>
<a class="cta v5" href="#s">Start now v5</a>
<a class="cta v6" href="#s">Start now v6</a>
<a class="cta v7" href="#s">Start now v7</a>
<a class="cta v8" href="#s">Start now v8</a>
<a class="cta v9" href="#s">Start now v9</a>
</nav>
</header>
<main style="padding:4rem 2rem; max-width:60ch;">
<h1 style="font-size:2.4rem; margin:0 0 1rem;">A page that exists to host a header</h1>
<p style="line-height:1.6;">The paragraphs below the header keep the document honest so the
detector treats this as a full page rather than a fragment. Nothing here should trip any rule.</p>
<p style="line-height:1.6;">Second paragraph of ordinary body copy with comfortable measure and
line height, dark ink on paper.</p>
</main>
</body>
</html>
+18
View File
@@ -77,9 +77,27 @@
border-radius: 2px;
animation: pulse 2s infinite;
}
/* FLAG (error): header logo dot whose size and animation live in
separate rule blocks, animation added inside a matching media query,
with a reduced-motion reset that must not mask the default. */
.rec-mark {
width: 10px; height: 10px;
border-radius: 50%;
background: #dc2626;
}
@media (prefers-reduced-motion: no-preference) {
.rec-mark { animation: pulse 2.4s ease-in-out infinite; }
}
@media (prefers-reduced-motion: reduce) {
.rec-mark { animation: none; }
}
</style>
</head>
<body>
<header>
<a href="#top"><span class="rec-mark"></span> Brand</a>
</header>
<span class="live-dot"></span>
<span class="status"><i class="dot"></i> Online</span>
<span class="beacon"></span>
@@ -0,0 +1,95 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Shape-assembled illustration fixture</title>
</head>
<body>
<header>
<!-- PASS: logo, small (explicit size) even with a big viewBox -->
<svg viewBox="0 0 400 400" width="32" height="32" aria-hidden="true">
<rect x="0" y="0" width="180" height="180" fill="#c33"/>
<rect x="220" y="0" width="180" height="180" fill="#3c3"/>
<rect x="0" y="220" width="180" height="180" fill="#33c"/>
<rect x="220" y="220" width="180" height="180" fill="#cc3"/>
<circle cx="200" cy="200" r="60" fill="#333"/>
<circle cx="200" cy="200" r="30" fill="#eee"/>
<polygon points="10,10 30,10 20,30" fill="#933"/>
<polygon points="370,10 390,10 380,30" fill="#393"/>
</svg>
<span>Acme</span>
</header>
<main>
<!-- FLAG: hero mascot scene assembled from primitives at hero size -->
<svg class="mascot" viewBox="0 0 640 480" role="img" aria-label="A cheerful robot waving">
<rect x="200" y="120" width="240" height="200" rx="24" fill="#8ecae6"/>
<rect x="240" y="340" width="60" height="100" fill="#219ebc"/>
<rect x="340" y="340" width="60" height="100" fill="#219ebc"/>
<circle cx="270" cy="200" r="24" fill="#ffffff"/>
<circle cx="370" cy="200" r="24" fill="#ffffff"/>
<circle cx="270" cy="200" r="10" fill="#023047"/>
<circle cx="370" cy="200" r="10" fill="#023047"/>
<ellipse cx="320" cy="270" rx="50" ry="20" fill="#ffb703"/>
<polygon points="180,160 140,120 180,120" fill="#fb8500"/>
<polygon points="460,160 500,120 460,120" fill="#fb8500"/>
<rect x="300" y="60" width="8" height="60" fill="#023047"/>
<circle cx="304" cy="52" r="12" fill="#fb8500"/>
</svg>
<!-- PASS: bar chart, data-bearing (axis labels) -->
<svg viewBox="0 0 600 400" role="img" aria-label="Monthly signups">
<rect x="60" y="220" width="40" height="120" fill="#4477aa"/>
<rect x="120" y="180" width="40" height="160" fill="#4477aa"/>
<rect x="180" y="140" width="40" height="200" fill="#66ccee"/>
<rect x="240" y="200" width="40" height="140" fill="#66ccee"/>
<rect x="300" y="100" width="40" height="240" fill="#228833"/>
<rect x="360" y="90" width="40" height="250" fill="#228833"/>
<rect x="420" y="60" width="40" height="280" fill="#ccbb44"/>
<rect x="480" y="40" width="40" height="300" fill="#ccbb44"/>
<line x1="40" y1="340" x2="560" y2="340" stroke="#333"/>
<text x="60" y="370">Jan</text>
<text x="180" y="370">Mar</text>
<text x="300" y="370">May</text>
<text x="420" y="370">Jul</text>
</svg>
<!-- PASS: stroke-only technical line drawing, no fills -->
<svg viewBox="0 0 600 470" fill="none" stroke="currentColor" stroke-width="4">
<circle cx="200" cy="200" r="80"/>
<circle cx="200" cy="200" r="40"/>
<ellipse cx="400" cy="220" rx="90" ry="40"/>
<rect x="120" y="320" width="360" height="60"/>
<rect x="150" y="340" width="80" height="20"/>
<rect x="260" y="340" width="80" height="20"/>
<polygon points="500,80 540,140 460,140"/>
<polygon points="80,80 120,140 40,140"/>
<path d="M120 120 C 200 40, 400 40, 480 120"/>
</svg>
<!-- PASS: full-bleed tiling background texture -->
<svg width="100%" height="600" viewBox="0 0 1200 600" aria-hidden="true">
<defs>
<pattern id="tile" width="40" height="40" patternUnits="userSpaceOnUse">
<rect width="20" height="20" fill="#eee"/>
<rect x="20" y="20" width="20" height="20" fill="#ddd"/>
<circle cx="30" cy="10" r="4" fill="#ccc"/>
</pattern>
</defs>
<rect width="1200" height="600" fill="url(#tile)"/>
<rect x="0" y="0" width="600" height="300" fill="url(#tile)"/>
<rect x="600" y="300" width="600" height="300" fill="url(#tile)"/>
<circle cx="300" cy="300" r="80" fill="url(#tile)"/>
<circle cx="900" cy="150" r="60" fill="url(#tile)"/>
<circle cx="150" cy="450" r="50" fill="url(#tile)"/>
<polygon points="700,100 760,180 640,180" fill="url(#tile)"/>
<polygon points="1000,400 1060,480 940,480" fill="url(#tile)"/>
</svg>
<!-- PASS: small inline icon -->
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<circle cx="12" cy="12" r="10"/>
<path d="M8 12l3 3 5-6"/>
</svg>
</main>
</body>
</html>