diff --git a/cli/engine/browser/injected/index.mjs b/cli/engine/browser/injected/index.mjs
index e61c536ee..dfc725a8e 100644
--- a/cli/engine/browser/injected/index.mjs
+++ b/cli/engine/browser/injected/index.mjs
@@ -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) {
diff --git a/cli/engine/detect-antipatterns-browser.js b/cli/engine/detect-antipatterns-browser.js
index a728d4f9e..3d948ad39 100644
--- a/cli/engine/detect-antipatterns-browser.js
+++ b/cli/engine/detect-antipatterns-browser.js
@@ -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 |
…
…).
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) {
diff --git a/cli/engine/engines/static-html/css-cascade.mjs b/cli/engine/engines/static-html/css-cascade.mjs
index 53d7be48c..939eed779 100644
--- a/cli/engine/engines/static-html/css-cascade.mjs
+++ b/cli/engine/engines/static-html/css-cascade.mjs
@@ -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',
diff --git a/cli/engine/engines/static-html/detect-html.mjs b/cli/engine/engines/static-html/detect-html.mjs
index 1ebb3e427..4e2074042 100644
--- a/cli/engine/engines/static-html/detect-html.mjs
+++ b/cli/engine/engines/static-html/detect-html.mjs
@@ -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))) {
diff --git a/cli/engine/registry/antipatterns.mjs b/cli/engine/registry/antipatterns.mjs
index c47d39524..38ce07390 100644
--- a/cli/engine/registry/antipatterns.mjs
+++ b/cli/engine/registry/antipatterns.mjs
@@ -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',
diff --git a/cli/engine/rules/checks.mjs b/cli/engine/rules/checks.mjs
index 3e810076b..3bf26b9b5 100644
--- a/cli/engine/rules/checks.mjs
+++ b/cli/engine/rules/checks.mjs
@@ -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 | …
…).
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,
diff --git a/skill/reference/craft-floor.md b/skill/reference/craft-floor.md
index f1f2894d9..68162c595 100644
--- a/skill/reference/craft-floor.md
+++ b/skill/reference/craft-floor.md
@@ -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.
- The hero-metric template: big number, small label, supporting stats, accent.
-- A tracked uppercase eyebrow over every section. One named kicker is a system; an eyebrow everywhere is grammar you did not choose.
+- 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.
- Section numbers (01 / 02 / 03) unless the sequence itself carries information the reader needs.
- A modal for a task that needs neither interruption nor protected focus.
diff --git a/tests/detect-antipatterns-fixtures.test.mjs b/tests/detect-antipatterns-fixtures.test.mjs
index 78abd7db8..722a29c04 100644
--- a/tests/detect-antipatterns-fixtures.test.mjs
+++ b/tests/detect-antipatterns-fixtures.test.mjs
@@ -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');
});
});
diff --git a/tests/fixtures/antipatterns/kicker-above-heading.html b/tests/fixtures/antipatterns/kicker-above-heading.html
new file mode 100644
index 000000000..a301f147b
--- /dev/null
+++ b/tests/fixtures/antipatterns/kicker-above-heading.html
@@ -0,0 +1,349 @@
+
+
+
+
+ Kicker Above Heading Fixture
+
+
+
+
+
+ Should flag
+
+
+ Invitation protocol
+ "A Single Kicker Still Flags"
+ One tracked uppercase label above one heading is enough. No repetition required.
+
+
+
+ Preview sequence
+ "Kicker Above An H3"
+ Subsection headings get no eyebrow either.
+
+
+
+ Access window
+ "Kicker Above An H4"
+ The ban runs down to h4.
+
+
+
+ Platform notes
+ "Sub Hero Heading"
+ A 32px h1 is below hero scale, so this pair belongs to the general ban.
+
+
+
+ materials briefing
+ "Small Caps Kicker"
+ Small-caps styling is the same label in a different costume.
+
+
+
+ Field studies
+ "Heading Role Kicker"
+ Elements carrying a heading role are headings for this rule.
+
+
+
+
+ Should pass
+
+
+
+
+ Home / Journal / Archive
+ "Breadcrumb Trail Outside Nav"
+ A path with separators is wayfinding, not a kicker, even without a nav wrapper.
+
+
+
+ News · Jan 12, 2026
+ "Dateline Above Headline"
+ Editorial category-and-date meta lines are content metadata.
+
+
+
+ Field report
+ "Editorial Card Meta"
+ Category labels inside an article card are structured metadata for scanning.
+
+
+
+
+
+ Plate study
+ "Figure Caption Label"
+
+
+
+ Section 4.2
+ "Limitation Of Liability"
+ Legal and document numbering identifies the clause; it is not decoration.
+
+
+
+ § 12.3
+ "Indemnification Clause"
+ Statutory references keep their marker.
+
+
+
+ Chapter Four
+ "Chapter Numbering Passes"
+ Spelled-out chapter numbering is document structure, like legal numbering.
+
+
+
+ Next from
+ "Application Panel Context"
+ Context labels inside application panels describe state, not decoration.
+
+
+
+
+ "Page Title After Nav"
+ A navigation landmark preceding the page title is chrome, not a kicker.
+
+
+
+
"48%"
+
Growth yoy
+
Stat callouts put the small label below the number. That direction is legal.
+
+
+
+ A quiet lead-in sentence
+ "Sentence Case Lead In"
+ Not uppercase, not small-caps: an ordinary standfirst.
+
+
+
+ New arrivals
+ "Untracked Caps Label"
+ Uppercase without tracking lacks the kicker styling signature.
+
+
+
+ Archive code
+ "Intentional Brand Label"
+ Deliberate brand systems can opt out with an explicit marker.
+
+
+
+ Platform overview
+ "Hero Owned By Hero Rule"
+ Hero-scale h1 eyebrows stay with hero-eyebrow-chip; this rule stands down.
+
+
+
+
+ Suite
+ "Garden Suite"
+ Category labels inside sibling cards are metadata for comparison.
+
+
+ Suite
+ "Sea Loft"
+ The label is useful because the user is scanning sibling cards.
+
+
+
+
+ -
+ Step 01
+
"Step Indicator"
+ Step indicators in ordered flows are sequence markers.
+
+
+
+
+
+ Amalfi Coast
+
"Mockup Hero Variant One"
+
+
+
+
+
+
diff --git a/tests/fixtures/antipatterns/repeated-section-kickers.html b/tests/fixtures/antipatterns/repeated-section-kickers.html
deleted file mode 100644
index 4942511ff..000000000
--- a/tests/fixtures/antipatterns/repeated-section-kickers.html
+++ /dev/null
@@ -1,263 +0,0 @@
-
-
-
-
- Repeated Section Kickers Fixture
-
-
-
-
-
- Should flag
-
-
- Invitation protocol
- "The Future Is Admitted"
- Repeated tracked labels carry the hierarchy instead of a stronger structure.
-
-
-
- Preview sequence
- "A Private Rehearsal"
- The same scaffolding appears again before another section heading.
-
-
-
- Access window
- "Reviewed, Not Sold"
- The pattern is now the page's default section grammar.
-
-
-
- Material briefing
- "Touch the Future"
- Four repeated kickers should be enough to flag the page-level smell.
-
-
-
-
- Should pass
-
-
-
-
-
-
- -
- Step 01
-
"Step Indicator"
- Step indicators in ordered flows should not count as section scaffolding.
-
-
-
-
- Plate study
- "Figure Caption Label"
-
-
-
- Lowercase label
- "Normal Case Kicker"
- Not uppercase, not the repeated AI section-label pattern.
-
-
-
- Archive code
- "Intentional Brand Label"
- Deliberate brand systems can opt out with an explicit marker.
-
-
-
-
- Suite
- "Garden Suite"
- Repeated category labels inside cards are structured metadata, not page-section scaffolding.
-
-
- Suite
- "Sea Loft"
- The label is useful because the user is scanning sibling cards.
-
-
- Suite
- "Cliff Suite"
- Repeating it here does not create the generic section-kicker smell.
-
-
-
-
-
-
-
-
-
-
-
-
Create
-
"/impeccablecraft"
-
Carousel slides can repeat category labels as navigational taxonomy.
-
-
-
Evaluate
-
"/impeccableaudit"
-
The slash command heading identifies an item, not a page section.
-
-
-
Harden
-
"/impeccablepolish"
-
The label is a category chip for a selectable command slide.
-
-
-
-
-
- Amalfi Coast
-
"Mockup Hero Variant One"
-
-
- Amalfi Coast
-
"Mockup Hero Variant Two"
-
-
- Amalfi Coast
-
"Mockup Hero Variant Three"
-
-
-
-
-
-