mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
detector: add heading-rhythm and blinking-cursor rules (51 -> 53)
heading-rhythm (quality): a heading binds to the content it introduces, so its rendered space above must exceed its space below. Browser-only: measures real getBoundingClientRect gaps (margin collapsing, flex rows, and section padding make authored margins untrustworthy), merges eyebrow labels into the heading cluster, requires same-column edges, and exempts first-in-container headings, bounded bands, and small cards. Fires only when 2+ headings on a page invert the rhythm. blinking-cursor (slop, advisory): a decorative blinking caret (solid block, underscore bar, or block glyph) bound to an infinite blink animation in the landing region of a page. Real editable surfaces (contenteditable, role=textbox, inputs) are exempt; round pulsing dots stay with the pulsing-dot rule. Verified against eval corpus repros: heading-rhythm fires on the val-a18 observability sample Paul flagged (6 headings, 0px above vs 40px below) and blinking-cursor on the val-a19 hero terminal cursor; 10 other samples across both runs stay clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
1866afe789
commit
cc8906ecaa
@@ -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
|
||||
|
||||
|
||||
+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 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
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -521,7 +521,7 @@ import '../styles/testimonials.css';
|
||||
<article class="ks-bento-tile ks-bento-tile--span-6" id="why-ci">
|
||||
<span class="ks-bento-num" data-color="patina">06</span>
|
||||
<h3 class="why-panel-title">Block slop before it ships.</h3>
|
||||
<p class="why-panel-body">A detector you can wire into PR checks. 51 deterministic rules, no LLM, exit codes the build can read.</p>
|
||||
<p class="why-panel-body">A detector you can wire into PR checks. 53 deterministic rules, no LLM, exit codes the build can read.</p>
|
||||
<div class="why-visual why-visual--ci">
|
||||
<div class="why-ci-window">
|
||||
<div class="why-ci-header">
|
||||
@@ -799,7 +799,7 @@ import '../styles/testimonials.css';
|
||||
</li>
|
||||
<li>
|
||||
<strong>CLI for CI</strong>
|
||||
<span><code>npx impeccable detect src/</code> in a PR check. 51 deterministic rules. JSON output, exit codes for build gates.</span>
|
||||
<span><code>npx impeccable detect src/</code> in a PR check. 53 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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Blinking cursor fixture</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: monospace, monospace; font-size: 16px; }
|
||||
.hero { padding: 40px; }
|
||||
.terminal { background: #111; color: #eee; padding: 24px; width: 600px; }
|
||||
|
||||
@keyframes blink-anim { 50% { opacity: 0; } }
|
||||
@keyframes flicker { 0%, 49% { opacity: 1; } 50%, 100% { opacity: 0; } }
|
||||
@keyframes caret-blink { 0%, 50% { opacity: 1; } 51%, 100% { opacity: 0; } }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* FLAG: solid block cursor in the hero terminal */
|
||||
.flag-block-cursor {
|
||||
display: inline-block; width: 10px; height: 18px;
|
||||
background: #33d17a; vertical-align: text-bottom;
|
||||
animation: blink-anim 1.1s steps(1) infinite;
|
||||
}
|
||||
|
||||
/* FLAG: glyph cursor, animation name says nothing but keyframes toggle opacity */
|
||||
.flag-glyph-cursor { animation: flicker 1s steps(2) infinite; }
|
||||
|
||||
/* FLAG: underscore bar cursor */
|
||||
.flag-underscore-cursor {
|
||||
display: inline-block; width: 12px; height: 3px;
|
||||
background: #eee; vertical-align: baseline;
|
||||
animation: caret-blink 0.9s steps(1) infinite;
|
||||
}
|
||||
|
||||
/* PASS: same block cursor shape, but deep below the landing region */
|
||||
.pass-below-fold-cursor {
|
||||
display: inline-block; width: 10px; height: 18px;
|
||||
background: #33d17a;
|
||||
animation: blink-anim 1.1s steps(1) infinite;
|
||||
}
|
||||
|
||||
/* PASS: cursor inside real editable surfaces */
|
||||
.pass-editable-cursor, .pass-textbox-cursor {
|
||||
display: inline-block; width: 10px; height: 18px;
|
||||
background: #444;
|
||||
animation: blink-anim 1s steps(1) infinite;
|
||||
}
|
||||
|
||||
/* PASS: round pulsing dot stays with the pulsing-dot rule */
|
||||
.pass-round-dot {
|
||||
display: inline-block; width: 8px; height: 8px;
|
||||
background: #33d17a; border-radius: 50%;
|
||||
animation: blink-anim 2s steps(1) infinite;
|
||||
}
|
||||
|
||||
/* PASS: spinner animates transform, not visibility */
|
||||
.pass-spinner {
|
||||
display: inline-block; width: 16px; height: 16px;
|
||||
border: 2px solid #999; border-top-color: #333; border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* PASS: a blinking word is not a cursor shape */
|
||||
.pass-blinking-word { animation: flicker 1s steps(2) infinite; }
|
||||
|
||||
/* PASS: large blinking panel fails the caret-size gates */
|
||||
.pass-blinking-panel {
|
||||
display: inline-block; width: 200px; height: 90px;
|
||||
background: #222;
|
||||
animation: blink-anim 2s steps(1) infinite;
|
||||
}
|
||||
|
||||
.spacer { height: 1400px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="hero">
|
||||
<h1>Terminal-flavored landing hero</h1>
|
||||
<div class="terminal">
|
||||
<p>user@host:~$ run demo <span class="flag-block-cursor" aria-hidden="true"></span></p>
|
||||
<p>user@host:~$ tail -f logs <span class="flag-glyph-cursor" aria-hidden="true">▌</span></p>
|
||||
<p>user@host:~$ next up <span class="flag-underscore-cursor" aria-hidden="true"></span></p>
|
||||
</div>
|
||||
|
||||
<p style="margin-top: 24px;">Status <span class="pass-round-dot" aria-hidden="true"></span>
|
||||
loading <span class="pass-spinner" aria-hidden="true"></span>
|
||||
and a <span class="pass-blinking-word">LIVE</span> badge word
|
||||
plus a big panel <span class="pass-blinking-panel" aria-hidden="true"></span></p>
|
||||
|
||||
<div contenteditable="true" class="terminal" style="margin-top: 24px;">
|
||||
<p>editable prompt <span class="pass-editable-cursor" aria-hidden="true"></span></p>
|
||||
</div>
|
||||
|
||||
<div role="textbox" class="terminal" style="margin-top: 24px;">
|
||||
<p>textbox prompt <span class="pass-textbox-cursor" aria-hidden="true"></span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="spacer"></div>
|
||||
|
||||
<div class="terminal">
|
||||
<p>deep footer terminal <span class="pass-below-fold-cursor" aria-hidden="true"></span></p>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Heading rhythm fixture</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: Georgia, serif; font-size: 16px; line-height: 1.5; width: 720px; }
|
||||
h2 { font-size: 28px; line-height: 1.2; }
|
||||
h3 { font-size: 20px; line-height: 1.2; }
|
||||
p { max-width: 60ch; }
|
||||
|
||||
/* FLAG: zero space above the heading, clear space below it */
|
||||
.flag-crowded { margin: 0 0 40px; }
|
||||
.flag-crowded + p { margin: 0 0 24px; }
|
||||
|
||||
/* PASS: generous space above, tight below */
|
||||
.pass-generous { margin: 64px 0 16px; }
|
||||
|
||||
/* PASS: near-equal spacing is ambiguous, not inverted */
|
||||
.pass-near-equal { margin: 20px 0 24px; }
|
||||
|
||||
/* PASS: eyebrow label bound to the heading; real space sits above the cluster */
|
||||
.pass-eyebrow-label { font-size: 12px; letter-spacing: 0.1em; text-transform: uppercase; margin: 72px 0 8px; }
|
||||
.pass-eyebrow-heading { margin: 0 0 24px; }
|
||||
|
||||
/* PASS: small card, its boundary provides the separation */
|
||||
.pass-card { box-shadow: 0 1px 4px rgba(0,0,0,0.2); background: #f4f4f4; padding: 16px; margin: 48px 0; width: 320px; }
|
||||
.pass-card h3 { margin: 0 0 20px; }
|
||||
|
||||
/* PASS: heading first inside a banded section; the band edge separates */
|
||||
.pass-band { background: #ececec; padding: 8px 0 32px; margin-top: 8px; }
|
||||
.pass-band h2 { margin: 0 0 24px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h2 class="pass-first">Pass First Heading</h2>
|
||||
<p>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.</p>
|
||||
|
||||
<p style="margin-top: 24px;">A closing paragraph of the previous block of content,
|
||||
sitting directly above the crowded heading with no gap at all between them.</p>
|
||||
<h2 class="flag-crowded">Flag Crowded One</h2>
|
||||
<p>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.</p>
|
||||
|
||||
<p>Another closing paragraph of a previous content block, again flush against
|
||||
the heading that follows it in the document flow.</p>
|
||||
<h2 class="flag-crowded">Flag Crowded Two</h2>
|
||||
<p>Second crowded case. Two or more headings must violate the principle before
|
||||
the rule fires, because a single occurrence is treated as noise.</p>
|
||||
|
||||
<p>A third closing paragraph that the next heading will visually attach to,
|
||||
which is exactly the inversion this rule measures.</p>
|
||||
<h3 class="flag-crowded">Flag Crowded Three</h3>
|
||||
<p>Third crowded case, this time an h3, to confirm the rule reads h2 through h4.</p>
|
||||
|
||||
<h2 class="pass-generous">Pass Generous Above</h2>
|
||||
<p>Sixty-four pixels above and sixteen below. The heading clearly binds to this
|
||||
paragraph, which is the healthy shape.</p>
|
||||
|
||||
<h2 class="pass-near-equal">Pass Near Equal</h2>
|
||||
<p>Twenty pixels above and twenty-four below. Ambiguous rather than inverted,
|
||||
so the rule leaves it alone.</p>
|
||||
|
||||
<p class="pass-eyebrow-label">Chapter Four</p>
|
||||
<h2 class="pass-eyebrow-heading">Pass Eyebrow Cluster</h2>
|
||||
<p>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.</p>
|
||||
|
||||
<div class="pass-card">
|
||||
<h3>Pass Card Heading</h3>
|
||||
<p>A heading inside a small card. The card boundary provides the separation,
|
||||
so the rule exempts headings inside panels shorter than two hundred pixels.</p>
|
||||
</div>
|
||||
|
||||
<div class="pass-band">
|
||||
<h2>Pass Band Heading</h2>
|
||||
<p>This heading is the first content inside a section that draws its own
|
||||
background, so the band edge is the separator above it.</p>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user