diff --git a/README.md b/README.md index 91b2cd6fc..cc486ccc6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Impeccable -Design guidance for AI coding agents. 1 skill, 23 commands, live browser iteration, and 51 deterministic detector rules for AI-generated frontend design. +Design guidance for AI coding agents. 1 skill, 23 commands, live browser iteration, and 53 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. -- **51 deterministic detector rules** plus LLM-only critique checks. The CLI and browser extension run the deterministic rules with no LLM and no API key. +- **53 deterministic detector rules** plus LLM-only critique checks. The CLI and browser extension run the deterministic rules with no LLM and no API key. ## What's Included diff --git a/README.npm.md b/README.npm.md index 7d82057fd..67ba9d276 100644 --- a/README.npm.md +++ b/README.npm.md @@ -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 51 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 53 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 -51 deterministic detector rules in total. See the full catalog at [impeccable.style/slop](https://impeccable.style/slop). +53 deterministic detector rules in total. See the full catalog at [impeccable.style/slop](https://impeccable.style/slop). ## Exit Codes diff --git a/cli/engine/browser/injected/index.mjs b/cli/engine/browser/injected/index.mjs index 13342d599..2ace5650b 100644 --- a/cli/engine/browser/injected/index.mjs +++ b/cli/engine/browser/injected/index.mjs @@ -1489,6 +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 })), ...checkElementDesignSystemDOM(el, designSystem, designSeen), ].filter(f => _ruleOk(f.type)); @@ -1549,6 +1550,12 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, el, [{ type: f.type, detail: f.detail || f.snippet }]); } + // Heading rhythm (browser-only: needs real layout for the gap math) + const headingRhythmFindings = checkHeadingRhythmDOM().filter(f => _ruleOk(f.type)); + for (const f of headingRhythmFindings) { + addBrowserFindings(groupMap, f.el || document.body, [{ type: f.type, detail: f.detail }]); + } + // Page-level quality checks (headings, etc.) const qualityFindings = checkPageQualityDOM().filter(f => _ruleOk(f.type)); if (qualityFindings.length > 0) { diff --git a/cli/engine/detect-antipatterns-browser.js b/cli/engine/detect-antipatterns-browser.js index f4ceef75f..3aff11077 100644 --- a/cli/engine/detect-antipatterns-browser.js +++ b/cli/engine/detect-antipatterns-browser.js @@ -215,6 +215,15 @@ const ANTIPATTERNS = [ skillSection: 'Motion', skillGuideline: 'decorative pulsing status dot', }, + { + id: 'blinking-cursor', + category: 'slop', + severity: 'advisory', + name: 'Decorative blinking cursor', + description: + '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: 'dark-glow', category: 'slop', @@ -432,6 +441,15 @@ const ANTIPATTERNS = [ description: 'Heading levels should not skip (e.g. h1 then h3 with no h2). Screen readers use heading hierarchy for navigation. Skipping levels breaks the document outline.', }, + { + id: 'heading-rhythm', + category: 'quality', + scopes: ['layout', 'type'], + name: 'Heading crowded against the previous block', + description: + 'A heading binds to the content it introduces, so the rendered space above it should exceed the space below it. When headings across a page sit as close or closer to the block above than to their own content, every section reads as if it captions the previous one. Open up the space above each heading.', + skillSection: 'Layout & Space', + }, { id: 'justified-text', category: 'quality', @@ -3999,6 +4017,157 @@ function checkLayout() { return findings; } +// Heading rhythm (browser-only): a heading binds to the content it +// introduces, so its rendered space above must exceed its space below. +// Margins alone can't be trusted (collapsing, flex rows, section padding), +// so this measures actual getBoundingClientRect gaps between the heading +// and the nearest content genuinely above / below it. Fires only when two +// or more headings violate the principle — a single occurrence is noise. +function checkHeadingRhythmDOM() { + const MIN_VIOLATIONS = 2; + const CARD_EXEMPT_HEIGHT = 200; + const MAX_BELOW_PX = 160; // beyond this the heading isn't binding to nearby content at all + const MIN_DEFICIT_PX = 12; + + function isVisibleFlow(el) { + const style = getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden') return false; + if (parseFloat(style.opacity || '1') <= 0.05) return false; + if (style.position === 'absolute' || style.position === 'fixed' || style.position === 'sticky') return false; + const rect = el.getBoundingClientRect(); + return rect.width >= 1 && rect.height >= 1; + } + + // Edges only count when they share the heading's column — grid layouts + // put content beside a heading, and a far-away element in another column + // says nothing about the heading's vertical rhythm. + function overlapsX(sr, rect) { + return Math.min(sr.right, rect.right) - Math.max(sr.left, rect.left) >= 8; + } + + // Does this container draw its own top boundary (background, top border, + // shadow)? Crossing out of such a container means the container edge is + // the separator above the heading, not raw whitespace — exempt. + function hasOwnTopBoundary(el) { + const style = getComputedStyle(el); + const bg = parseAnyColor(style.backgroundColor || ''); + if (bg && (bg.a ?? 1) > 0.05) return true; + if ((parseFloat(style.borderTopWidth) || 0) > 0) return true; + if (style.boxShadow && style.boxShadow !== 'none') return true; + return false; + } + + // Eyebrows, kickers, and index labels sitting directly on top of a + // heading belong to the heading's own cluster — space above is measured + // from the top of the cluster, not from the label to the heading. + function clusterTop(h, rect) { + const headingFontSize = parseFloat(getComputedStyle(h).fontSize) || 16; + let topEl = h; + let top = rect.top; + for (let i = 0; i < 3; i++) { + const sib = topEl.previousElementSibling; + if (!sib || !isVisibleFlow(sib)) break; + const sr = sib.getBoundingClientRect(); + if (!overlapsX(sr, rect)) break; + const gap = top - sr.bottom; + if (gap < 0 || gap >= 28 || sr.height > 60) break; + const text = (sib.textContent || '').trim(); + const sibFontSize = parseFloat(getComputedStyle(sib).fontSize) || 16; + const labelLike = sibFontSize < headingFontSize * 0.75 || text.length <= 40; + if (!labelLike || text.length > 80) break; + topEl = sib; + top = sr.top; + } + return { topEl, top }; + } + + // Nearest content edge strictly above the heading cluster. Walks + // previous siblings, then out through ancestors. Skips elements that + // vertically overlap (flex-row companions, sticky rails) or sit in + // another column. Returns null when nothing qualifies — first content + // on the page, or the top of a visually bounded container. + function edgeAbove(startEl, top, rect) { + let node = startEl; + while (node && node !== document.body) { + let sib = node.previousElementSibling; + while (sib) { + if (isVisibleFlow(sib)) { + const sr = sib.getBoundingClientRect(); + if (sr.bottom <= top + 2 && overlapsX(sr, rect)) return sr.bottom; + } + sib = sib.previousElementSibling; + } + const parent = node.parentElement; + if (!parent || parent === document.body) return null; + // Leaving a container upward: if it draws its own top edge, that + // edge separates the heading from whatever sits above. + if (hasOwnTopBoundary(parent)) return null; + node = parent; + } + return null; + } + + // Nearest content edge strictly below the heading — the block the + // heading introduces. Crosses wrappers freely (headings often share a + // row wrapper with an eyebrow or index label). + function edgeBelow(h, rect) { + let node = h; + while (node && node !== document.body) { + let sib = node.nextElementSibling; + while (sib) { + if (isVisibleFlow(sib)) { + const sr = sib.getBoundingClientRect(); + if (sr.top >= rect.bottom - 2 && overlapsX(sr, rect)) return sr.top; + } + sib = sib.nextElementSibling; + } + node = node.parentElement; + } + return null; + } + + function insideSmallCard(h) { + let cur = h.parentElement; + while (cur && cur !== document.body) { + if (isCardLikeDOM(cur)) { + const cr = cur.getBoundingClientRect(); + if (cr.height < CARD_EXEMPT_HEIGHT) return true; + } + cur = cur.parentElement; + } + return false; + } + + const candidates = []; + for (const h of document.querySelectorAll('h2, h3, h4')) { + if (!isVisibleFlow(h)) continue; + const text = (h.textContent || '').trim().replace(/\s+/g, ' '); + if (text.length < 3) continue; + const rect = h.getBoundingClientRect(); + const belowTop = edgeBelow(h, rect); + if (belowTop == null) continue; // heading introduces nothing measurable + const { topEl, top } = clusterTop(h, rect); + const aboveBottom = edgeAbove(topEl, top, rect); + if (aboveBottom == null) continue; // first content, or bounded container + if (insideSmallCard(h)) continue; + const above = Math.max(0, top - aboveBottom); + const below = Math.max(0, belowTop - rect.bottom); + if (below < 6 || below > MAX_BELOW_PX) continue; + // Violation: the space above clearly fails to exceed the space below. + // Near-equal gaps are ambiguous rather than inverted, so they pass. + if (above < below * 0.75 && below - above >= MIN_DEFICIT_PX) { + candidates.push({ el: h, tag: h.tagName.toLowerCase(), text: text.slice(0, 60), above, below }); + } + } + + if (candidates.length < MIN_VIOLATIONS) return []; + return candidates.map(c => ({ + type: 'heading-rhythm', + detail: `${c.tag} "${c.text}" has ${Math.round(c.above)}px above vs ${Math.round(c.below)}px below — it reads as bound to the block above (${candidates.length} headings on page)`, + el: c.el, + })); +} + // Node page-level checks — take document/window as parameters function checkPageTypography(doc, win) { @@ -4711,6 +4880,110 @@ function checkElementTextOverflowDOM(el) { return []; } +// --------------------------------------------------------------------------- +// Blinking cursor (browser-only) +// --------------------------------------------------------------------------- + +// Block / underscore glyphs commonly used as a fake text cursor. +const CURSOR_GLYPH_RE = /^[_|▀-▟■▮❙❚|]$/; + +// How far down the page still counts as the first-viewport / hero region. +// Hero compositions regularly run past a literal viewport height, so the +// gate is a landing-region budget, not an exact fold line. +const CURSOR_FIRST_VIEWPORT_PX = 1200; + +// Do the named @keyframes only toggle visibility (opacity dropping to ~0 or +// visibility:hidden), i.e. a blink rather than a fade/move/spin? Walks the +// live CSSOM; cross-origin sheets are skipped. +function keyframesToggleVisibilityDOM(name) { + if (!name) return false; + for (const sheet of document.styleSheets) { + let rules; + try { rules = sheet.cssRules || sheet.rules; } catch { continue; } + if (!rules) continue; + const stack = [...rules]; + while (stack.length) { + const rule = stack.shift(); + if (rule.cssRules && rule.type !== 7) { stack.push(...rule.cssRules); continue; } + if (rule.type !== 7 || rule.name !== name) continue; // 7 = KEYFRAMES_RULE + let togglesOut = false; + for (const frame of rule.cssRules || []) { + const fs = frame.style; + if (!fs) continue; + for (let i = 0; i < fs.length; i++) { + const prop = fs[i]; + if (prop === 'opacity') { + if ((parseFloat(fs.getPropertyValue('opacity')) || 0) <= 0.15) togglesOut = true; + } else if (prop === 'visibility') { + if (/hidden/i.test(fs.getPropertyValue('visibility'))) togglesOut = true; + } else if (prop !== 'animation-timing-function') { + return false; // keyframes animate something else — not a blink + } + } + } + return togglesOut; + } + } + return false; +} + +// Decorative blinking cursor: a small block / underscore element bound to an +// infinite blink animation, sitting in the first-viewport region of a page. +// Real editable surfaces (inputs, textareas, contenteditable, role=textbox) +// draw their own caret and are exempt. Round pulsing dots stay with the +// pulsing-dot rule. +function checkElementBlinkingCursorDOM(el) { + const tag = el.tagName.toLowerCase(); + if (['input', 'textarea', 'select', 'img', 'svg', 'script', 'style'].includes(tag)) return []; + const style = getComputedStyle(el); + + const iterations = (style.animationIterationCount || '').split(',').map(s => s.trim()); + if (!iterations.includes('infinite')) return []; + const names = (style.animationName || '').split(',').map(s => s.trim()).filter(n => n && n !== 'none'); + if (names.length === 0) return []; + const blinkName = names.find(n => /blink|caret|cursor/i.test(n)) + || names.find(n => keyframesToggleVisibilityDOM(n)); + if (!blinkName) return []; + + // Real caret contexts are exempt. + if (el.isContentEditable || el.closest('[contenteditable=""], [contenteditable="true"], [role="textbox"]')) return []; + + const rect = el.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return []; + + // First-viewport gate: the hero cliché, not a footer terminal. + const pageTop = rect.top + (window.scrollY || 0); + if (pageTop > CURSOR_FIRST_VIEWPORT_PX) return []; + + // Cursor shape: a lone block/underscore glyph, or an empty solid + // rectangle sized like a text caret (block or underscore form). + const text = (el.textContent || '').trim(); + const glyphCursor = text.length === 1 && CURSOR_GLYPH_RE.test(text); + let blockCursor = false; + if (!glyphCursor) { + if (text.length > 0 || el.childElementCount > 0) return []; + const bg = parseAnyColor(style.backgroundColor || ''); + const filled = bg && (bg.a ?? 1) > 0.2; + const hasBorderFill = ['Left', 'Right', 'Bottom'].some( + side => (parseFloat(style[`border${side}Width`]) || 0) >= 1, + ); + if (!filled && !hasBorderFill) return []; + const vertical = rect.width >= 1 && rect.width <= 24 && rect.height >= 6 && rect.height <= 48 && rect.height >= rect.width; + const underscore = rect.height >= 1 && rect.height <= 6 && rect.width >= 4 && rect.width <= 24; + if (!vertical && !underscore) return []; + // Round dots are the pulsing-dot rule's territory. + const radiusPx = parseFloat(style.borderRadius) || 0; + if (radiusPx >= 0.4 * Math.min(rect.width, rect.height)) return []; + blockCursor = true; + } + if (!glyphCursor && !blockCursor) return []; + + 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`, + }]; +} + // --- cli/engine/browser/injected/index.mjs --- const IS_BROWSER = typeof window !== 'undefined'; @@ -6203,6 +6476,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 })), ...checkElementDesignSystemDOM(el, designSystem, designSeen), ].filter(f => _ruleOk(f.type)); @@ -6263,6 +6537,12 @@ if (IS_BROWSER) { addBrowserFindings(groupMap, el, [{ type: f.type, detail: f.detail || f.snippet }]); } + // Heading rhythm (browser-only: needs real layout for the gap math) + const headingRhythmFindings = checkHeadingRhythmDOM().filter(f => _ruleOk(f.type)); + for (const f of headingRhythmFindings) { + addBrowserFindings(groupMap, f.el || document.body, [{ type: f.type, detail: f.detail }]); + } + // Page-level quality checks (headings, etc.) const qualityFindings = checkPageQualityDOM().filter(f => _ruleOk(f.type)); if (qualityFindings.length > 0) { diff --git a/cli/engine/registry/antipatterns.mjs b/cli/engine/registry/antipatterns.mjs index 98e6787dd..f3e71b89c 100644 --- a/cli/engine/registry/antipatterns.mjs +++ b/cli/engine/registry/antipatterns.mjs @@ -113,6 +113,15 @@ const ANTIPATTERNS = [ skillSection: 'Motion', skillGuideline: 'decorative pulsing status dot', }, + { + id: 'blinking-cursor', + category: 'slop', + severity: 'advisory', + name: 'Decorative blinking cursor', + description: + '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: 'dark-glow', category: 'slop', @@ -330,6 +339,15 @@ const ANTIPATTERNS = [ description: 'Heading levels should not skip (e.g. h1 then h3 with no h2). Screen readers use heading hierarchy for navigation. Skipping levels breaks the document outline.', }, + { + id: 'heading-rhythm', + category: 'quality', + scopes: ['layout', 'type'], + name: 'Heading crowded against the previous block', + description: + 'A heading binds to the content it introduces, so the rendered space above it should exceed the space below it. When headings across a page sit as close or closer to the block above than to their own content, every section reads as if it captions the previous one. Open up the space above each heading.', + skillSection: 'Layout & Space', + }, { id: 'justified-text', category: 'quality', diff --git a/cli/engine/rules/checks.mjs b/cli/engine/rules/checks.mjs index 599f4c231..d9f2784a6 100644 --- a/cli/engine/rules/checks.mjs +++ b/cli/engine/rules/checks.mjs @@ -3280,6 +3280,157 @@ function checkLayout() { return findings; } +// Heading rhythm (browser-only): a heading binds to the content it +// introduces, so its rendered space above must exceed its space below. +// Margins alone can't be trusted (collapsing, flex rows, section padding), +// so this measures actual getBoundingClientRect gaps between the heading +// and the nearest content genuinely above / below it. Fires only when two +// or more headings violate the principle — a single occurrence is noise. +function checkHeadingRhythmDOM() { + const MIN_VIOLATIONS = 2; + const CARD_EXEMPT_HEIGHT = 200; + const MAX_BELOW_PX = 160; // beyond this the heading isn't binding to nearby content at all + const MIN_DEFICIT_PX = 12; + + function isVisibleFlow(el) { + const style = getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden') return false; + if (parseFloat(style.opacity || '1') <= 0.05) return false; + if (style.position === 'absolute' || style.position === 'fixed' || style.position === 'sticky') return false; + const rect = el.getBoundingClientRect(); + return rect.width >= 1 && rect.height >= 1; + } + + // Edges only count when they share the heading's column — grid layouts + // put content beside a heading, and a far-away element in another column + // says nothing about the heading's vertical rhythm. + function overlapsX(sr, rect) { + return Math.min(sr.right, rect.right) - Math.max(sr.left, rect.left) >= 8; + } + + // Does this container draw its own top boundary (background, top border, + // shadow)? Crossing out of such a container means the container edge is + // the separator above the heading, not raw whitespace — exempt. + function hasOwnTopBoundary(el) { + const style = getComputedStyle(el); + const bg = parseAnyColor(style.backgroundColor || ''); + if (bg && (bg.a ?? 1) > 0.05) return true; + if ((parseFloat(style.borderTopWidth) || 0) > 0) return true; + if (style.boxShadow && style.boxShadow !== 'none') return true; + return false; + } + + // Eyebrows, kickers, and index labels sitting directly on top of a + // heading belong to the heading's own cluster — space above is measured + // from the top of the cluster, not from the label to the heading. + function clusterTop(h, rect) { + const headingFontSize = parseFloat(getComputedStyle(h).fontSize) || 16; + let topEl = h; + let top = rect.top; + for (let i = 0; i < 3; i++) { + const sib = topEl.previousElementSibling; + if (!sib || !isVisibleFlow(sib)) break; + const sr = sib.getBoundingClientRect(); + if (!overlapsX(sr, rect)) break; + const gap = top - sr.bottom; + if (gap < 0 || gap >= 28 || sr.height > 60) break; + const text = (sib.textContent || '').trim(); + const sibFontSize = parseFloat(getComputedStyle(sib).fontSize) || 16; + const labelLike = sibFontSize < headingFontSize * 0.75 || text.length <= 40; + if (!labelLike || text.length > 80) break; + topEl = sib; + top = sr.top; + } + return { topEl, top }; + } + + // Nearest content edge strictly above the heading cluster. Walks + // previous siblings, then out through ancestors. Skips elements that + // vertically overlap (flex-row companions, sticky rails) or sit in + // another column. Returns null when nothing qualifies — first content + // on the page, or the top of a visually bounded container. + function edgeAbove(startEl, top, rect) { + let node = startEl; + while (node && node !== document.body) { + let sib = node.previousElementSibling; + while (sib) { + if (isVisibleFlow(sib)) { + const sr = sib.getBoundingClientRect(); + if (sr.bottom <= top + 2 && overlapsX(sr, rect)) return sr.bottom; + } + sib = sib.previousElementSibling; + } + const parent = node.parentElement; + if (!parent || parent === document.body) return null; + // Leaving a container upward: if it draws its own top edge, that + // edge separates the heading from whatever sits above. + if (hasOwnTopBoundary(parent)) return null; + node = parent; + } + return null; + } + + // Nearest content edge strictly below the heading — the block the + // heading introduces. Crosses wrappers freely (headings often share a + // row wrapper with an eyebrow or index label). + function edgeBelow(h, rect) { + let node = h; + while (node && node !== document.body) { + let sib = node.nextElementSibling; + while (sib) { + if (isVisibleFlow(sib)) { + const sr = sib.getBoundingClientRect(); + if (sr.top >= rect.bottom - 2 && overlapsX(sr, rect)) return sr.top; + } + sib = sib.nextElementSibling; + } + node = node.parentElement; + } + return null; + } + + function insideSmallCard(h) { + let cur = h.parentElement; + while (cur && cur !== document.body) { + if (isCardLikeDOM(cur)) { + const cr = cur.getBoundingClientRect(); + if (cr.height < CARD_EXEMPT_HEIGHT) return true; + } + cur = cur.parentElement; + } + return false; + } + + const candidates = []; + for (const h of document.querySelectorAll('h2, h3, h4')) { + if (!isVisibleFlow(h)) continue; + const text = (h.textContent || '').trim().replace(/\s+/g, ' '); + if (text.length < 3) continue; + const rect = h.getBoundingClientRect(); + const belowTop = edgeBelow(h, rect); + if (belowTop == null) continue; // heading introduces nothing measurable + const { topEl, top } = clusterTop(h, rect); + const aboveBottom = edgeAbove(topEl, top, rect); + if (aboveBottom == null) continue; // first content, or bounded container + if (insideSmallCard(h)) continue; + const above = Math.max(0, top - aboveBottom); + const below = Math.max(0, belowTop - rect.bottom); + if (below < 6 || below > MAX_BELOW_PX) continue; + // Violation: the space above clearly fails to exceed the space below. + // Near-equal gaps are ambiguous rather than inverted, so they pass. + if (above < below * 0.75 && below - above >= MIN_DEFICIT_PX) { + candidates.push({ el: h, tag: h.tagName.toLowerCase(), text: text.slice(0, 60), above, below }); + } + } + + if (candidates.length < MIN_VIOLATIONS) return []; + return candidates.map(c => ({ + type: 'heading-rhythm', + detail: `${c.tag} "${c.text}" has ${Math.round(c.above)}px above vs ${Math.round(c.below)}px below — it reads as bound to the block above (${candidates.length} headings on page)`, + el: c.el, + })); +} + // Node page-level checks — take document/window as parameters function checkPageTypography(doc, win) { @@ -3992,6 +4143,110 @@ function checkElementTextOverflowDOM(el) { return []; } +// --------------------------------------------------------------------------- +// Blinking cursor (browser-only) +// --------------------------------------------------------------------------- + +// Block / underscore glyphs commonly used as a fake text cursor. +const CURSOR_GLYPH_RE = /^[_|▀-▟■▮❙❚|]$/; + +// How far down the page still counts as the first-viewport / hero region. +// Hero compositions regularly run past a literal viewport height, so the +// gate is a landing-region budget, not an exact fold line. +const CURSOR_FIRST_VIEWPORT_PX = 1200; + +// Do the named @keyframes only toggle visibility (opacity dropping to ~0 or +// visibility:hidden), i.e. a blink rather than a fade/move/spin? Walks the +// live CSSOM; cross-origin sheets are skipped. +function keyframesToggleVisibilityDOM(name) { + if (!name) return false; + for (const sheet of document.styleSheets) { + let rules; + try { rules = sheet.cssRules || sheet.rules; } catch { continue; } + if (!rules) continue; + const stack = [...rules]; + while (stack.length) { + const rule = stack.shift(); + if (rule.cssRules && rule.type !== 7) { stack.push(...rule.cssRules); continue; } + if (rule.type !== 7 || rule.name !== name) continue; // 7 = KEYFRAMES_RULE + let togglesOut = false; + for (const frame of rule.cssRules || []) { + const fs = frame.style; + if (!fs) continue; + for (let i = 0; i < fs.length; i++) { + const prop = fs[i]; + if (prop === 'opacity') { + if ((parseFloat(fs.getPropertyValue('opacity')) || 0) <= 0.15) togglesOut = true; + } else if (prop === 'visibility') { + if (/hidden/i.test(fs.getPropertyValue('visibility'))) togglesOut = true; + } else if (prop !== 'animation-timing-function') { + return false; // keyframes animate something else — not a blink + } + } + } + return togglesOut; + } + } + return false; +} + +// Decorative blinking cursor: a small block / underscore element bound to an +// infinite blink animation, sitting in the first-viewport region of a page. +// Real editable surfaces (inputs, textareas, contenteditable, role=textbox) +// draw their own caret and are exempt. Round pulsing dots stay with the +// pulsing-dot rule. +function checkElementBlinkingCursorDOM(el) { + const tag = el.tagName.toLowerCase(); + if (['input', 'textarea', 'select', 'img', 'svg', 'script', 'style'].includes(tag)) return []; + const style = getComputedStyle(el); + + const iterations = (style.animationIterationCount || '').split(',').map(s => s.trim()); + if (!iterations.includes('infinite')) return []; + const names = (style.animationName || '').split(',').map(s => s.trim()).filter(n => n && n !== 'none'); + if (names.length === 0) return []; + const blinkName = names.find(n => /blink|caret|cursor/i.test(n)) + || names.find(n => keyframesToggleVisibilityDOM(n)); + if (!blinkName) return []; + + // Real caret contexts are exempt. + if (el.isContentEditable || el.closest('[contenteditable=""], [contenteditable="true"], [role="textbox"]')) return []; + + const rect = el.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return []; + + // First-viewport gate: the hero cliché, not a footer terminal. + const pageTop = rect.top + (window.scrollY || 0); + if (pageTop > CURSOR_FIRST_VIEWPORT_PX) return []; + + // Cursor shape: a lone block/underscore glyph, or an empty solid + // rectangle sized like a text caret (block or underscore form). + const text = (el.textContent || '').trim(); + const glyphCursor = text.length === 1 && CURSOR_GLYPH_RE.test(text); + let blockCursor = false; + if (!glyphCursor) { + if (text.length > 0 || el.childElementCount > 0) return []; + const bg = parseAnyColor(style.backgroundColor || ''); + const filled = bg && (bg.a ?? 1) > 0.2; + const hasBorderFill = ['Left', 'Right', 'Bottom'].some( + side => (parseFloat(style[`border${side}Width`]) || 0) >= 1, + ); + if (!filled && !hasBorderFill) return []; + const vertical = rect.width >= 1 && rect.width <= 24 && rect.height >= 6 && rect.height <= 48 && rect.height >= rect.width; + const underscore = rect.height >= 1 && rect.height <= 6 && rect.width >= 4 && rect.width <= 24; + if (!vertical && !underscore) return []; + // Round dots are the pulsing-dot rule's territory. + const radiusPx = parseFloat(style.borderRadius) || 0; + if (radiusPx >= 0.4 * Math.min(rect.width, rect.height)) return []; + blockCursor = true; + } + if (!glyphCursor && !blockCursor) return []; + + 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`, + }]; +} + export { checkBorders, isEmojiOnlyText, @@ -4087,4 +4342,6 @@ export { checkElementClippedOverflowDOM, isScreenReaderOnlyTextStyle, checkElementTextOverflowDOM, + checkHeadingRhythmDOM, + checkElementBlinkingCursorDOM, }; diff --git a/site/pages/index.astro b/site/pages/index.astro index 3c8fca572..c671d0b74 100644 --- a/site/pages/index.astro +++ b/site/pages/index.astro @@ -521,7 +521,7 @@ import '../styles/testimonials.css';
06

