Sync generated provider output

This commit is contained in:
github-actions[bot]
2026-07-28 01:56:22 +00:00
parent 690e24129a
commit d3c7b05a3e
105 changed files with 2520 additions and 1245 deletions
@@ -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.
@@ -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) {
@@ -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))) {
@@ -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',
@@ -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,
@@ -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.
@@ -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) {
@@ -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))) {
@@ -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',
@@ -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,
@@ -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.
@@ -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) {
@@ -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))) {
@@ -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',
@@ -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,
@@ -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.
@@ -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) {
@@ -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))) {
@@ -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',
@@ -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,
@@ -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.
@@ -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) {
@@ -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))) {
@@ -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',
@@ -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,
@@ -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.
@@ -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) {
@@ -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))) {
@@ -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',
@@ -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,
@@ -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.
@@ -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) {
@@ -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))) {
@@ -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',
@@ -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,
@@ -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.
@@ -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) {
@@ -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))) {
@@ -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',
@@ -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,
@@ -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.
@@ -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) {
@@ -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))) {
@@ -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',
@@ -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,
@@ -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.
@@ -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) {
@@ -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))) {
@@ -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',
@@ -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,
@@ -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.
@@ -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) {
@@ -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))) {
@@ -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',
@@ -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,
@@ -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.
@@ -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) {
@@ -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))) {
@@ -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',
@@ -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,
@@ -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.
@@ -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) {
@@ -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))) {
@@ -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',
@@ -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,
@@ -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.
@@ -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) {
@@ -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))) {
@@ -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',
@@ -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,
@@ -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.
@@ -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) {

Some files were not shown because too many files have changed in this diff Show More