mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-14 07:06:45 +03:00
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:
co-authored by
Claude Fable 5
parent
1734f13a2e
commit
dc0b25d393
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
Reference in New Issue
Block a user