Block slop before it ships.

-

A detector you can wire into PR checks. 51 deterministic rules, no LLM, exit codes the build can read.

+

A detector you can wire into PR checks. 53 deterministic rules, no LLM, exit codes the build can read.

@@ -799,7 +799,7 @@ import '../styles/testimonials.css';
  • CLI for CI - npx impeccable detect src/ in a PR check. 51 deterministic rules. JSON output, exit codes for build gates. + npx impeccable detect src/ in a PR check. 53 deterministic rules. JSON output, exit codes for build gates. View on npm →
  • diff --git a/tests/detect-antipatterns-browser.test.mjs b/tests/detect-antipatterns-browser.test.mjs index 31eb69a26..6e2c35cc6 100644 --- a/tests/detect-antipatterns-browser.test.mjs +++ b/tests/detect-antipatterns-browser.test.mjs @@ -203,6 +203,30 @@ describe('detectUrl — browser-only fixtures', () => { ); }); + it('heading-rhythm: crowded headings flag, first/eyebrow/card/band/near-equal shapes pass', async () => { + const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/heading-rhythm.html`, { visualContrast: false }); + const hits = f.filter(r => r.antipattern === 'heading-rhythm'); + const snippets = hits.map(r => r.snippet || '').join('\n'); + + for (const label of ['Flag Crowded One', 'Flag Crowded Two', 'Flag Crowded Three']) { + assert.match(snippets, new RegExp(`"${label}"`), `expected "${label}" to be flagged`); + } + assert.doesNotMatch(snippets, /Pass /, `no pass-case heading should be flagged, got: ${snippets}`); + assert.equal(hits.length, 3, `expected 3 heading-rhythm findings, got ${hits.length}: ${snippets}`); + }); + + it('blinking-cursor: hero cursors flag, editable/deep/round/spinner shapes pass', async () => { + const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/blinking-cursor.html`, { visualContrast: false }); + const hits = f.filter(r => r.antipattern === 'blinking-cursor'); + const snippets = hits.map(r => r.snippet || '').join('\n'); + + for (const cls of ['flag-block-cursor', 'flag-glyph-cursor', 'flag-underscore-cursor']) { + assert.match(snippets, new RegExp(cls), `expected .${cls} to be flagged`); + } + assert.doesNotMatch(snippets, /pass-/, `no pass-case cursor should be flagged, got: ${snippets}`); + assert.equal(hits.length, 3, `expected 3 blinking-cursor findings, got ${hits.length}: ${snippets}`); + }); + it('typography side-by-side: element-level flag cases get regular overlays', async () => { const puppeteer = await import('puppeteer'); const browser = await puppeteer.default.launch({ diff --git a/tests/fixtures/antipatterns/blinking-cursor.html b/tests/fixtures/antipatterns/blinking-cursor.html new file mode 100644 index 000000000..bb21085f4 --- /dev/null +++ b/tests/fixtures/antipatterns/blinking-cursor.html @@ -0,0 +1,106 @@ + + + + +Blinking cursor fixture + + + + +
    +

    Terminal-flavored landing hero

    +
    +

    user@host:~$ run demo

    +

    user@host:~$ tail -f logs

    +

    user@host:~$ next up

    +
    + +

    Status + loading + and a LIVE badge word + plus a big panel

    + +
    +

    editable prompt

    +
    + +
    +

    textbox prompt

    +
    +
    + +
    + +
    +

    deep footer terminal

    +
    + + + diff --git a/tests/fixtures/antipatterns/heading-rhythm.html b/tests/fixtures/antipatterns/heading-rhythm.html new file mode 100644 index 000000000..b09d13bce --- /dev/null +++ b/tests/fixtures/antipatterns/heading-rhythm.html @@ -0,0 +1,85 @@ + + + + +Heading rhythm fixture + + + + +

    Pass First Heading

    +

    Opening paragraph after the first heading on the page. The first heading has + nothing above it, so it is exempt from the rhythm comparison entirely.

    + +

    A closing paragraph of the previous block of content, + sitting directly above the crowded heading with no gap at all between them.

    +

    Flag Crowded One

    +

    The content this heading introduces sits forty pixels away while the previous + block touches the heading, so the heading reads as a caption for the text above.

    + +

    Another closing paragraph of a previous content block, again flush against + the heading that follows it in the document flow.

    +

    Flag Crowded Two

    +

    Second crowded case. Two or more headings must violate the principle before + the rule fires, because a single occurrence is treated as noise.

    + +

    A third closing paragraph that the next heading will visually attach to, + which is exactly the inversion this rule measures.

    +

    Flag Crowded Three

    +

    Third crowded case, this time an h3, to confirm the rule reads h2 through h4.

    + +

    Pass Generous Above

    +

    Sixty-four pixels above and sixteen below. The heading clearly binds to this + paragraph, which is the healthy shape.

    + +

    Pass Near Equal

    +

    Twenty pixels above and twenty-four below. Ambiguous rather than inverted, + so the rule leaves it alone.

    + +

    Chapter Four

    +

    Pass Eyebrow Cluster

    +

    The tiny label above belongs to the heading cluster. Space is measured from + the top of the cluster, where seventy-two pixels separate it from this text.

    + +
    +

    Pass Card Heading

    +

    A heading inside a small card. The card boundary provides the separation, + so the rule exempts headings inside panels shorter than two hundred pixels.

    +
    + +
    +

    Pass Band Heading

    +

    This heading is the first content inside a section that draws its own + background, so the band edge is the separator above it.

    +
    + + +