Ban kickers outright: one eyebrow above a heading is one too many

The detector's repeated-section-kickers rule waited for three tracked
labels before calling the pattern; generated pages earn the finding on
the first one. Retire that id and replace it with kicker-above-heading,
which flags any tracked-caps or small-caps label block sitting directly
above an h1-h4 or heading-role element, at full warning severity.

The candidate gate absorbs the false-positive shapes the repetition
count used to paper over: editorial category-and-date meta lines,
breadcrumbs with separators, legal and chapter numbering, application
panel context labels, nav landmarks before page titles, and stat
callouts with the label below the number. Hero-scale h1 eyebrows stay
with hero-eyebrow-chip so one element gets one finding, and the static
cascade now carries font-variant so small-caps kickers register.

The craft floor entry moves from caution to ban in the same breath.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-07-27 18:55:52 -07:00
co-authored by Claude Fable 5
parent 806a48aef2
commit 33a1c5fcae
10 changed files with 552 additions and 368 deletions
+1 -1
View File
@@ -1529,7 +1529,7 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, typoFindings);
}
const sectionKickerFindings = checkRepeatedSectionKickersDOM()
const sectionKickerFindings = checkKickerAboveHeadingDOM()
.map(f => ({ type: f.id, detail: f.snippet }))
.filter(f => _ruleOk(f.type));
if (sectionKickerFindings.length > 0) {
+79 -37
View File
@@ -309,15 +309,14 @@ const ANTIPATTERNS = [
skillGuideline: 'tiny uppercase tracked label above the hero headline',
},
{
id: 'repeated-section-kickers',
id: 'kicker-above-heading',
category: 'slop',
scopes: ['type'],
severity: 'advisory',
name: 'Repeated section kicker labels',
name: 'Kicker / eyebrow label above heading',
description:
'Repeating tiny uppercase tracked labels above section headings turns a brand page into AI editorial scaffolding. Replace them with stronger structure, artifacts, imagery, or a deliberate brand system.',
'A tiny tracked uppercase or small-caps label sitting as its own block directly above a heading is banned outright, repeated or not. Generated kickers never earn their place: the heading carries its own weight. Delete the label and let the heading speak; if the words matter, work them into the heading or the body.',
skillSection: 'Typography',
skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding',
skillGuideline: 'kicker or eyebrow labels above headings',
},
{
id: 'numbered-section-labels',
@@ -1258,12 +1257,15 @@ function checkHeroEyebrow(opts) {
}];
}
function checkRepeatedSectionKickers(opts) {
const { candidates, minCount = 3 } = opts;
if (!Array.isArray(candidates) || candidates.length < minCount) return [];
// Outright ban: one kicker is one too many, so every collected candidate is
// a finding. The judgment lives in the candidate gate (isKickerCandidate) and
// the collector's context skips, not in a repetition count.
function checkKickerAboveHeading(opts) {
const { candidates } = opts;
if (!Array.isArray(candidates)) return [];
return candidates.map(candidate => ({
id: 'repeated-section-kickers',
snippet: `repeated section kicker "${candidate.kickerText}" before ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`,
id: 'kicker-above-heading',
snippet: `kicker "${candidate.kickerText}" above ${candidate.headingTag} "${candidate.headingText}"`,
}));
}
@@ -3190,7 +3192,7 @@ function parseColorResolved(str, customPropMap) {
return parseAnyColor(resolved);
}
const REPEATED_KICKER_SKIP_SELECTOR = [
const KICKER_SKIP_SELECTOR = [
'nav',
'form',
'table',
@@ -3209,7 +3211,7 @@ const REPEATED_KICKER_SKIP_SELECTOR = [
'[data-impeccable-allow-kickers]',
].join(',');
const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [
const KICKER_CARD_CONTEXT_SELECTOR = [
'article',
'button',
'a',
@@ -3227,23 +3229,32 @@ function cleanInlineText(el) {
.trim();
}
function isRepeatedKickerCardContext(heading, kicker) {
const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR);
function isKickerCardContext(heading, kicker) {
const item = heading.closest?.(KICKER_CARD_CONTEXT_SELECTOR);
return Boolean(item && (!item.contains || item.contains(kicker)));
}
function isRepeatedKickerCandidate(opts) {
// Meta lines above headlines join category and date (or path crumbs) with
// separator glyphs, or carry a year. A kicker is one short phrase; metadata
// keeps its markers.
const KICKER_META_TEXT_RE = /[·•|]|\s[\/›»>]\s|\b(19|20)\d{2}\b/;
// Legal and document numbering: "Section 4.2", "Article IX", "§ 12.3",
// dotted decimal outlines. The label identifies the clause, so it stays.
const KICKER_DOC_NUMBERING_RE = /^(§|\d+(\.\d+)+\b|(section|article|clause|appendix|exhibit|schedule|chapter|part|rule|title)\s+([\divxlc]+\b|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)\b)/i;
function isKickerCandidate(opts) {
const {
headingTag,
headingLevel,
headingText,
headingFontSize,
kickerTag,
kickerText,
kickerTextTransform,
kickerFontVariant,
kickerFontSize,
kickerLetterSpacing,
} = opts;
if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
if (!headingLevel || headingLevel > 4) return false;
if (!headingText || headingText.length < 3) return false;
if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false;
if (!(headingFontSize >= 20)) return false;
@@ -3251,9 +3262,13 @@ function isRepeatedKickerCandidate(opts) {
if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;
if (!kickerText || kickerText.length < 2 || kickerText.length > 34) return false;
if (/^step\s*\d+/i.test(kickerText) || /^\d{1,2}$/.test(kickerText)) return false;
if (KICKER_META_TEXT_RE.test(kickerText)) return false;
if (KICKER_DOC_NUMBERING_RE.test(kickerText)) return false;
const isSmallCaps = /small-caps/.test(kickerFontVariant || '');
const isUppercased = kickerTextTransform === 'uppercase'
|| (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText));
|| (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText))
|| isSmallCaps;
if (!isUppercased) return false;
if (!(kickerFontSize > 0 && kickerFontSize <= 14)) return false;
const minTrackedSpacing = Math.max(1, kickerFontSize * 0.08);
@@ -3261,37 +3276,64 @@ function isRepeatedKickerCandidate(opts) {
return true;
}
function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpacing) {
// Resolve a heading level for the anchor element: 1-4 for h1-h4, aria-level
// (default 2) for role="heading" elements, 0 otherwise.
function kickerHeadingLevel(heading) {
const tag = heading.tagName.toLowerCase();
const byTag = /^h([1-6])$/.exec(tag);
if (byTag) return parseInt(byTag[1], 10);
const role = heading.getAttribute?.('role') || '';
if (role.toLowerCase() !== 'heading') return 0;
const ariaLevel = parseInt(heading.getAttribute?.('aria-level') || '', 10);
return Number.isFinite(ariaLevel) && ariaLevel >= 1 ? ariaLevel : 2;
}
function collectKickerCandidates(doc, getStyle, resolveLetterSpacing) {
const candidates = [];
for (const heading of doc.querySelectorAll('h2, h3, h4')) {
if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
for (const heading of doc.querySelectorAll('h1, h2, h3, h4, [role="heading"]')) {
const headingLevel = kickerHeadingLevel(heading);
if (!headingLevel || headingLevel > 4) continue;
if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
// Application contexts (tab panels, dialogs) use compact context labels
// above headings to describe state, not to decorate. Same carve-out the
// hero-eyebrow rule makes.
if (heading.closest?.('[role="tabpanel"], [role="dialog"], [role="application"], dialog')) continue;
const kicker = heading.previousElementSibling;
if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
if (isRepeatedKickerCardContext(heading, kicker)) continue;
if (!kicker || kicker.closest?.(KICKER_SKIP_SELECTOR)) continue;
if (isKickerCardContext(heading, kicker)) continue;
const headingStyle = getStyle(heading);
const kickerStyle = getStyle(kicker);
const headingTag = heading.tagName.toLowerCase();
const headingText = (heading.textContent || '').replace(/\s+/g, ' ').trim();
const kickerText = cleanInlineText(kicker) || (kicker.textContent || '').replace(/\s+/g, ' ').trim();
const headingFontSize = resolveLetterSpacing(headingStyle.fontSize || '', 16) || parseFloat(headingStyle.fontSize) || 0;
const kickerFontSize = resolveLetterSpacing(kickerStyle.fontSize || '', 16) || parseFloat(kickerStyle.fontSize) || 0;
const kickerLetterSpacing = resolveLetterSpacing(kickerStyle.letterSpacing || '', kickerFontSize);
if (!isRepeatedKickerCandidate({
headingTag: heading.tagName.toLowerCase(),
if (!isKickerCandidate({
headingLevel,
headingText,
headingFontSize,
kickerTag: kicker.tagName.toLowerCase(),
kickerText,
kickerTextTransform: kickerStyle.textTransform || '',
kickerFontVariant: `${kickerStyle.fontVariant || ''} ${kickerStyle.fontVariantCaps || ''}`,
kickerFontSize,
kickerLetterSpacing,
})) {
continue;
}
// A tracked-caps eyebrow above a hero-scale h1 belongs to
// hero-eyebrow-chip (which also covers the accent-bold and dash-prefix
// stylings there). Stand down so one element gets one finding.
if (headingTag === 'h1' && headingFontSize >= 48 && kickerLetterSpacing >= 1.6) {
continue;
}
candidates.push({
headingTag: heading.tagName.toLowerCase(),
headingTag,
headingText: headingText.replace(/^"|"$/g, '').slice(0, 60),
kickerText: kickerText.slice(0, 40),
});
@@ -3299,17 +3341,17 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac
return candidates;
}
function checkRepeatedSectionKickersDOM() {
const candidates = collectRepeatedSectionKickerCandidates(
function checkKickerAboveHeadingDOM() {
const candidates = collectKickerCandidates(
document,
(el) => getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
return checkRepeatedSectionKickers({ candidates });
return checkKickerAboveHeading({ candidates });
}
// ── Numbered section labels ─────────────────────────────────────────────────
// Sibling of the repeated-kicker rule: instead of a tracked uppercase word,
// Sibling of the kicker-above-heading rule: instead of a tracked uppercase word,
// the section scaffold is a tiny numeric index riding beside each section
// heading — bare and zero-padded, or an index joined to a short micro-label
// by a separator glyph. The kicker rule deliberately excludes bare 1-2 digit
@@ -3363,7 +3405,7 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
const candidates = [];
const seenLabels = new Set();
for (const heading of doc.querySelectorAll('h2, h3, h4')) {
if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
// The index sits either directly before the heading, or before the
// wrapper the heading leads (label | <div><h2>…</h2>…</div>).
let label = heading.previousElementSibling;
@@ -3373,9 +3415,9 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
if (firstChild === heading) label = parent.previousElementSibling;
}
if (!label || seenLabels.has(label)) continue;
if (label.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
if (label.closest?.(KICKER_SKIP_SELECTOR)) continue;
if (HEADING_TAGS.has(label.tagName.toLowerCase())) continue;
if (isRepeatedKickerCardContext(heading, label)) continue;
if (isKickerCardContext(heading, label)) continue;
const labelText = cleanInlineText(label) || (label.textContent || '').replace(/\s+/g, ' ').trim();
const parsed = parseNumberedLabelText(labelText);
@@ -4548,13 +4590,13 @@ function checkElementHeroEyebrow(el, style, tag, window, customPropMap) {
});
}
function checkRepeatedSectionKickersFromDoc(doc, win) {
const candidates = collectRepeatedSectionKickerCandidates(
function checkKickerAboveHeadingFromDoc(doc, win) {
const candidates = collectKickerCandidates(
doc,
(el) => win.getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
return checkRepeatedSectionKickers({ candidates });
return checkKickerAboveHeading({ candidates });
}
function checkElementMotion(tag, style) {
@@ -7712,7 +7754,7 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, typoFindings);
}
const sectionKickerFindings = checkRepeatedSectionKickersDOM()
const sectionKickerFindings = checkKickerAboveHeadingDOM()
.map(f => ({ type: f.id, detail: f.snippet }))
.filter(f => _ruleOk(f.type));
if (sectionKickerFindings.length > 0) {
@@ -223,7 +223,7 @@ function unwrapCssAtLayer(source) {
// ---------------------------------------------------------------------------
const STATIC_INHERITED_PROPS = new Set([
'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight',
'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight', 'fontVariant',
'lineHeight', 'letterSpacing', 'textTransform', 'textAlign', 'hyphens',
'webkitHyphens',
]);
@@ -252,6 +252,7 @@ const STATIC_DEFAULT_STYLE = {
fontFamily: '',
fontSize: '16px',
fontStyle: 'normal',
fontVariant: 'normal',
fontWeight: '400',
lineHeight: 'normal',
letterSpacing: 'normal',
@@ -27,11 +27,11 @@ import {
checkElementRadialSpotlight,
checkCreamPalette,
checkHtmlPatterns,
checkKickerAboveHeadingFromDoc,
checkNumberedSectionLabelsFromDoc,
checkPageLayout,
checkPageQualityFromDoc,
checkRepeatedContainerTextFromDoc,
checkRepeatedSectionKickersFromDoc,
resolveBackground,
resolveBorderRadiusPx,
} from '../../rules/checks.mjs';
@@ -202,7 +202,7 @@ async function detectHtml(filePath, options = {}) {
for (const f of runPageCheck('typography-rules', () => checkStaticPageTypography(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of runPageCheck('repeated-section-kickers', () => checkRepeatedSectionKickersFromDoc(document, window))) {
for (const f of runPageCheck('kicker-above-heading', () => checkKickerAboveHeadingFromDoc(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of runPageCheck('numbered-section-labels', () => checkNumberedSectionLabelsFromDoc(document, window))) {
+4 -5
View File
@@ -198,15 +198,14 @@ const ANTIPATTERNS = [
skillGuideline: 'tiny uppercase tracked label above the hero headline',
},
{
id: 'repeated-section-kickers',
id: 'kicker-above-heading',
category: 'slop',
scopes: ['type'],
severity: 'advisory',
name: 'Repeated section kicker labels',
name: 'Kicker / eyebrow label above heading',
description:
'Repeating tiny uppercase tracked labels above section headings turns a brand page into AI editorial scaffolding. Replace them with stronger structure, artifacts, imagery, or a deliberate brand system.',
'A tiny tracked uppercase or small-caps label sitting as its own block directly above a heading is banned outright, repeated or not. Generated kickers never earn their place: the heading carries its own weight. Delete the label and let the heading speak; if the words matter, work them into the heading or the body.',
skillSection: 'Typography',
skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding',
skillGuideline: 'kicker or eyebrow labels above headings',
},
{
id: 'numbered-section-labels',
+79 -36
View File
@@ -456,12 +456,15 @@ function checkHeroEyebrow(opts) {
}];
}
function checkRepeatedSectionKickers(opts) {
const { candidates, minCount = 3 } = opts;
if (!Array.isArray(candidates) || candidates.length < minCount) return [];
// Outright ban: one kicker is one too many, so every collected candidate is
// a finding. The judgment lives in the candidate gate (isKickerCandidate) and
// the collector's context skips, not in a repetition count.
function checkKickerAboveHeading(opts) {
const { candidates } = opts;
if (!Array.isArray(candidates)) return [];
return candidates.map(candidate => ({
id: 'repeated-section-kickers',
snippet: `repeated section kicker "${candidate.kickerText}" before ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`,
id: 'kicker-above-heading',
snippet: `kicker "${candidate.kickerText}" above ${candidate.headingTag} "${candidate.headingText}"`,
}));
}
@@ -2388,7 +2391,7 @@ function parseColorResolved(str, customPropMap) {
return parseAnyColor(resolved);
}
const REPEATED_KICKER_SKIP_SELECTOR = [
const KICKER_SKIP_SELECTOR = [
'nav',
'form',
'table',
@@ -2407,7 +2410,7 @@ const REPEATED_KICKER_SKIP_SELECTOR = [
'[data-impeccable-allow-kickers]',
].join(',');
const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [
const KICKER_CARD_CONTEXT_SELECTOR = [
'article',
'button',
'a',
@@ -2425,23 +2428,32 @@ function cleanInlineText(el) {
.trim();
}
function isRepeatedKickerCardContext(heading, kicker) {
const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR);
function isKickerCardContext(heading, kicker) {
const item = heading.closest?.(KICKER_CARD_CONTEXT_SELECTOR);
return Boolean(item && (!item.contains || item.contains(kicker)));
}
function isRepeatedKickerCandidate(opts) {
// Meta lines above headlines join category and date (or path crumbs) with
// separator glyphs, or carry a year. A kicker is one short phrase; metadata
// keeps its markers.
const KICKER_META_TEXT_RE = /[·•|]|\s[\/›»>]\s|\b(19|20)\d{2}\b/;
// Legal and document numbering: "Section 4.2", "Article IX", "§ 12.3",
// dotted decimal outlines. The label identifies the clause, so it stays.
const KICKER_DOC_NUMBERING_RE = /^(§|\d+(\.\d+)+\b|(section|article|clause|appendix|exhibit|schedule|chapter|part|rule|title)\s+([\divxlc]+\b|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)\b)/i;
function isKickerCandidate(opts) {
const {
headingTag,
headingLevel,
headingText,
headingFontSize,
kickerTag,
kickerText,
kickerTextTransform,
kickerFontVariant,
kickerFontSize,
kickerLetterSpacing,
} = opts;
if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
if (!headingLevel || headingLevel > 4) return false;
if (!headingText || headingText.length < 3) return false;
if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false;
if (!(headingFontSize >= 20)) return false;
@@ -2449,9 +2461,13 @@ function isRepeatedKickerCandidate(opts) {
if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;
if (!kickerText || kickerText.length < 2 || kickerText.length > 34) return false;
if (/^step\s*\d+/i.test(kickerText) || /^\d{1,2}$/.test(kickerText)) return false;
if (KICKER_META_TEXT_RE.test(kickerText)) return false;
if (KICKER_DOC_NUMBERING_RE.test(kickerText)) return false;
const isSmallCaps = /small-caps/.test(kickerFontVariant || '');
const isUppercased = kickerTextTransform === 'uppercase'
|| (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText));
|| (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText))
|| isSmallCaps;
if (!isUppercased) return false;
if (!(kickerFontSize > 0 && kickerFontSize <= 14)) return false;
const minTrackedSpacing = Math.max(1, kickerFontSize * 0.08);
@@ -2459,37 +2475,64 @@ function isRepeatedKickerCandidate(opts) {
return true;
}
function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpacing) {
// Resolve a heading level for the anchor element: 1-4 for h1-h4, aria-level
// (default 2) for role="heading" elements, 0 otherwise.
function kickerHeadingLevel(heading) {
const tag = heading.tagName.toLowerCase();
const byTag = /^h([1-6])$/.exec(tag);
if (byTag) return parseInt(byTag[1], 10);
const role = heading.getAttribute?.('role') || '';
if (role.toLowerCase() !== 'heading') return 0;
const ariaLevel = parseInt(heading.getAttribute?.('aria-level') || '', 10);
return Number.isFinite(ariaLevel) && ariaLevel >= 1 ? ariaLevel : 2;
}
function collectKickerCandidates(doc, getStyle, resolveLetterSpacing) {
const candidates = [];
for (const heading of doc.querySelectorAll('h2, h3, h4')) {
if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
for (const heading of doc.querySelectorAll('h1, h2, h3, h4, [role="heading"]')) {
const headingLevel = kickerHeadingLevel(heading);
if (!headingLevel || headingLevel > 4) continue;
if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
// Application contexts (tab panels, dialogs) use compact context labels
// above headings to describe state, not to decorate. Same carve-out the
// hero-eyebrow rule makes.
if (heading.closest?.('[role="tabpanel"], [role="dialog"], [role="application"], dialog')) continue;
const kicker = heading.previousElementSibling;
if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
if (isRepeatedKickerCardContext(heading, kicker)) continue;
if (!kicker || kicker.closest?.(KICKER_SKIP_SELECTOR)) continue;
if (isKickerCardContext(heading, kicker)) continue;
const headingStyle = getStyle(heading);
const kickerStyle = getStyle(kicker);
const headingTag = heading.tagName.toLowerCase();
const headingText = (heading.textContent || '').replace(/\s+/g, ' ').trim();
const kickerText = cleanInlineText(kicker) || (kicker.textContent || '').replace(/\s+/g, ' ').trim();
const headingFontSize = resolveLetterSpacing(headingStyle.fontSize || '', 16) || parseFloat(headingStyle.fontSize) || 0;
const kickerFontSize = resolveLetterSpacing(kickerStyle.fontSize || '', 16) || parseFloat(kickerStyle.fontSize) || 0;
const kickerLetterSpacing = resolveLetterSpacing(kickerStyle.letterSpacing || '', kickerFontSize);
if (!isRepeatedKickerCandidate({
headingTag: heading.tagName.toLowerCase(),
if (!isKickerCandidate({
headingLevel,
headingText,
headingFontSize,
kickerTag: kicker.tagName.toLowerCase(),
kickerText,
kickerTextTransform: kickerStyle.textTransform || '',
kickerFontVariant: `${kickerStyle.fontVariant || ''} ${kickerStyle.fontVariantCaps || ''}`,
kickerFontSize,
kickerLetterSpacing,
})) {
continue;
}
// A tracked-caps eyebrow above a hero-scale h1 belongs to
// hero-eyebrow-chip (which also covers the accent-bold and dash-prefix
// stylings there). Stand down so one element gets one finding.
if (headingTag === 'h1' && headingFontSize >= 48 && kickerLetterSpacing >= 1.6) {
continue;
}
candidates.push({
headingTag: heading.tagName.toLowerCase(),
headingTag,
headingText: headingText.replace(/^"|"$/g, '').slice(0, 60),
kickerText: kickerText.slice(0, 40),
});
@@ -2497,17 +2540,17 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac
return candidates;
}
function checkRepeatedSectionKickersDOM() {
const candidates = collectRepeatedSectionKickerCandidates(
function checkKickerAboveHeadingDOM() {
const candidates = collectKickerCandidates(
document,
(el) => getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
return checkRepeatedSectionKickers({ candidates });
return checkKickerAboveHeading({ candidates });
}
// ── Numbered section labels ─────────────────────────────────────────────────
// Sibling of the repeated-kicker rule: instead of a tracked uppercase word,
// Sibling of the kicker-above-heading rule: instead of a tracked uppercase word,
// the section scaffold is a tiny numeric index riding beside each section
// heading — bare and zero-padded, or an index joined to a short micro-label
// by a separator glyph. The kicker rule deliberately excludes bare 1-2 digit
@@ -2561,7 +2604,7 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
const candidates = [];
const seenLabels = new Set();
for (const heading of doc.querySelectorAll('h2, h3, h4')) {
if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
// The index sits either directly before the heading, or before the
// wrapper the heading leads (label | <div><h2>…</h2>…</div>).
let label = heading.previousElementSibling;
@@ -2571,9 +2614,9 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
if (firstChild === heading) label = parent.previousElementSibling;
}
if (!label || seenLabels.has(label)) continue;
if (label.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
if (label.closest?.(KICKER_SKIP_SELECTOR)) continue;
if (HEADING_TAGS.has(label.tagName.toLowerCase())) continue;
if (isRepeatedKickerCardContext(heading, label)) continue;
if (isKickerCardContext(heading, label)) continue;
const labelText = cleanInlineText(label) || (label.textContent || '').replace(/\s+/g, ' ').trim();
const parsed = parseNumberedLabelText(labelText);
@@ -3746,13 +3789,13 @@ function checkElementHeroEyebrow(el, style, tag, window, customPropMap) {
});
}
function checkRepeatedSectionKickersFromDoc(doc, win) {
const candidates = collectRepeatedSectionKickerCandidates(
function checkKickerAboveHeadingFromDoc(doc, win) {
const candidates = collectKickerCandidates(
doc,
(el) => win.getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
return checkRepeatedSectionKickers({ candidates });
return checkKickerAboveHeading({ candidates });
}
function checkElementMotion(tag, style) {
@@ -5393,7 +5436,7 @@ export {
checkItalicSerif,
isAccentColor,
checkHeroEyebrow,
checkRepeatedSectionKickers,
checkKickerAboveHeading,
checkMotion,
checkGlow,
scanCssTextForGlow,
@@ -5424,9 +5467,9 @@ export {
parseAnyColor,
parseColorResolved,
cleanInlineText,
isRepeatedKickerCandidate,
collectRepeatedSectionKickerCandidates,
checkRepeatedSectionKickersDOM,
isKickerCandidate,
collectKickerCandidates,
checkKickerAboveHeadingDOM,
parseNumberedLabelText,
isNumberedSectionLabelCandidate,
collectNumberedSectionLabelCandidates,
@@ -5458,7 +5501,7 @@ export {
checkElementIconTile,
checkElementItalicSerif,
checkElementHeroEyebrow,
checkRepeatedSectionKickersFromDoc,
checkKickerAboveHeadingFromDoc,
checkElementMotion,
checkElementGlow,
checkTypography,
+1 -1
View File
@@ -23,7 +23,7 @@ Page scaffolds:
- Same-size cards of icon plus heading plus text as the page structure. Cards are the lazy container; nested cards are always wrong. <!-- rule:skill-ban-identical-card-grids --> <!-- rule:skill-layout-cards-lazy -->
- The hero-metric template: big number, small label, supporting stats, accent. <!-- rule:skill-ban-hero-metric -->
- A tracked uppercase eyebrow over every section. One named kicker is a system; an eyebrow everywhere is grammar you did not choose. <!-- rule:skill-ban-eyebrow-on-every-section -->
- A kicker or eyebrow above a heading. This one is a ban, not a default: no brief earns it back. The heading carries its own weight; delete the label and let the heading speak. <!-- rule:skill-ban-eyebrow-on-every-section -->
- Section numbers (01 / 02 / 03) unless the sequence itself carries information the reader needs. <!-- rule:skill-ban-numbered-section-markers -->
- A modal for a task that needs neither interruption nor protected focus. <!-- rule:skill-reflex-modal-by-reflex -->
+35 -22
View File
@@ -912,50 +912,63 @@ describe('detectHtml — hero-eyebrow-chip', () => {
});
});
describe('detectHtml — repeated-section-kickers', () => {
describe('detectHtml — kicker-above-heading', () => {
const SHOULD_FLAG = [
'The Future Is Admitted',
'A Private Rehearsal',
'Reviewed, Not Sold',
'Touch the Future',
'A Single Kicker Still Flags',
'Kicker Above An H3',
'Kicker Above An H4',
'Sub Hero Heading',
'Small Caps Kicker',
'Heading Role Kicker',
];
const SHOULD_PASS = [
'Breadcrumb Before Heading',
'Breadcrumb Trail Outside Nav',
'Dateline Above Headline',
'Editorial Card Meta',
'Form Heading Is Separate',
'Step Indicator',
'Figure Caption Label',
'Normal Case Kicker',
'Limitation Of Liability',
'Indemnification Clause',
'Chapter Numbering Passes',
'Application Panel Context',
'Page Title After Nav',
'48%',
'Sentence Case Lead In',
'Untracked Caps Label',
'Intentional Brand Label',
'Hero Owned By Hero Rule',
'Garden Suite',
'Sea Loft',
'Cliff Suite',
'/impeccabletypeset',
'/impeccablelayout',
'/impeccablecolorize',
'/impeccablecraft',
'/impeccableaudit',
'/impeccablepolish',
'Step Indicator',
'Mockup Hero Variant One',
'Mockup Hero Variant Two',
'Mockup Hero Variant Three',
];
it('repeated-section-kickers: flags only repeated section scaffolding', async () => {
const f = await detectHtml(path.join(FIXTURES, 'repeated-section-kickers.html'));
it('kicker-above-heading: flags any kicker above a heading, without repetition', async () => {
const f = await detectHtml(path.join(FIXTURES, 'kicker-above-heading.html'));
const flagged = new Set();
for (const r of f) {
if (r.antipattern !== 'repeated-section-kickers') continue;
assert.equal(r.severity, 'advisory');
if (r.antipattern !== 'kicker-above-heading') continue;
assert.equal(r.severity, 'warning');
const matches = [...(r.snippet || '').matchAll(/"([^"]+)"/g)];
if (matches.length) flagged.add(matches[matches.length - 1][1]);
}
for (const text of SHOULD_FLAG) {
assert.ok(flagged.has(text), `expected "${text}" to be flagged as repeated-section-kickers`);
assert.ok(flagged.has(text), `expected "${text}" to be flagged as kicker-above-heading`);
}
for (const text of SHOULD_PASS) {
assert.ok(!flagged.has(text), `"${text}" should NOT be flagged as repeated-section-kickers`);
assert.ok(!flagged.has(text), `"${text}" should NOT be flagged as kicker-above-heading`);
}
// The retired repeated-section-kickers id must never resurface.
assert.ok(!f.some(r => r.antipattern === 'repeated-section-kickers'),
'retired rule id repeated-section-kickers should not fire');
// The hero-scale h1 eyebrow stays with hero-eyebrow-chip, exactly once.
const heroHits = f.filter(r => r.antipattern === 'hero-eyebrow-chip'
&& /Hero Owned By Hero Rule/.test(r.snippet || ''));
assert.equal(heroHits.length, 1, 'hero-eyebrow-chip should own the hero-scale h1 eyebrow');
});
});
+349
View File
@@ -0,0 +1,349 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Kicker Above Heading Fixture</title>
<style>
body {
margin: 0;
font-family: Arial, sans-serif;
background: #f6f2ec;
color: #171411;
}
main {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 32px;
padding: 32px;
}
.col {
min-width: 0;
border: 1px solid #c9bfb2;
padding: 24px;
}
.case {
min-height: 120px;
margin: 0 0 24px;
padding: 16px;
background: #fffaf3;
}
.kicker,
.pass-kicker,
.pass-kicker-caps {
display: block;
width: 240px;
min-height: 16px;
margin: 0 0 8px;
font-size: 12px;
line-height: 16px;
letter-spacing: 0.11em;
color: #5b5046;
}
.kicker,
.pass-kicker-caps {
text-transform: uppercase;
}
.smallcaps-kicker {
display: block;
width: 240px;
min-height: 16px;
margin: 0 0 8px;
font-size: 12px;
line-height: 16px;
letter-spacing: 0.12em;
font-variant: small-caps;
color: #5b5046;
}
.untracked-caps {
display: block;
width: 240px;
min-height: 16px;
margin: 0 0 8px;
font-size: 12px;
line-height: 16px;
letter-spacing: normal;
text-transform: uppercase;
color: #5b5046;
}
h1,
h2 {
margin: 0 0 12px;
font-size: 32px;
line-height: 38px;
font-weight: 700;
}
h3 {
margin: 0 0 12px;
font-size: 24px;
line-height: 30px;
font-weight: 700;
}
h4 {
margin: 0 0 12px;
font-size: 20px;
line-height: 26px;
font-weight: 700;
}
.role-heading {
margin: 0 0 12px;
font-size: 28px;
line-height: 34px;
font-weight: 700;
}
.hero-h1 {
margin: 0 0 12px;
font-size: 64px;
line-height: 70px;
font-weight: 700;
}
.hero-eyebrow {
display: block;
width: 240px;
min-height: 16px;
margin: 0 0 8px;
font-size: 12px;
line-height: 16px;
letter-spacing: 2px;
text-transform: uppercase;
color: #5b5046;
}
p,
label,
figcaption,
a,
li {
font-size: 16px;
line-height: 24px;
}
nav,
form,
figure,
ol {
margin: 0 0 24px;
padding: 16px;
border: 1px solid #d8cec0;
min-height: 80px;
}
.card-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin: 0 0 24px;
}
.suite-card {
display: block;
min-height: 120px;
padding: 16px;
border: 1px solid #d8cec0;
background: #fff;
}
.taxonomy-label {
display: block;
margin: 0 0 8px;
font-size: 12px;
line-height: 16px;
letter-spacing: 0.11em;
text-transform: uppercase;
color: #5b5046;
}
.suite-card h3 {
font-size: 24px;
line-height: 30px;
}
.stat {
min-height: 100px;
padding: 16px;
background: #fffaf3;
}
.stat h3 {
font-size: 40px;
line-height: 46px;
}
.hidden-mockup {
display: grid;
gap: 12px;
margin: 0 0 24px;
}
.mockup-hero {
min-height: 120px;
padding: 16px;
border: 1px solid #d8cec0;
background: #fff;
}
.mockup-hero h2 {
font-size: 24px;
line-height: 30px;
}
</style>
</head>
<body>
<main>
<section class="col" aria-label="Should flag">
<h1>Should flag</h1>
<section class="case">
<span class="kicker">Invitation protocol</span>
<h2>"A Single Kicker Still Flags"</h2>
<p>One tracked uppercase label above one heading is enough. No repetition required.</p>
</section>
<section class="case">
<p class="kicker">Preview sequence</p>
<h3>"Kicker Above An H3"</h3>
<p>Subsection headings get no eyebrow either.</p>
</section>
<section class="case">
<div class="kicker">Access window</div>
<h4>"Kicker Above An H4"</h4>
<p>The ban runs down to h4.</p>
</section>
<section class="case">
<span class="kicker">Platform notes</span>
<h1 class="sub-hero">"Sub Hero Heading"</h1>
<p>A 32px h1 is below hero scale, so this pair belongs to the general ban.</p>
</section>
<section class="case">
<span class="smallcaps-kicker">materials briefing</span>
<h2>"Small Caps Kicker"</h2>
<p>Small-caps styling is the same label in a different costume.</p>
</section>
<section class="case">
<div class="kicker">Field studies</div>
<div role="heading" aria-level="2" class="role-heading">"Heading Role Kicker"</div>
<p>Elements carrying a heading role are headings for this rule.</p>
</section>
</section>
<section class="col" aria-label="Should pass">
<h1>Should pass</h1>
<nav class="case" aria-label="Breadcrumb">
<span class="pass-kicker">Home / Journal</span>
<h2>"Breadcrumb Before Heading"</h2>
<a href="/">Home</a>
</nav>
<section class="case">
<span class="pass-kicker-caps">Home / Journal / Archive</span>
<h2>"Breadcrumb Trail Outside Nav"</h2>
<p>A path with separators is wayfinding, not a kicker, even without a nav wrapper.</p>
</section>
<section class="case">
<span class="pass-kicker-caps">News · Jan 12, 2026</span>
<h2>"Dateline Above Headline"</h2>
<p>Editorial category-and-date meta lines are content metadata.</p>
</section>
<article class="case">
<span class="pass-kicker-caps">Field report</span>
<h3>"Editorial Card Meta"</h3>
<p>Category labels inside an article card are structured metadata for scanning.</p>
</article>
<form class="case">
<label class="pass-kicker" for="guest">Guest name</label>
<input id="guest" name="guest" style="width: 220px; height: 40px;">
<h2>"Form Heading Is Separate"</h2>
</form>
<figure class="case">
<figcaption class="pass-kicker-caps">Plate study</figcaption>
<h3>"Figure Caption Label"</h3>
</figure>
<section class="case">
<span class="pass-kicker-caps">Section 4.2</span>
<h2>"Limitation Of Liability"</h2>
<p>Legal and document numbering identifies the clause; it is not decoration.</p>
</section>
<section class="case">
<span class="pass-kicker-caps">§ 12.3</span>
<h2>"Indemnification Clause"</h2>
<p>Statutory references keep their marker.</p>
</section>
<section class="case">
<span class="pass-kicker-caps">Chapter Four</span>
<h2>"Chapter Numbering Passes"</h2>
<p>Spelled-out chapter numbering is document structure, like legal numbering.</p>
</section>
<section class="case" role="tabpanel" aria-label="Saved station">
<span class="pass-kicker-caps">Next from</span>
<h2>"Application Panel Context"</h2>
<p>Context labels inside application panels describe state, not decoration.</p>
</section>
<section class="case">
<nav aria-label="Primary"><a href="/">Home</a> <a href="/docs">Docs</a></nav>
<h1>"Page Title After Nav"</h1>
<p>A navigation landmark preceding the page title is chrome, not a kicker.</p>
</section>
<div class="stat">
<h3>"48%"</h3>
<span class="pass-kicker-caps">Growth yoy</span>
<p>Stat callouts put the small label below the number. That direction is legal.</p>
</div>
<section class="case">
<span class="pass-kicker">A quiet lead-in sentence</span>
<h2>"Sentence Case Lead In"</h2>
<p>Not uppercase, not small-caps: an ordinary standfirst.</p>
</section>
<section class="case">
<span class="untracked-caps">New arrivals</span>
<h2>"Untracked Caps Label"</h2>
<p>Uppercase without tracking lacks the kicker styling signature.</p>
</section>
<section class="case" data-impeccable-allow-kickers>
<span class="pass-kicker-caps">Archive code</span>
<h2>"Intentional Brand Label"</h2>
<p>Deliberate brand systems can opt out with an explicit marker.</p>
</section>
<section class="case">
<span class="hero-eyebrow">Platform overview</span>
<h1 class="hero-h1">"Hero Owned By Hero Rule"</h1>
<p>Hero-scale h1 eyebrows stay with hero-eyebrow-chip; this rule stands down.</p>
</section>
<div class="card-grid" aria-label="Room cards">
<article class="suite-card">
<span class="taxonomy-label">Suite</span>
<h3>"Garden Suite"</h3>
<p>Category labels inside sibling cards are metadata for comparison.</p>
</article>
<article class="suite-card">
<span class="taxonomy-label">Suite</span>
<h3>"Sea Loft"</h3>
<p>The label is useful because the user is scanning sibling cards.</p>
</article>
</div>
<ol class="case">
<li>
<span class="pass-kicker-caps">Step 01</span>
<h3>"Step Indicator"</h3>
<p>Step indicators in ordered flows are sequence markers.</p>
</li>
</ol>
<div class="hidden-mockup" aria-hidden="true">
<div class="mockup-hero">
<span class="taxonomy-label">Amalfi Coast</span>
<h2>"Mockup Hero Variant One"</h2>
</div>
</div>
</section>
</main>
</body>
</html>
@@ -1,263 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Repeated Section Kickers Fixture</title>
<style>
body {
margin: 0;
font-family: Arial, sans-serif;
background: #f6f2ec;
color: #171411;
}
main {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 32px;
padding: 32px;
}
.col {
min-width: 0;
border: 1px solid #c9bfb2;
padding: 24px;
}
.case {
min-height: 120px;
margin: 0 0 24px;
padding: 16px;
background: #fffaf3;
}
.kicker,
.pass-kicker {
display: block;
width: 240px;
min-height: 16px;
margin: 0 0 8px;
font-size: 12px;
line-height: 16px;
letter-spacing: 0.11em;
color: #5b5046;
}
.kicker {
text-transform: uppercase;
}
h1,
h2,
h3 {
margin: 0 0 12px;
font-size: 32px;
line-height: 38px;
font-weight: 700;
}
p,
label,
figcaption,
a,
li {
font-size: 16px;
line-height: 24px;
}
nav,
form,
figure,
ol {
margin: 0 0 24px;
padding: 16px;
border: 1px solid #d8cec0;
min-height: 80px;
}
.brand-system {
min-height: 120px;
}
.card-grid,
.command-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin: 0 0 24px;
}
.suite-card,
.command-card {
display: block;
min-height: 120px;
padding: 16px;
border: 1px solid #d8cec0;
background: #fff;
text-align: left;
}
.taxonomy-label {
display: block;
margin: 0 0 8px;
font-size: 12px;
line-height: 16px;
letter-spacing: 0.11em;
text-transform: uppercase;
color: #5b5046;
}
.suite-card h3,
.command-card h3 {
font-size: 24px;
line-height: 30px;
}
.command-carousel,
.hidden-mockup {
display: grid;
gap: 12px;
margin: 0 0 24px;
}
.command-spread,
.mockup-hero {
min-height: 120px;
padding: 16px;
border: 1px solid #d8cec0;
background: #fff;
}
.command-spread h3,
.mockup-hero h2 {
font-size: 24px;
line-height: 30px;
}
</style>
</head>
<body>
<main>
<section class="col" aria-label="Should flag">
<h1>Should flag</h1>
<section class="case">
<span class="kicker">Invitation protocol</span>
<h2>"The Future Is Admitted"</h2>
<p>Repeated tracked labels carry the hierarchy instead of a stronger structure.</p>
</section>
<section class="case">
<p class="kicker">Preview sequence</p>
<h2>"A Private Rehearsal"</h2>
<p>The same scaffolding appears again before another section heading.</p>
</section>
<section class="case">
<div class="kicker">Access window</div>
<h2>"Reviewed, Not Sold"</h2>
<p>The pattern is now the page's default section grammar.</p>
</section>
<section class="case">
<small class="kicker">Material briefing</small>
<h2>"Touch the Future"</h2>
<p>Four repeated kickers should be enough to flag the page-level smell.</p>
</section>
</section>
<section class="col" aria-label="Should pass">
<h1>Should pass</h1>
<nav class="case" aria-label="Breadcrumb">
<span class="pass-kicker">Home / Journal</span>
<h2>"Breadcrumb Before Heading"</h2>
<a href="/">Home</a>
</nav>
<form class="case">
<label class="pass-kicker" for="guest">Guest name</label>
<input id="guest" name="guest" style="width: 220px; height: 40px;">
<h2>"Form Heading Is Separate"</h2>
</form>
<ol class="case">
<li>
<span class="pass-kicker">Step 01</span>
<h3>"Step Indicator"</h3>
<p>Step indicators in ordered flows should not count as section scaffolding.</p>
</li>
</ol>
<figure class="case">
<span class="pass-kicker">Plate study</span>
<figcaption>"Figure Caption Label"</figcaption>
</figure>
<section class="case">
<span class="pass-kicker">Lowercase label</span>
<h2>"Normal Case Kicker"</h2>
<p>Not uppercase, not the repeated AI section-label pattern.</p>
</section>
<section class="case brand-system" data-impeccable-allow-kickers>
<span class="pass-kicker">Archive code</span>
<h2>"Intentional Brand Label"</h2>
<p>Deliberate brand systems can opt out with an explicit marker.</p>
</section>
<div class="card-grid" aria-label="Room cards">
<article class="suite-card">
<span class="taxonomy-label">Suite</span>
<h3>"Garden Suite"</h3>
<p>Repeated category labels inside cards are structured metadata, not page-section scaffolding.</p>
</article>
<article class="suite-card">
<span class="taxonomy-label">Suite</span>
<h3>"Sea Loft"</h3>
<p>The label is useful because the user is scanning sibling cards.</p>
</article>
<article class="suite-card">
<span class="taxonomy-label">Suite</span>
<h3>"Cliff Suite"</h3>
<p>Repeating it here does not create the generic section-kicker smell.</p>
</article>
</div>
<div class="command-grid" aria-label="Command cards">
<button type="button" class="command-card">
<span class="taxonomy-label">Refine</span>
<h3>"/impeccabletypeset"</h3>
<p>Command category labels can repeat across many action cards.</p>
</button>
<button type="button" class="command-card">
<span class="taxonomy-label">Refine</span>
<h3>"/impeccablelayout"</h3>
<p>The repeated taxonomy helps compare tools inside the same group.</p>
</button>
<button type="button" class="command-card">
<span class="taxonomy-label">Refine</span>
<h3>"/impeccablecolorize"</h3>
<p>This is a card-grid label, not a page-level eyebrow before a section.</p>
</button>
</div>
<div class="command-carousel" aria-label="Command carousel">
<div class="command-spread" data-category="create">
<span class="taxonomy-label">Create</span>
<h3>"/impeccablecraft"</h3>
<p>Carousel slides can repeat category labels as navigational taxonomy.</p>
</div>
<div class="command-spread" data-category="evaluate">
<span class="taxonomy-label">Evaluate</span>
<h3>"/impeccableaudit"</h3>
<p>The slash command heading identifies an item, not a page section.</p>
</div>
<div class="command-spread" data-category="harden">
<span class="taxonomy-label">Harden</span>
<h3>"/impeccablepolish"</h3>
<p>The label is a category chip for a selectable command slide.</p>
</div>
</div>
<div class="hidden-mockup" aria-hidden="true">
<div class="mockup-hero">
<span class="taxonomy-label">Amalfi Coast</span>
<h2>"Mockup Hero Variant One"</h2>
</div>
<div class="mockup-hero">
<span class="taxonomy-label">Amalfi Coast</span>
<h2>"Mockup Hero Variant Two"</h2>
</div>
<div class="mockup-hero">
<span class="taxonomy-label">Amalfi Coast</span>
<h2>"Mockup Hero Variant Three"</h2>
</div>
</div>
</section>
</main>
</body>
</html>