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.

+
+ +
+ + +

"Form Heading Is Separate"

+
+ +
+
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.

+
+
+ +
    +
  1. + Step 01 +

    "Step Indicator"

    +

    Step indicators in ordered flows are sequence markers.

    +
  2. +
+ + +
+
+ + 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

- - - -
- - -

"Form Heading Is Separate"

-
- -
    -
  1. - Step 01 -

    "Step Indicator"

    -

    Step indicators in ordered flows should not count as section scaffolding.

    -
  2. -
- -
- 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.

-
-
- -
- - - -
- - - - -
-
- -