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:
Paul Bakaus
2026-07-14 09:35:03 -07:00
co-authored by Claude Fable 5
parent 1866afe789
commit cc8906ecaa
10 changed files with 783 additions and 6 deletions
+257
View File
@@ -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,
};