mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-20 01:56:37 +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
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user