diff --git a/.agents/skills/impeccable/reference/craft-floor.md b/.agents/skills/impeccable/reference/craft-floor.md
index 4a5d39b5c..6516d6de4 100644
--- a/.agents/skills/impeccable/reference/craft-floor.md
+++ b/.agents/skills/impeccable/reference/craft-floor.md
@@ -23,7 +23,7 @@ Page scaffolds:
- Same-size cards of icon plus heading plus text as the page structure. Cards are the lazy container; nested cards are always wrong.
- The hero-metric template: big number, small label, supporting stats, accent.
-- A tracked uppercase eyebrow over every section. One named kicker is a system; an eyebrow everywhere is grammar you did not choose.
+- A kicker or eyebrow above a heading. This one is a ban, not a default: no brief earns it back. The heading carries its own weight; delete the label and let the heading speak.
- Section numbers (01 / 02 / 03) unless the sequence itself carries information the reader needs.
- A modal for a task that needs neither interruption nor protected focus.
diff --git a/.agents/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.agents/skills/impeccable/scripts/detector/browser/injected/index.mjs
index e61c536ee..dfc725a8e 100644
--- a/.agents/skills/impeccable/scripts/detector/browser/injected/index.mjs
+++ b/.agents/skills/impeccable/scripts/detector/browser/injected/index.mjs
@@ -1529,7 +1529,7 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, typoFindings);
}
- const sectionKickerFindings = checkRepeatedSectionKickersDOM()
+ const sectionKickerFindings = checkKickerAboveHeadingDOM()
.map(f => ({ type: f.id, detail: f.snippet }))
.filter(f => _ruleOk(f.type));
if (sectionKickerFindings.length > 0) {
diff --git a/.agents/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.agents/skills/impeccable/scripts/detector/detect-antipatterns-browser.js
index a728d4f9e..3d948ad39 100644
--- a/.agents/skills/impeccable/scripts/detector/detect-antipatterns-browser.js
+++ b/.agents/skills/impeccable/scripts/detector/detect-antipatterns-browser.js
@@ -309,15 +309,14 @@ const ANTIPATTERNS = [
skillGuideline: 'tiny uppercase tracked label above the hero headline',
},
{
- id: 'repeated-section-kickers',
+ id: 'kicker-above-heading',
category: 'slop',
scopes: ['type'],
- severity: 'advisory',
- name: 'Repeated section kicker labels',
+ name: 'Kicker / eyebrow label above heading',
description:
- 'Repeating tiny uppercase tracked labels above section headings turns a brand page into AI editorial scaffolding. Replace them with stronger structure, artifacts, imagery, or a deliberate brand system.',
+ 'A tiny tracked uppercase or small-caps label sitting as its own block directly above a heading is banned outright, repeated or not. Generated kickers never earn their place: the heading carries its own weight. Delete the label and let the heading speak; if the words matter, work them into the heading or the body.',
skillSection: 'Typography',
- skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding',
+ skillGuideline: 'kicker or eyebrow labels above headings',
},
{
id: 'numbered-section-labels',
@@ -1258,12 +1257,15 @@ function checkHeroEyebrow(opts) {
}];
}
-function checkRepeatedSectionKickers(opts) {
- const { candidates, minCount = 3 } = opts;
- if (!Array.isArray(candidates) || candidates.length < minCount) return [];
+// Outright ban: one kicker is one too many, so every collected candidate is
+// a finding. The judgment lives in the candidate gate (isKickerCandidate) and
+// the collector's context skips, not in a repetition count.
+function checkKickerAboveHeading(opts) {
+ const { candidates } = opts;
+ if (!Array.isArray(candidates)) return [];
return candidates.map(candidate => ({
- id: 'repeated-section-kickers',
- snippet: `repeated section kicker "${candidate.kickerText}" before ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`,
+ id: 'kicker-above-heading',
+ snippet: `kicker "${candidate.kickerText}" above ${candidate.headingTag} "${candidate.headingText}"`,
}));
}
@@ -3190,7 +3192,7 @@ function parseColorResolved(str, customPropMap) {
return parseAnyColor(resolved);
}
-const REPEATED_KICKER_SKIP_SELECTOR = [
+const KICKER_SKIP_SELECTOR = [
'nav',
'form',
'table',
@@ -3209,7 +3211,7 @@ const REPEATED_KICKER_SKIP_SELECTOR = [
'[data-impeccable-allow-kickers]',
].join(',');
-const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [
+const KICKER_CARD_CONTEXT_SELECTOR = [
'article',
'button',
'a',
@@ -3227,23 +3229,32 @@ function cleanInlineText(el) {
.trim();
}
-function isRepeatedKickerCardContext(heading, kicker) {
- const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR);
+function isKickerCardContext(heading, kicker) {
+ const item = heading.closest?.(KICKER_CARD_CONTEXT_SELECTOR);
return Boolean(item && (!item.contains || item.contains(kicker)));
}
-function isRepeatedKickerCandidate(opts) {
+// Meta lines above headlines join category and date (or path crumbs) with
+// separator glyphs, or carry a year. A kicker is one short phrase; metadata
+// keeps its markers.
+const KICKER_META_TEXT_RE = /[·•|]|\s[\/›»>]\s|\b(19|20)\d{2}\b/;
+// Legal and document numbering: "Section 4.2", "Article IX", "§ 12.3",
+// dotted decimal outlines. The label identifies the clause, so it stays.
+const KICKER_DOC_NUMBERING_RE = /^(§|\d+(\.\d+)+\b|(section|article|clause|appendix|exhibit|schedule|chapter|part|rule|title)\s+([\divxlc]+\b|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)\b)/i;
+
+function isKickerCandidate(opts) {
const {
- headingTag,
+ headingLevel,
headingText,
headingFontSize,
kickerTag,
kickerText,
kickerTextTransform,
+ kickerFontVariant,
kickerFontSize,
kickerLetterSpacing,
} = opts;
- if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
+ if (!headingLevel || headingLevel > 4) return false;
if (!headingText || headingText.length < 3) return false;
if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false;
if (!(headingFontSize >= 20)) return false;
@@ -3251,9 +3262,13 @@ function isRepeatedKickerCandidate(opts) {
if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;
if (!kickerText || kickerText.length < 2 || kickerText.length > 34) return false;
if (/^step\s*\d+/i.test(kickerText) || /^\d{1,2}$/.test(kickerText)) return false;
+ if (KICKER_META_TEXT_RE.test(kickerText)) return false;
+ if (KICKER_DOC_NUMBERING_RE.test(kickerText)) return false;
+ const isSmallCaps = /small-caps/.test(kickerFontVariant || '');
const isUppercased = kickerTextTransform === 'uppercase'
- || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText));
+ || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText))
+ || isSmallCaps;
if (!isUppercased) return false;
if (!(kickerFontSize > 0 && kickerFontSize <= 14)) return false;
const minTrackedSpacing = Math.max(1, kickerFontSize * 0.08);
@@ -3261,37 +3276,64 @@ function isRepeatedKickerCandidate(opts) {
return true;
}
-function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpacing) {
+// Resolve a heading level for the anchor element: 1-4 for h1-h4, aria-level
+// (default 2) for role="heading" elements, 0 otherwise.
+function kickerHeadingLevel(heading) {
+ const tag = heading.tagName.toLowerCase();
+ const byTag = /^h([1-6])$/.exec(tag);
+ if (byTag) return parseInt(byTag[1], 10);
+ const role = heading.getAttribute?.('role') || '';
+ if (role.toLowerCase() !== 'heading') return 0;
+ const ariaLevel = parseInt(heading.getAttribute?.('aria-level') || '', 10);
+ return Number.isFinite(ariaLevel) && ariaLevel >= 1 ? ariaLevel : 2;
+}
+
+function collectKickerCandidates(doc, getStyle, resolveLetterSpacing) {
const candidates = [];
- for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ for (const heading of doc.querySelectorAll('h1, h2, h3, h4, [role="heading"]')) {
+ const headingLevel = kickerHeadingLevel(heading);
+ if (!headingLevel || headingLevel > 4) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ // Application contexts (tab panels, dialogs) use compact context labels
+ // above headings to describe state, not to decorate. Same carve-out the
+ // hero-eyebrow rule makes.
+ if (heading.closest?.('[role="tabpanel"], [role="dialog"], [role="application"], dialog')) continue;
const kicker = heading.previousElementSibling;
- if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
- if (isRepeatedKickerCardContext(heading, kicker)) continue;
+ if (!kicker || kicker.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ if (isKickerCardContext(heading, kicker)) continue;
const headingStyle = getStyle(heading);
const kickerStyle = getStyle(kicker);
+ const headingTag = heading.tagName.toLowerCase();
const headingText = (heading.textContent || '').replace(/\s+/g, ' ').trim();
const kickerText = cleanInlineText(kicker) || (kicker.textContent || '').replace(/\s+/g, ' ').trim();
const headingFontSize = resolveLetterSpacing(headingStyle.fontSize || '', 16) || parseFloat(headingStyle.fontSize) || 0;
const kickerFontSize = resolveLetterSpacing(kickerStyle.fontSize || '', 16) || parseFloat(kickerStyle.fontSize) || 0;
const kickerLetterSpacing = resolveLetterSpacing(kickerStyle.letterSpacing || '', kickerFontSize);
- if (!isRepeatedKickerCandidate({
- headingTag: heading.tagName.toLowerCase(),
+ if (!isKickerCandidate({
+ headingLevel,
headingText,
headingFontSize,
kickerTag: kicker.tagName.toLowerCase(),
kickerText,
kickerTextTransform: kickerStyle.textTransform || '',
+ kickerFontVariant: `${kickerStyle.fontVariant || ''} ${kickerStyle.fontVariantCaps || ''}`,
kickerFontSize,
kickerLetterSpacing,
})) {
continue;
}
+ // A tracked-caps eyebrow above a hero-scale h1 belongs to
+ // hero-eyebrow-chip (which also covers the accent-bold and dash-prefix
+ // stylings there). Stand down so one element gets one finding.
+ if (headingTag === 'h1' && headingFontSize >= 48 && kickerLetterSpacing >= 1.6) {
+ continue;
+ }
+
candidates.push({
- headingTag: heading.tagName.toLowerCase(),
+ headingTag,
headingText: headingText.replace(/^"|"$/g, '').slice(0, 60),
kickerText: kickerText.slice(0, 40),
});
@@ -3299,17 +3341,17 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac
return candidates;
}
-function checkRepeatedSectionKickersDOM() {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingDOM() {
+ const candidates = collectKickerCandidates(
document,
(el) => getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
// ── Numbered section labels ─────────────────────────────────────────────────
-// Sibling of the repeated-kicker rule: instead of a tracked uppercase word,
+// Sibling of the kicker-above-heading rule: instead of a tracked uppercase word,
// the section scaffold is a tiny numeric index riding beside each section
// heading — bare and zero-padded, or an index joined to a short micro-label
// by a separator glyph. The kicker rule deliberately excludes bare 1-2 digit
@@ -3363,7 +3405,7 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
const candidates = [];
const seenLabels = new Set();
for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
// The index sits either directly before the heading, or before the
// wrapper the heading leads (label |
…
…).
let label = heading.previousElementSibling;
@@ -3373,9 +3415,9 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
if (firstChild === heading) label = parent.previousElementSibling;
}
if (!label || seenLabels.has(label)) continue;
- if (label.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (label.closest?.(KICKER_SKIP_SELECTOR)) continue;
if (HEADING_TAGS.has(label.tagName.toLowerCase())) continue;
- if (isRepeatedKickerCardContext(heading, label)) continue;
+ if (isKickerCardContext(heading, label)) continue;
const labelText = cleanInlineText(label) || (label.textContent || '').replace(/\s+/g, ' ').trim();
const parsed = parseNumberedLabelText(labelText);
@@ -4548,13 +4590,13 @@ function checkElementHeroEyebrow(el, style, tag, window, customPropMap) {
});
}
-function checkRepeatedSectionKickersFromDoc(doc, win) {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingFromDoc(doc, win) {
+ const candidates = collectKickerCandidates(
doc,
(el) => win.getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
function checkElementMotion(tag, style) {
@@ -7712,7 +7754,7 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, typoFindings);
}
- const sectionKickerFindings = checkRepeatedSectionKickersDOM()
+ const sectionKickerFindings = checkKickerAboveHeadingDOM()
.map(f => ({ type: f.id, detail: f.snippet }))
.filter(f => _ruleOk(f.type));
if (sectionKickerFindings.length > 0) {
diff --git a/.agents/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.agents/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs
index 53d7be48c..939eed779 100644
--- a/.agents/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs
+++ b/.agents/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs
@@ -223,7 +223,7 @@ function unwrapCssAtLayer(source) {
// ---------------------------------------------------------------------------
const STATIC_INHERITED_PROPS = new Set([
- 'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight',
+ 'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight', 'fontVariant',
'lineHeight', 'letterSpacing', 'textTransform', 'textAlign', 'hyphens',
'webkitHyphens',
]);
@@ -252,6 +252,7 @@ const STATIC_DEFAULT_STYLE = {
fontFamily: '',
fontSize: '16px',
fontStyle: 'normal',
+ fontVariant: 'normal',
fontWeight: '400',
lineHeight: 'normal',
letterSpacing: 'normal',
diff --git a/.agents/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.agents/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs
index 1ebb3e427..4e2074042 100644
--- a/.agents/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs
+++ b/.agents/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs
@@ -27,11 +27,11 @@ import {
checkElementRadialSpotlight,
checkCreamPalette,
checkHtmlPatterns,
+ checkKickerAboveHeadingFromDoc,
checkNumberedSectionLabelsFromDoc,
checkPageLayout,
checkPageQualityFromDoc,
checkRepeatedContainerTextFromDoc,
- checkRepeatedSectionKickersFromDoc,
resolveBackground,
resolveBorderRadiusPx,
} from '../../rules/checks.mjs';
@@ -202,7 +202,7 @@ async function detectHtml(filePath, options = {}) {
for (const f of runPageCheck('typography-rules', () => checkStaticPageTypography(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
- for (const f of runPageCheck('repeated-section-kickers', () => checkRepeatedSectionKickersFromDoc(document, window))) {
+ for (const f of runPageCheck('kicker-above-heading', () => checkKickerAboveHeadingFromDoc(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of runPageCheck('numbered-section-labels', () => checkNumberedSectionLabelsFromDoc(document, window))) {
diff --git a/.agents/skills/impeccable/scripts/detector/registry/antipatterns.mjs b/.agents/skills/impeccable/scripts/detector/registry/antipatterns.mjs
index c47d39524..38ce07390 100644
--- a/.agents/skills/impeccable/scripts/detector/registry/antipatterns.mjs
+++ b/.agents/skills/impeccable/scripts/detector/registry/antipatterns.mjs
@@ -198,15 +198,14 @@ const ANTIPATTERNS = [
skillGuideline: 'tiny uppercase tracked label above the hero headline',
},
{
- id: 'repeated-section-kickers',
+ id: 'kicker-above-heading',
category: 'slop',
scopes: ['type'],
- severity: 'advisory',
- name: 'Repeated section kicker labels',
+ name: 'Kicker / eyebrow label above heading',
description:
- 'Repeating tiny uppercase tracked labels above section headings turns a brand page into AI editorial scaffolding. Replace them with stronger structure, artifacts, imagery, or a deliberate brand system.',
+ 'A tiny tracked uppercase or small-caps label sitting as its own block directly above a heading is banned outright, repeated or not. Generated kickers never earn their place: the heading carries its own weight. Delete the label and let the heading speak; if the words matter, work them into the heading or the body.',
skillSection: 'Typography',
- skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding',
+ skillGuideline: 'kicker or eyebrow labels above headings',
},
{
id: 'numbered-section-labels',
diff --git a/.agents/skills/impeccable/scripts/detector/rules/checks.mjs b/.agents/skills/impeccable/scripts/detector/rules/checks.mjs
index 3e810076b..3bf26b9b5 100644
--- a/.agents/skills/impeccable/scripts/detector/rules/checks.mjs
+++ b/.agents/skills/impeccable/scripts/detector/rules/checks.mjs
@@ -456,12 +456,15 @@ function checkHeroEyebrow(opts) {
}];
}
-function checkRepeatedSectionKickers(opts) {
- const { candidates, minCount = 3 } = opts;
- if (!Array.isArray(candidates) || candidates.length < minCount) return [];
+// Outright ban: one kicker is one too many, so every collected candidate is
+// a finding. The judgment lives in the candidate gate (isKickerCandidate) and
+// the collector's context skips, not in a repetition count.
+function checkKickerAboveHeading(opts) {
+ const { candidates } = opts;
+ if (!Array.isArray(candidates)) return [];
return candidates.map(candidate => ({
- id: 'repeated-section-kickers',
- snippet: `repeated section kicker "${candidate.kickerText}" before ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`,
+ id: 'kicker-above-heading',
+ snippet: `kicker "${candidate.kickerText}" above ${candidate.headingTag} "${candidate.headingText}"`,
}));
}
@@ -2388,7 +2391,7 @@ function parseColorResolved(str, customPropMap) {
return parseAnyColor(resolved);
}
-const REPEATED_KICKER_SKIP_SELECTOR = [
+const KICKER_SKIP_SELECTOR = [
'nav',
'form',
'table',
@@ -2407,7 +2410,7 @@ const REPEATED_KICKER_SKIP_SELECTOR = [
'[data-impeccable-allow-kickers]',
].join(',');
-const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [
+const KICKER_CARD_CONTEXT_SELECTOR = [
'article',
'button',
'a',
@@ -2425,23 +2428,32 @@ function cleanInlineText(el) {
.trim();
}
-function isRepeatedKickerCardContext(heading, kicker) {
- const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR);
+function isKickerCardContext(heading, kicker) {
+ const item = heading.closest?.(KICKER_CARD_CONTEXT_SELECTOR);
return Boolean(item && (!item.contains || item.contains(kicker)));
}
-function isRepeatedKickerCandidate(opts) {
+// Meta lines above headlines join category and date (or path crumbs) with
+// separator glyphs, or carry a year. A kicker is one short phrase; metadata
+// keeps its markers.
+const KICKER_META_TEXT_RE = /[·•|]|\s[\/›»>]\s|\b(19|20)\d{2}\b/;
+// Legal and document numbering: "Section 4.2", "Article IX", "§ 12.3",
+// dotted decimal outlines. The label identifies the clause, so it stays.
+const KICKER_DOC_NUMBERING_RE = /^(§|\d+(\.\d+)+\b|(section|article|clause|appendix|exhibit|schedule|chapter|part|rule|title)\s+([\divxlc]+\b|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)\b)/i;
+
+function isKickerCandidate(opts) {
const {
- headingTag,
+ headingLevel,
headingText,
headingFontSize,
kickerTag,
kickerText,
kickerTextTransform,
+ kickerFontVariant,
kickerFontSize,
kickerLetterSpacing,
} = opts;
- if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
+ if (!headingLevel || headingLevel > 4) return false;
if (!headingText || headingText.length < 3) return false;
if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false;
if (!(headingFontSize >= 20)) return false;
@@ -2449,9 +2461,13 @@ function isRepeatedKickerCandidate(opts) {
if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;
if (!kickerText || kickerText.length < 2 || kickerText.length > 34) return false;
if (/^step\s*\d+/i.test(kickerText) || /^\d{1,2}$/.test(kickerText)) return false;
+ if (KICKER_META_TEXT_RE.test(kickerText)) return false;
+ if (KICKER_DOC_NUMBERING_RE.test(kickerText)) return false;
+ const isSmallCaps = /small-caps/.test(kickerFontVariant || '');
const isUppercased = kickerTextTransform === 'uppercase'
- || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText));
+ || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText))
+ || isSmallCaps;
if (!isUppercased) return false;
if (!(kickerFontSize > 0 && kickerFontSize <= 14)) return false;
const minTrackedSpacing = Math.max(1, kickerFontSize * 0.08);
@@ -2459,37 +2475,64 @@ function isRepeatedKickerCandidate(opts) {
return true;
}
-function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpacing) {
+// Resolve a heading level for the anchor element: 1-4 for h1-h4, aria-level
+// (default 2) for role="heading" elements, 0 otherwise.
+function kickerHeadingLevel(heading) {
+ const tag = heading.tagName.toLowerCase();
+ const byTag = /^h([1-6])$/.exec(tag);
+ if (byTag) return parseInt(byTag[1], 10);
+ const role = heading.getAttribute?.('role') || '';
+ if (role.toLowerCase() !== 'heading') return 0;
+ const ariaLevel = parseInt(heading.getAttribute?.('aria-level') || '', 10);
+ return Number.isFinite(ariaLevel) && ariaLevel >= 1 ? ariaLevel : 2;
+}
+
+function collectKickerCandidates(doc, getStyle, resolveLetterSpacing) {
const candidates = [];
- for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ for (const heading of doc.querySelectorAll('h1, h2, h3, h4, [role="heading"]')) {
+ const headingLevel = kickerHeadingLevel(heading);
+ if (!headingLevel || headingLevel > 4) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ // Application contexts (tab panels, dialogs) use compact context labels
+ // above headings to describe state, not to decorate. Same carve-out the
+ // hero-eyebrow rule makes.
+ if (heading.closest?.('[role="tabpanel"], [role="dialog"], [role="application"], dialog')) continue;
const kicker = heading.previousElementSibling;
- if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
- if (isRepeatedKickerCardContext(heading, kicker)) continue;
+ if (!kicker || kicker.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ if (isKickerCardContext(heading, kicker)) continue;
const headingStyle = getStyle(heading);
const kickerStyle = getStyle(kicker);
+ const headingTag = heading.tagName.toLowerCase();
const headingText = (heading.textContent || '').replace(/\s+/g, ' ').trim();
const kickerText = cleanInlineText(kicker) || (kicker.textContent || '').replace(/\s+/g, ' ').trim();
const headingFontSize = resolveLetterSpacing(headingStyle.fontSize || '', 16) || parseFloat(headingStyle.fontSize) || 0;
const kickerFontSize = resolveLetterSpacing(kickerStyle.fontSize || '', 16) || parseFloat(kickerStyle.fontSize) || 0;
const kickerLetterSpacing = resolveLetterSpacing(kickerStyle.letterSpacing || '', kickerFontSize);
- if (!isRepeatedKickerCandidate({
- headingTag: heading.tagName.toLowerCase(),
+ if (!isKickerCandidate({
+ headingLevel,
headingText,
headingFontSize,
kickerTag: kicker.tagName.toLowerCase(),
kickerText,
kickerTextTransform: kickerStyle.textTransform || '',
+ kickerFontVariant: `${kickerStyle.fontVariant || ''} ${kickerStyle.fontVariantCaps || ''}`,
kickerFontSize,
kickerLetterSpacing,
})) {
continue;
}
+ // A tracked-caps eyebrow above a hero-scale h1 belongs to
+ // hero-eyebrow-chip (which also covers the accent-bold and dash-prefix
+ // stylings there). Stand down so one element gets one finding.
+ if (headingTag === 'h1' && headingFontSize >= 48 && kickerLetterSpacing >= 1.6) {
+ continue;
+ }
+
candidates.push({
- headingTag: heading.tagName.toLowerCase(),
+ headingTag,
headingText: headingText.replace(/^"|"$/g, '').slice(0, 60),
kickerText: kickerText.slice(0, 40),
});
@@ -2497,17 +2540,17 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac
return candidates;
}
-function checkRepeatedSectionKickersDOM() {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingDOM() {
+ const candidates = collectKickerCandidates(
document,
(el) => getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
// ── Numbered section labels ─────────────────────────────────────────────────
-// Sibling of the repeated-kicker rule: instead of a tracked uppercase word,
+// Sibling of the kicker-above-heading rule: instead of a tracked uppercase word,
// the section scaffold is a tiny numeric index riding beside each section
// heading — bare and zero-padded, or an index joined to a short micro-label
// by a separator glyph. The kicker rule deliberately excludes bare 1-2 digit
@@ -2561,7 +2604,7 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
const candidates = [];
const seenLabels = new Set();
for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
// The index sits either directly before the heading, or before the
// wrapper the heading leads (label | …
…).
let label = heading.previousElementSibling;
@@ -2571,9 +2614,9 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
if (firstChild === heading) label = parent.previousElementSibling;
}
if (!label || seenLabels.has(label)) continue;
- if (label.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (label.closest?.(KICKER_SKIP_SELECTOR)) continue;
if (HEADING_TAGS.has(label.tagName.toLowerCase())) continue;
- if (isRepeatedKickerCardContext(heading, label)) continue;
+ if (isKickerCardContext(heading, label)) continue;
const labelText = cleanInlineText(label) || (label.textContent || '').replace(/\s+/g, ' ').trim();
const parsed = parseNumberedLabelText(labelText);
@@ -3746,13 +3789,13 @@ function checkElementHeroEyebrow(el, style, tag, window, customPropMap) {
});
}
-function checkRepeatedSectionKickersFromDoc(doc, win) {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingFromDoc(doc, win) {
+ const candidates = collectKickerCandidates(
doc,
(el) => win.getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
function checkElementMotion(tag, style) {
@@ -5393,7 +5436,7 @@ export {
checkItalicSerif,
isAccentColor,
checkHeroEyebrow,
- checkRepeatedSectionKickers,
+ checkKickerAboveHeading,
checkMotion,
checkGlow,
scanCssTextForGlow,
@@ -5424,9 +5467,9 @@ export {
parseAnyColor,
parseColorResolved,
cleanInlineText,
- isRepeatedKickerCandidate,
- collectRepeatedSectionKickerCandidates,
- checkRepeatedSectionKickersDOM,
+ isKickerCandidate,
+ collectKickerCandidates,
+ checkKickerAboveHeadingDOM,
parseNumberedLabelText,
isNumberedSectionLabelCandidate,
collectNumberedSectionLabelCandidates,
@@ -5458,7 +5501,7 @@ export {
checkElementIconTile,
checkElementItalicSerif,
checkElementHeroEyebrow,
- checkRepeatedSectionKickersFromDoc,
+ checkKickerAboveHeadingFromDoc,
checkElementMotion,
checkElementGlow,
checkTypography,
diff --git a/.claude/skills/impeccable/reference/craft-floor.md b/.claude/skills/impeccable/reference/craft-floor.md
index 3d0c7a8fb..4ea9b8fa4 100644
--- a/.claude/skills/impeccable/reference/craft-floor.md
+++ b/.claude/skills/impeccable/reference/craft-floor.md
@@ -23,7 +23,7 @@ Page scaffolds:
- Same-size cards of icon plus heading plus text as the page structure. Cards are the lazy container; nested cards are always wrong.
- The hero-metric template: big number, small label, supporting stats, accent.
-- A tracked uppercase eyebrow over every section. One named kicker is a system; an eyebrow everywhere is grammar you did not choose.
+- A kicker or eyebrow above a heading. This one is a ban, not a default: no brief earns it back. The heading carries its own weight; delete the label and let the heading speak.
- Section numbers (01 / 02 / 03) unless the sequence itself carries information the reader needs.
- A modal for a task that needs neither interruption nor protected focus.
diff --git a/.claude/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.claude/skills/impeccable/scripts/detector/browser/injected/index.mjs
index e61c536ee..dfc725a8e 100644
--- a/.claude/skills/impeccable/scripts/detector/browser/injected/index.mjs
+++ b/.claude/skills/impeccable/scripts/detector/browser/injected/index.mjs
@@ -1529,7 +1529,7 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, typoFindings);
}
- const sectionKickerFindings = checkRepeatedSectionKickersDOM()
+ const sectionKickerFindings = checkKickerAboveHeadingDOM()
.map(f => ({ type: f.id, detail: f.snippet }))
.filter(f => _ruleOk(f.type));
if (sectionKickerFindings.length > 0) {
diff --git a/.claude/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.claude/skills/impeccable/scripts/detector/detect-antipatterns-browser.js
index a728d4f9e..3d948ad39 100644
--- a/.claude/skills/impeccable/scripts/detector/detect-antipatterns-browser.js
+++ b/.claude/skills/impeccable/scripts/detector/detect-antipatterns-browser.js
@@ -309,15 +309,14 @@ const ANTIPATTERNS = [
skillGuideline: 'tiny uppercase tracked label above the hero headline',
},
{
- id: 'repeated-section-kickers',
+ id: 'kicker-above-heading',
category: 'slop',
scopes: ['type'],
- severity: 'advisory',
- name: 'Repeated section kicker labels',
+ name: 'Kicker / eyebrow label above heading',
description:
- 'Repeating tiny uppercase tracked labels above section headings turns a brand page into AI editorial scaffolding. Replace them with stronger structure, artifacts, imagery, or a deliberate brand system.',
+ 'A tiny tracked uppercase or small-caps label sitting as its own block directly above a heading is banned outright, repeated or not. Generated kickers never earn their place: the heading carries its own weight. Delete the label and let the heading speak; if the words matter, work them into the heading or the body.',
skillSection: 'Typography',
- skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding',
+ skillGuideline: 'kicker or eyebrow labels above headings',
},
{
id: 'numbered-section-labels',
@@ -1258,12 +1257,15 @@ function checkHeroEyebrow(opts) {
}];
}
-function checkRepeatedSectionKickers(opts) {
- const { candidates, minCount = 3 } = opts;
- if (!Array.isArray(candidates) || candidates.length < minCount) return [];
+// Outright ban: one kicker is one too many, so every collected candidate is
+// a finding. The judgment lives in the candidate gate (isKickerCandidate) and
+// the collector's context skips, not in a repetition count.
+function checkKickerAboveHeading(opts) {
+ const { candidates } = opts;
+ if (!Array.isArray(candidates)) return [];
return candidates.map(candidate => ({
- id: 'repeated-section-kickers',
- snippet: `repeated section kicker "${candidate.kickerText}" before ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`,
+ id: 'kicker-above-heading',
+ snippet: `kicker "${candidate.kickerText}" above ${candidate.headingTag} "${candidate.headingText}"`,
}));
}
@@ -3190,7 +3192,7 @@ function parseColorResolved(str, customPropMap) {
return parseAnyColor(resolved);
}
-const REPEATED_KICKER_SKIP_SELECTOR = [
+const KICKER_SKIP_SELECTOR = [
'nav',
'form',
'table',
@@ -3209,7 +3211,7 @@ const REPEATED_KICKER_SKIP_SELECTOR = [
'[data-impeccable-allow-kickers]',
].join(',');
-const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [
+const KICKER_CARD_CONTEXT_SELECTOR = [
'article',
'button',
'a',
@@ -3227,23 +3229,32 @@ function cleanInlineText(el) {
.trim();
}
-function isRepeatedKickerCardContext(heading, kicker) {
- const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR);
+function isKickerCardContext(heading, kicker) {
+ const item = heading.closest?.(KICKER_CARD_CONTEXT_SELECTOR);
return Boolean(item && (!item.contains || item.contains(kicker)));
}
-function isRepeatedKickerCandidate(opts) {
+// Meta lines above headlines join category and date (or path crumbs) with
+// separator glyphs, or carry a year. A kicker is one short phrase; metadata
+// keeps its markers.
+const KICKER_META_TEXT_RE = /[·•|]|\s[\/›»>]\s|\b(19|20)\d{2}\b/;
+// Legal and document numbering: "Section 4.2", "Article IX", "§ 12.3",
+// dotted decimal outlines. The label identifies the clause, so it stays.
+const KICKER_DOC_NUMBERING_RE = /^(§|\d+(\.\d+)+\b|(section|article|clause|appendix|exhibit|schedule|chapter|part|rule|title)\s+([\divxlc]+\b|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)\b)/i;
+
+function isKickerCandidate(opts) {
const {
- headingTag,
+ headingLevel,
headingText,
headingFontSize,
kickerTag,
kickerText,
kickerTextTransform,
+ kickerFontVariant,
kickerFontSize,
kickerLetterSpacing,
} = opts;
- if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
+ if (!headingLevel || headingLevel > 4) return false;
if (!headingText || headingText.length < 3) return false;
if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false;
if (!(headingFontSize >= 20)) return false;
@@ -3251,9 +3262,13 @@ function isRepeatedKickerCandidate(opts) {
if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;
if (!kickerText || kickerText.length < 2 || kickerText.length > 34) return false;
if (/^step\s*\d+/i.test(kickerText) || /^\d{1,2}$/.test(kickerText)) return false;
+ if (KICKER_META_TEXT_RE.test(kickerText)) return false;
+ if (KICKER_DOC_NUMBERING_RE.test(kickerText)) return false;
+ const isSmallCaps = /small-caps/.test(kickerFontVariant || '');
const isUppercased = kickerTextTransform === 'uppercase'
- || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText));
+ || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText))
+ || isSmallCaps;
if (!isUppercased) return false;
if (!(kickerFontSize > 0 && kickerFontSize <= 14)) return false;
const minTrackedSpacing = Math.max(1, kickerFontSize * 0.08);
@@ -3261,37 +3276,64 @@ function isRepeatedKickerCandidate(opts) {
return true;
}
-function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpacing) {
+// Resolve a heading level for the anchor element: 1-4 for h1-h4, aria-level
+// (default 2) for role="heading" elements, 0 otherwise.
+function kickerHeadingLevel(heading) {
+ const tag = heading.tagName.toLowerCase();
+ const byTag = /^h([1-6])$/.exec(tag);
+ if (byTag) return parseInt(byTag[1], 10);
+ const role = heading.getAttribute?.('role') || '';
+ if (role.toLowerCase() !== 'heading') return 0;
+ const ariaLevel = parseInt(heading.getAttribute?.('aria-level') || '', 10);
+ return Number.isFinite(ariaLevel) && ariaLevel >= 1 ? ariaLevel : 2;
+}
+
+function collectKickerCandidates(doc, getStyle, resolveLetterSpacing) {
const candidates = [];
- for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ for (const heading of doc.querySelectorAll('h1, h2, h3, h4, [role="heading"]')) {
+ const headingLevel = kickerHeadingLevel(heading);
+ if (!headingLevel || headingLevel > 4) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ // Application contexts (tab panels, dialogs) use compact context labels
+ // above headings to describe state, not to decorate. Same carve-out the
+ // hero-eyebrow rule makes.
+ if (heading.closest?.('[role="tabpanel"], [role="dialog"], [role="application"], dialog')) continue;
const kicker = heading.previousElementSibling;
- if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
- if (isRepeatedKickerCardContext(heading, kicker)) continue;
+ if (!kicker || kicker.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ if (isKickerCardContext(heading, kicker)) continue;
const headingStyle = getStyle(heading);
const kickerStyle = getStyle(kicker);
+ const headingTag = heading.tagName.toLowerCase();
const headingText = (heading.textContent || '').replace(/\s+/g, ' ').trim();
const kickerText = cleanInlineText(kicker) || (kicker.textContent || '').replace(/\s+/g, ' ').trim();
const headingFontSize = resolveLetterSpacing(headingStyle.fontSize || '', 16) || parseFloat(headingStyle.fontSize) || 0;
const kickerFontSize = resolveLetterSpacing(kickerStyle.fontSize || '', 16) || parseFloat(kickerStyle.fontSize) || 0;
const kickerLetterSpacing = resolveLetterSpacing(kickerStyle.letterSpacing || '', kickerFontSize);
- if (!isRepeatedKickerCandidate({
- headingTag: heading.tagName.toLowerCase(),
+ if (!isKickerCandidate({
+ headingLevel,
headingText,
headingFontSize,
kickerTag: kicker.tagName.toLowerCase(),
kickerText,
kickerTextTransform: kickerStyle.textTransform || '',
+ kickerFontVariant: `${kickerStyle.fontVariant || ''} ${kickerStyle.fontVariantCaps || ''}`,
kickerFontSize,
kickerLetterSpacing,
})) {
continue;
}
+ // A tracked-caps eyebrow above a hero-scale h1 belongs to
+ // hero-eyebrow-chip (which also covers the accent-bold and dash-prefix
+ // stylings there). Stand down so one element gets one finding.
+ if (headingTag === 'h1' && headingFontSize >= 48 && kickerLetterSpacing >= 1.6) {
+ continue;
+ }
+
candidates.push({
- headingTag: heading.tagName.toLowerCase(),
+ headingTag,
headingText: headingText.replace(/^"|"$/g, '').slice(0, 60),
kickerText: kickerText.slice(0, 40),
});
@@ -3299,17 +3341,17 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac
return candidates;
}
-function checkRepeatedSectionKickersDOM() {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingDOM() {
+ const candidates = collectKickerCandidates(
document,
(el) => getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
// ── Numbered section labels ─────────────────────────────────────────────────
-// Sibling of the repeated-kicker rule: instead of a tracked uppercase word,
+// Sibling of the kicker-above-heading rule: instead of a tracked uppercase word,
// the section scaffold is a tiny numeric index riding beside each section
// heading — bare and zero-padded, or an index joined to a short micro-label
// by a separator glyph. The kicker rule deliberately excludes bare 1-2 digit
@@ -3363,7 +3405,7 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
const candidates = [];
const seenLabels = new Set();
for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
// The index sits either directly before the heading, or before the
// wrapper the heading leads (label | …
…).
let label = heading.previousElementSibling;
@@ -3373,9 +3415,9 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
if (firstChild === heading) label = parent.previousElementSibling;
}
if (!label || seenLabels.has(label)) continue;
- if (label.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (label.closest?.(KICKER_SKIP_SELECTOR)) continue;
if (HEADING_TAGS.has(label.tagName.toLowerCase())) continue;
- if (isRepeatedKickerCardContext(heading, label)) continue;
+ if (isKickerCardContext(heading, label)) continue;
const labelText = cleanInlineText(label) || (label.textContent || '').replace(/\s+/g, ' ').trim();
const parsed = parseNumberedLabelText(labelText);
@@ -4548,13 +4590,13 @@ function checkElementHeroEyebrow(el, style, tag, window, customPropMap) {
});
}
-function checkRepeatedSectionKickersFromDoc(doc, win) {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingFromDoc(doc, win) {
+ const candidates = collectKickerCandidates(
doc,
(el) => win.getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
function checkElementMotion(tag, style) {
@@ -7712,7 +7754,7 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, typoFindings);
}
- const sectionKickerFindings = checkRepeatedSectionKickersDOM()
+ const sectionKickerFindings = checkKickerAboveHeadingDOM()
.map(f => ({ type: f.id, detail: f.snippet }))
.filter(f => _ruleOk(f.type));
if (sectionKickerFindings.length > 0) {
diff --git a/.claude/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.claude/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs
index 53d7be48c..939eed779 100644
--- a/.claude/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs
+++ b/.claude/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs
@@ -223,7 +223,7 @@ function unwrapCssAtLayer(source) {
// ---------------------------------------------------------------------------
const STATIC_INHERITED_PROPS = new Set([
- 'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight',
+ 'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight', 'fontVariant',
'lineHeight', 'letterSpacing', 'textTransform', 'textAlign', 'hyphens',
'webkitHyphens',
]);
@@ -252,6 +252,7 @@ const STATIC_DEFAULT_STYLE = {
fontFamily: '',
fontSize: '16px',
fontStyle: 'normal',
+ fontVariant: 'normal',
fontWeight: '400',
lineHeight: 'normal',
letterSpacing: 'normal',
diff --git a/.claude/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.claude/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs
index 1ebb3e427..4e2074042 100644
--- a/.claude/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs
+++ b/.claude/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs
@@ -27,11 +27,11 @@ import {
checkElementRadialSpotlight,
checkCreamPalette,
checkHtmlPatterns,
+ checkKickerAboveHeadingFromDoc,
checkNumberedSectionLabelsFromDoc,
checkPageLayout,
checkPageQualityFromDoc,
checkRepeatedContainerTextFromDoc,
- checkRepeatedSectionKickersFromDoc,
resolveBackground,
resolveBorderRadiusPx,
} from '../../rules/checks.mjs';
@@ -202,7 +202,7 @@ async function detectHtml(filePath, options = {}) {
for (const f of runPageCheck('typography-rules', () => checkStaticPageTypography(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
- for (const f of runPageCheck('repeated-section-kickers', () => checkRepeatedSectionKickersFromDoc(document, window))) {
+ for (const f of runPageCheck('kicker-above-heading', () => checkKickerAboveHeadingFromDoc(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of runPageCheck('numbered-section-labels', () => checkNumberedSectionLabelsFromDoc(document, window))) {
diff --git a/.claude/skills/impeccable/scripts/detector/registry/antipatterns.mjs b/.claude/skills/impeccable/scripts/detector/registry/antipatterns.mjs
index c47d39524..38ce07390 100644
--- a/.claude/skills/impeccable/scripts/detector/registry/antipatterns.mjs
+++ b/.claude/skills/impeccable/scripts/detector/registry/antipatterns.mjs
@@ -198,15 +198,14 @@ const ANTIPATTERNS = [
skillGuideline: 'tiny uppercase tracked label above the hero headline',
},
{
- id: 'repeated-section-kickers',
+ id: 'kicker-above-heading',
category: 'slop',
scopes: ['type'],
- severity: 'advisory',
- name: 'Repeated section kicker labels',
+ name: 'Kicker / eyebrow label above heading',
description:
- 'Repeating tiny uppercase tracked labels above section headings turns a brand page into AI editorial scaffolding. Replace them with stronger structure, artifacts, imagery, or a deliberate brand system.',
+ 'A tiny tracked uppercase or small-caps label sitting as its own block directly above a heading is banned outright, repeated or not. Generated kickers never earn their place: the heading carries its own weight. Delete the label and let the heading speak; if the words matter, work them into the heading or the body.',
skillSection: 'Typography',
- skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding',
+ skillGuideline: 'kicker or eyebrow labels above headings',
},
{
id: 'numbered-section-labels',
diff --git a/.claude/skills/impeccable/scripts/detector/rules/checks.mjs b/.claude/skills/impeccable/scripts/detector/rules/checks.mjs
index 3e810076b..3bf26b9b5 100644
--- a/.claude/skills/impeccable/scripts/detector/rules/checks.mjs
+++ b/.claude/skills/impeccable/scripts/detector/rules/checks.mjs
@@ -456,12 +456,15 @@ function checkHeroEyebrow(opts) {
}];
}
-function checkRepeatedSectionKickers(opts) {
- const { candidates, minCount = 3 } = opts;
- if (!Array.isArray(candidates) || candidates.length < minCount) return [];
+// Outright ban: one kicker is one too many, so every collected candidate is
+// a finding. The judgment lives in the candidate gate (isKickerCandidate) and
+// the collector's context skips, not in a repetition count.
+function checkKickerAboveHeading(opts) {
+ const { candidates } = opts;
+ if (!Array.isArray(candidates)) return [];
return candidates.map(candidate => ({
- id: 'repeated-section-kickers',
- snippet: `repeated section kicker "${candidate.kickerText}" before ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`,
+ id: 'kicker-above-heading',
+ snippet: `kicker "${candidate.kickerText}" above ${candidate.headingTag} "${candidate.headingText}"`,
}));
}
@@ -2388,7 +2391,7 @@ function parseColorResolved(str, customPropMap) {
return parseAnyColor(resolved);
}
-const REPEATED_KICKER_SKIP_SELECTOR = [
+const KICKER_SKIP_SELECTOR = [
'nav',
'form',
'table',
@@ -2407,7 +2410,7 @@ const REPEATED_KICKER_SKIP_SELECTOR = [
'[data-impeccable-allow-kickers]',
].join(',');
-const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [
+const KICKER_CARD_CONTEXT_SELECTOR = [
'article',
'button',
'a',
@@ -2425,23 +2428,32 @@ function cleanInlineText(el) {
.trim();
}
-function isRepeatedKickerCardContext(heading, kicker) {
- const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR);
+function isKickerCardContext(heading, kicker) {
+ const item = heading.closest?.(KICKER_CARD_CONTEXT_SELECTOR);
return Boolean(item && (!item.contains || item.contains(kicker)));
}
-function isRepeatedKickerCandidate(opts) {
+// Meta lines above headlines join category and date (or path crumbs) with
+// separator glyphs, or carry a year. A kicker is one short phrase; metadata
+// keeps its markers.
+const KICKER_META_TEXT_RE = /[·•|]|\s[\/›»>]\s|\b(19|20)\d{2}\b/;
+// Legal and document numbering: "Section 4.2", "Article IX", "§ 12.3",
+// dotted decimal outlines. The label identifies the clause, so it stays.
+const KICKER_DOC_NUMBERING_RE = /^(§|\d+(\.\d+)+\b|(section|article|clause|appendix|exhibit|schedule|chapter|part|rule|title)\s+([\divxlc]+\b|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)\b)/i;
+
+function isKickerCandidate(opts) {
const {
- headingTag,
+ headingLevel,
headingText,
headingFontSize,
kickerTag,
kickerText,
kickerTextTransform,
+ kickerFontVariant,
kickerFontSize,
kickerLetterSpacing,
} = opts;
- if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
+ if (!headingLevel || headingLevel > 4) return false;
if (!headingText || headingText.length < 3) return false;
if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false;
if (!(headingFontSize >= 20)) return false;
@@ -2449,9 +2461,13 @@ function isRepeatedKickerCandidate(opts) {
if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;
if (!kickerText || kickerText.length < 2 || kickerText.length > 34) return false;
if (/^step\s*\d+/i.test(kickerText) || /^\d{1,2}$/.test(kickerText)) return false;
+ if (KICKER_META_TEXT_RE.test(kickerText)) return false;
+ if (KICKER_DOC_NUMBERING_RE.test(kickerText)) return false;
+ const isSmallCaps = /small-caps/.test(kickerFontVariant || '');
const isUppercased = kickerTextTransform === 'uppercase'
- || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText));
+ || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText))
+ || isSmallCaps;
if (!isUppercased) return false;
if (!(kickerFontSize > 0 && kickerFontSize <= 14)) return false;
const minTrackedSpacing = Math.max(1, kickerFontSize * 0.08);
@@ -2459,37 +2475,64 @@ function isRepeatedKickerCandidate(opts) {
return true;
}
-function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpacing) {
+// Resolve a heading level for the anchor element: 1-4 for h1-h4, aria-level
+// (default 2) for role="heading" elements, 0 otherwise.
+function kickerHeadingLevel(heading) {
+ const tag = heading.tagName.toLowerCase();
+ const byTag = /^h([1-6])$/.exec(tag);
+ if (byTag) return parseInt(byTag[1], 10);
+ const role = heading.getAttribute?.('role') || '';
+ if (role.toLowerCase() !== 'heading') return 0;
+ const ariaLevel = parseInt(heading.getAttribute?.('aria-level') || '', 10);
+ return Number.isFinite(ariaLevel) && ariaLevel >= 1 ? ariaLevel : 2;
+}
+
+function collectKickerCandidates(doc, getStyle, resolveLetterSpacing) {
const candidates = [];
- for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ for (const heading of doc.querySelectorAll('h1, h2, h3, h4, [role="heading"]')) {
+ const headingLevel = kickerHeadingLevel(heading);
+ if (!headingLevel || headingLevel > 4) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ // Application contexts (tab panels, dialogs) use compact context labels
+ // above headings to describe state, not to decorate. Same carve-out the
+ // hero-eyebrow rule makes.
+ if (heading.closest?.('[role="tabpanel"], [role="dialog"], [role="application"], dialog')) continue;
const kicker = heading.previousElementSibling;
- if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
- if (isRepeatedKickerCardContext(heading, kicker)) continue;
+ if (!kicker || kicker.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ if (isKickerCardContext(heading, kicker)) continue;
const headingStyle = getStyle(heading);
const kickerStyle = getStyle(kicker);
+ const headingTag = heading.tagName.toLowerCase();
const headingText = (heading.textContent || '').replace(/\s+/g, ' ').trim();
const kickerText = cleanInlineText(kicker) || (kicker.textContent || '').replace(/\s+/g, ' ').trim();
const headingFontSize = resolveLetterSpacing(headingStyle.fontSize || '', 16) || parseFloat(headingStyle.fontSize) || 0;
const kickerFontSize = resolveLetterSpacing(kickerStyle.fontSize || '', 16) || parseFloat(kickerStyle.fontSize) || 0;
const kickerLetterSpacing = resolveLetterSpacing(kickerStyle.letterSpacing || '', kickerFontSize);
- if (!isRepeatedKickerCandidate({
- headingTag: heading.tagName.toLowerCase(),
+ if (!isKickerCandidate({
+ headingLevel,
headingText,
headingFontSize,
kickerTag: kicker.tagName.toLowerCase(),
kickerText,
kickerTextTransform: kickerStyle.textTransform || '',
+ kickerFontVariant: `${kickerStyle.fontVariant || ''} ${kickerStyle.fontVariantCaps || ''}`,
kickerFontSize,
kickerLetterSpacing,
})) {
continue;
}
+ // A tracked-caps eyebrow above a hero-scale h1 belongs to
+ // hero-eyebrow-chip (which also covers the accent-bold and dash-prefix
+ // stylings there). Stand down so one element gets one finding.
+ if (headingTag === 'h1' && headingFontSize >= 48 && kickerLetterSpacing >= 1.6) {
+ continue;
+ }
+
candidates.push({
- headingTag: heading.tagName.toLowerCase(),
+ headingTag,
headingText: headingText.replace(/^"|"$/g, '').slice(0, 60),
kickerText: kickerText.slice(0, 40),
});
@@ -2497,17 +2540,17 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac
return candidates;
}
-function checkRepeatedSectionKickersDOM() {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingDOM() {
+ const candidates = collectKickerCandidates(
document,
(el) => getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
// ── Numbered section labels ─────────────────────────────────────────────────
-// Sibling of the repeated-kicker rule: instead of a tracked uppercase word,
+// Sibling of the kicker-above-heading rule: instead of a tracked uppercase word,
// the section scaffold is a tiny numeric index riding beside each section
// heading — bare and zero-padded, or an index joined to a short micro-label
// by a separator glyph. The kicker rule deliberately excludes bare 1-2 digit
@@ -2561,7 +2604,7 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
const candidates = [];
const seenLabels = new Set();
for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
// The index sits either directly before the heading, or before the
// wrapper the heading leads (label | …
…).
let label = heading.previousElementSibling;
@@ -2571,9 +2614,9 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
if (firstChild === heading) label = parent.previousElementSibling;
}
if (!label || seenLabels.has(label)) continue;
- if (label.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (label.closest?.(KICKER_SKIP_SELECTOR)) continue;
if (HEADING_TAGS.has(label.tagName.toLowerCase())) continue;
- if (isRepeatedKickerCardContext(heading, label)) continue;
+ if (isKickerCardContext(heading, label)) continue;
const labelText = cleanInlineText(label) || (label.textContent || '').replace(/\s+/g, ' ').trim();
const parsed = parseNumberedLabelText(labelText);
@@ -3746,13 +3789,13 @@ function checkElementHeroEyebrow(el, style, tag, window, customPropMap) {
});
}
-function checkRepeatedSectionKickersFromDoc(doc, win) {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingFromDoc(doc, win) {
+ const candidates = collectKickerCandidates(
doc,
(el) => win.getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
function checkElementMotion(tag, style) {
@@ -5393,7 +5436,7 @@ export {
checkItalicSerif,
isAccentColor,
checkHeroEyebrow,
- checkRepeatedSectionKickers,
+ checkKickerAboveHeading,
checkMotion,
checkGlow,
scanCssTextForGlow,
@@ -5424,9 +5467,9 @@ export {
parseAnyColor,
parseColorResolved,
cleanInlineText,
- isRepeatedKickerCandidate,
- collectRepeatedSectionKickerCandidates,
- checkRepeatedSectionKickersDOM,
+ isKickerCandidate,
+ collectKickerCandidates,
+ checkKickerAboveHeadingDOM,
parseNumberedLabelText,
isNumberedSectionLabelCandidate,
collectNumberedSectionLabelCandidates,
@@ -5458,7 +5501,7 @@ export {
checkElementIconTile,
checkElementItalicSerif,
checkElementHeroEyebrow,
- checkRepeatedSectionKickersFromDoc,
+ checkKickerAboveHeadingFromDoc,
checkElementMotion,
checkElementGlow,
checkTypography,
diff --git a/.cursor/skills/impeccable/reference/craft-floor.md b/.cursor/skills/impeccable/reference/craft-floor.md
index 3d0c7a8fb..4ea9b8fa4 100644
--- a/.cursor/skills/impeccable/reference/craft-floor.md
+++ b/.cursor/skills/impeccable/reference/craft-floor.md
@@ -23,7 +23,7 @@ Page scaffolds:
- Same-size cards of icon plus heading plus text as the page structure. Cards are the lazy container; nested cards are always wrong.
- The hero-metric template: big number, small label, supporting stats, accent.
-- A tracked uppercase eyebrow over every section. One named kicker is a system; an eyebrow everywhere is grammar you did not choose.
+- A kicker or eyebrow above a heading. This one is a ban, not a default: no brief earns it back. The heading carries its own weight; delete the label and let the heading speak.
- Section numbers (01 / 02 / 03) unless the sequence itself carries information the reader needs.
- A modal for a task that needs neither interruption nor protected focus.
diff --git a/.cursor/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.cursor/skills/impeccable/scripts/detector/browser/injected/index.mjs
index e61c536ee..dfc725a8e 100644
--- a/.cursor/skills/impeccable/scripts/detector/browser/injected/index.mjs
+++ b/.cursor/skills/impeccable/scripts/detector/browser/injected/index.mjs
@@ -1529,7 +1529,7 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, typoFindings);
}
- const sectionKickerFindings = checkRepeatedSectionKickersDOM()
+ const sectionKickerFindings = checkKickerAboveHeadingDOM()
.map(f => ({ type: f.id, detail: f.snippet }))
.filter(f => _ruleOk(f.type));
if (sectionKickerFindings.length > 0) {
diff --git a/.cursor/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.cursor/skills/impeccable/scripts/detector/detect-antipatterns-browser.js
index a728d4f9e..3d948ad39 100644
--- a/.cursor/skills/impeccable/scripts/detector/detect-antipatterns-browser.js
+++ b/.cursor/skills/impeccable/scripts/detector/detect-antipatterns-browser.js
@@ -309,15 +309,14 @@ const ANTIPATTERNS = [
skillGuideline: 'tiny uppercase tracked label above the hero headline',
},
{
- id: 'repeated-section-kickers',
+ id: 'kicker-above-heading',
category: 'slop',
scopes: ['type'],
- severity: 'advisory',
- name: 'Repeated section kicker labels',
+ name: 'Kicker / eyebrow label above heading',
description:
- 'Repeating tiny uppercase tracked labels above section headings turns a brand page into AI editorial scaffolding. Replace them with stronger structure, artifacts, imagery, or a deliberate brand system.',
+ 'A tiny tracked uppercase or small-caps label sitting as its own block directly above a heading is banned outright, repeated or not. Generated kickers never earn their place: the heading carries its own weight. Delete the label and let the heading speak; if the words matter, work them into the heading or the body.',
skillSection: 'Typography',
- skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding',
+ skillGuideline: 'kicker or eyebrow labels above headings',
},
{
id: 'numbered-section-labels',
@@ -1258,12 +1257,15 @@ function checkHeroEyebrow(opts) {
}];
}
-function checkRepeatedSectionKickers(opts) {
- const { candidates, minCount = 3 } = opts;
- if (!Array.isArray(candidates) || candidates.length < minCount) return [];
+// Outright ban: one kicker is one too many, so every collected candidate is
+// a finding. The judgment lives in the candidate gate (isKickerCandidate) and
+// the collector's context skips, not in a repetition count.
+function checkKickerAboveHeading(opts) {
+ const { candidates } = opts;
+ if (!Array.isArray(candidates)) return [];
return candidates.map(candidate => ({
- id: 'repeated-section-kickers',
- snippet: `repeated section kicker "${candidate.kickerText}" before ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`,
+ id: 'kicker-above-heading',
+ snippet: `kicker "${candidate.kickerText}" above ${candidate.headingTag} "${candidate.headingText}"`,
}));
}
@@ -3190,7 +3192,7 @@ function parseColorResolved(str, customPropMap) {
return parseAnyColor(resolved);
}
-const REPEATED_KICKER_SKIP_SELECTOR = [
+const KICKER_SKIP_SELECTOR = [
'nav',
'form',
'table',
@@ -3209,7 +3211,7 @@ const REPEATED_KICKER_SKIP_SELECTOR = [
'[data-impeccable-allow-kickers]',
].join(',');
-const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [
+const KICKER_CARD_CONTEXT_SELECTOR = [
'article',
'button',
'a',
@@ -3227,23 +3229,32 @@ function cleanInlineText(el) {
.trim();
}
-function isRepeatedKickerCardContext(heading, kicker) {
- const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR);
+function isKickerCardContext(heading, kicker) {
+ const item = heading.closest?.(KICKER_CARD_CONTEXT_SELECTOR);
return Boolean(item && (!item.contains || item.contains(kicker)));
}
-function isRepeatedKickerCandidate(opts) {
+// Meta lines above headlines join category and date (or path crumbs) with
+// separator glyphs, or carry a year. A kicker is one short phrase; metadata
+// keeps its markers.
+const KICKER_META_TEXT_RE = /[·•|]|\s[\/›»>]\s|\b(19|20)\d{2}\b/;
+// Legal and document numbering: "Section 4.2", "Article IX", "§ 12.3",
+// dotted decimal outlines. The label identifies the clause, so it stays.
+const KICKER_DOC_NUMBERING_RE = /^(§|\d+(\.\d+)+\b|(section|article|clause|appendix|exhibit|schedule|chapter|part|rule|title)\s+([\divxlc]+\b|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)\b)/i;
+
+function isKickerCandidate(opts) {
const {
- headingTag,
+ headingLevel,
headingText,
headingFontSize,
kickerTag,
kickerText,
kickerTextTransform,
+ kickerFontVariant,
kickerFontSize,
kickerLetterSpacing,
} = opts;
- if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
+ if (!headingLevel || headingLevel > 4) return false;
if (!headingText || headingText.length < 3) return false;
if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false;
if (!(headingFontSize >= 20)) return false;
@@ -3251,9 +3262,13 @@ function isRepeatedKickerCandidate(opts) {
if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;
if (!kickerText || kickerText.length < 2 || kickerText.length > 34) return false;
if (/^step\s*\d+/i.test(kickerText) || /^\d{1,2}$/.test(kickerText)) return false;
+ if (KICKER_META_TEXT_RE.test(kickerText)) return false;
+ if (KICKER_DOC_NUMBERING_RE.test(kickerText)) return false;
+ const isSmallCaps = /small-caps/.test(kickerFontVariant || '');
const isUppercased = kickerTextTransform === 'uppercase'
- || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText));
+ || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText))
+ || isSmallCaps;
if (!isUppercased) return false;
if (!(kickerFontSize > 0 && kickerFontSize <= 14)) return false;
const minTrackedSpacing = Math.max(1, kickerFontSize * 0.08);
@@ -3261,37 +3276,64 @@ function isRepeatedKickerCandidate(opts) {
return true;
}
-function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpacing) {
+// Resolve a heading level for the anchor element: 1-4 for h1-h4, aria-level
+// (default 2) for role="heading" elements, 0 otherwise.
+function kickerHeadingLevel(heading) {
+ const tag = heading.tagName.toLowerCase();
+ const byTag = /^h([1-6])$/.exec(tag);
+ if (byTag) return parseInt(byTag[1], 10);
+ const role = heading.getAttribute?.('role') || '';
+ if (role.toLowerCase() !== 'heading') return 0;
+ const ariaLevel = parseInt(heading.getAttribute?.('aria-level') || '', 10);
+ return Number.isFinite(ariaLevel) && ariaLevel >= 1 ? ariaLevel : 2;
+}
+
+function collectKickerCandidates(doc, getStyle, resolveLetterSpacing) {
const candidates = [];
- for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ for (const heading of doc.querySelectorAll('h1, h2, h3, h4, [role="heading"]')) {
+ const headingLevel = kickerHeadingLevel(heading);
+ if (!headingLevel || headingLevel > 4) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ // Application contexts (tab panels, dialogs) use compact context labels
+ // above headings to describe state, not to decorate. Same carve-out the
+ // hero-eyebrow rule makes.
+ if (heading.closest?.('[role="tabpanel"], [role="dialog"], [role="application"], dialog')) continue;
const kicker = heading.previousElementSibling;
- if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
- if (isRepeatedKickerCardContext(heading, kicker)) continue;
+ if (!kicker || kicker.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ if (isKickerCardContext(heading, kicker)) continue;
const headingStyle = getStyle(heading);
const kickerStyle = getStyle(kicker);
+ const headingTag = heading.tagName.toLowerCase();
const headingText = (heading.textContent || '').replace(/\s+/g, ' ').trim();
const kickerText = cleanInlineText(kicker) || (kicker.textContent || '').replace(/\s+/g, ' ').trim();
const headingFontSize = resolveLetterSpacing(headingStyle.fontSize || '', 16) || parseFloat(headingStyle.fontSize) || 0;
const kickerFontSize = resolveLetterSpacing(kickerStyle.fontSize || '', 16) || parseFloat(kickerStyle.fontSize) || 0;
const kickerLetterSpacing = resolveLetterSpacing(kickerStyle.letterSpacing || '', kickerFontSize);
- if (!isRepeatedKickerCandidate({
- headingTag: heading.tagName.toLowerCase(),
+ if (!isKickerCandidate({
+ headingLevel,
headingText,
headingFontSize,
kickerTag: kicker.tagName.toLowerCase(),
kickerText,
kickerTextTransform: kickerStyle.textTransform || '',
+ kickerFontVariant: `${kickerStyle.fontVariant || ''} ${kickerStyle.fontVariantCaps || ''}`,
kickerFontSize,
kickerLetterSpacing,
})) {
continue;
}
+ // A tracked-caps eyebrow above a hero-scale h1 belongs to
+ // hero-eyebrow-chip (which also covers the accent-bold and dash-prefix
+ // stylings there). Stand down so one element gets one finding.
+ if (headingTag === 'h1' && headingFontSize >= 48 && kickerLetterSpacing >= 1.6) {
+ continue;
+ }
+
candidates.push({
- headingTag: heading.tagName.toLowerCase(),
+ headingTag,
headingText: headingText.replace(/^"|"$/g, '').slice(0, 60),
kickerText: kickerText.slice(0, 40),
});
@@ -3299,17 +3341,17 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac
return candidates;
}
-function checkRepeatedSectionKickersDOM() {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingDOM() {
+ const candidates = collectKickerCandidates(
document,
(el) => getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
// ── Numbered section labels ─────────────────────────────────────────────────
-// Sibling of the repeated-kicker rule: instead of a tracked uppercase word,
+// Sibling of the kicker-above-heading rule: instead of a tracked uppercase word,
// the section scaffold is a tiny numeric index riding beside each section
// heading — bare and zero-padded, or an index joined to a short micro-label
// by a separator glyph. The kicker rule deliberately excludes bare 1-2 digit
@@ -3363,7 +3405,7 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
const candidates = [];
const seenLabels = new Set();
for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
// The index sits either directly before the heading, or before the
// wrapper the heading leads (label | …
…).
let label = heading.previousElementSibling;
@@ -3373,9 +3415,9 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
if (firstChild === heading) label = parent.previousElementSibling;
}
if (!label || seenLabels.has(label)) continue;
- if (label.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (label.closest?.(KICKER_SKIP_SELECTOR)) continue;
if (HEADING_TAGS.has(label.tagName.toLowerCase())) continue;
- if (isRepeatedKickerCardContext(heading, label)) continue;
+ if (isKickerCardContext(heading, label)) continue;
const labelText = cleanInlineText(label) || (label.textContent || '').replace(/\s+/g, ' ').trim();
const parsed = parseNumberedLabelText(labelText);
@@ -4548,13 +4590,13 @@ function checkElementHeroEyebrow(el, style, tag, window, customPropMap) {
});
}
-function checkRepeatedSectionKickersFromDoc(doc, win) {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingFromDoc(doc, win) {
+ const candidates = collectKickerCandidates(
doc,
(el) => win.getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
function checkElementMotion(tag, style) {
@@ -7712,7 +7754,7 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, typoFindings);
}
- const sectionKickerFindings = checkRepeatedSectionKickersDOM()
+ const sectionKickerFindings = checkKickerAboveHeadingDOM()
.map(f => ({ type: f.id, detail: f.snippet }))
.filter(f => _ruleOk(f.type));
if (sectionKickerFindings.length > 0) {
diff --git a/.cursor/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.cursor/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs
index 53d7be48c..939eed779 100644
--- a/.cursor/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs
+++ b/.cursor/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs
@@ -223,7 +223,7 @@ function unwrapCssAtLayer(source) {
// ---------------------------------------------------------------------------
const STATIC_INHERITED_PROPS = new Set([
- 'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight',
+ 'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight', 'fontVariant',
'lineHeight', 'letterSpacing', 'textTransform', 'textAlign', 'hyphens',
'webkitHyphens',
]);
@@ -252,6 +252,7 @@ const STATIC_DEFAULT_STYLE = {
fontFamily: '',
fontSize: '16px',
fontStyle: 'normal',
+ fontVariant: 'normal',
fontWeight: '400',
lineHeight: 'normal',
letterSpacing: 'normal',
diff --git a/.cursor/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.cursor/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs
index 1ebb3e427..4e2074042 100644
--- a/.cursor/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs
+++ b/.cursor/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs
@@ -27,11 +27,11 @@ import {
checkElementRadialSpotlight,
checkCreamPalette,
checkHtmlPatterns,
+ checkKickerAboveHeadingFromDoc,
checkNumberedSectionLabelsFromDoc,
checkPageLayout,
checkPageQualityFromDoc,
checkRepeatedContainerTextFromDoc,
- checkRepeatedSectionKickersFromDoc,
resolveBackground,
resolveBorderRadiusPx,
} from '../../rules/checks.mjs';
@@ -202,7 +202,7 @@ async function detectHtml(filePath, options = {}) {
for (const f of runPageCheck('typography-rules', () => checkStaticPageTypography(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
- for (const f of runPageCheck('repeated-section-kickers', () => checkRepeatedSectionKickersFromDoc(document, window))) {
+ for (const f of runPageCheck('kicker-above-heading', () => checkKickerAboveHeadingFromDoc(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of runPageCheck('numbered-section-labels', () => checkNumberedSectionLabelsFromDoc(document, window))) {
diff --git a/.cursor/skills/impeccable/scripts/detector/registry/antipatterns.mjs b/.cursor/skills/impeccable/scripts/detector/registry/antipatterns.mjs
index c47d39524..38ce07390 100644
--- a/.cursor/skills/impeccable/scripts/detector/registry/antipatterns.mjs
+++ b/.cursor/skills/impeccable/scripts/detector/registry/antipatterns.mjs
@@ -198,15 +198,14 @@ const ANTIPATTERNS = [
skillGuideline: 'tiny uppercase tracked label above the hero headline',
},
{
- id: 'repeated-section-kickers',
+ id: 'kicker-above-heading',
category: 'slop',
scopes: ['type'],
- severity: 'advisory',
- name: 'Repeated section kicker labels',
+ name: 'Kicker / eyebrow label above heading',
description:
- 'Repeating tiny uppercase tracked labels above section headings turns a brand page into AI editorial scaffolding. Replace them with stronger structure, artifacts, imagery, or a deliberate brand system.',
+ 'A tiny tracked uppercase or small-caps label sitting as its own block directly above a heading is banned outright, repeated or not. Generated kickers never earn their place: the heading carries its own weight. Delete the label and let the heading speak; if the words matter, work them into the heading or the body.',
skillSection: 'Typography',
- skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding',
+ skillGuideline: 'kicker or eyebrow labels above headings',
},
{
id: 'numbered-section-labels',
diff --git a/.cursor/skills/impeccable/scripts/detector/rules/checks.mjs b/.cursor/skills/impeccable/scripts/detector/rules/checks.mjs
index 3e810076b..3bf26b9b5 100644
--- a/.cursor/skills/impeccable/scripts/detector/rules/checks.mjs
+++ b/.cursor/skills/impeccable/scripts/detector/rules/checks.mjs
@@ -456,12 +456,15 @@ function checkHeroEyebrow(opts) {
}];
}
-function checkRepeatedSectionKickers(opts) {
- const { candidates, minCount = 3 } = opts;
- if (!Array.isArray(candidates) || candidates.length < minCount) return [];
+// Outright ban: one kicker is one too many, so every collected candidate is
+// a finding. The judgment lives in the candidate gate (isKickerCandidate) and
+// the collector's context skips, not in a repetition count.
+function checkKickerAboveHeading(opts) {
+ const { candidates } = opts;
+ if (!Array.isArray(candidates)) return [];
return candidates.map(candidate => ({
- id: 'repeated-section-kickers',
- snippet: `repeated section kicker "${candidate.kickerText}" before ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`,
+ id: 'kicker-above-heading',
+ snippet: `kicker "${candidate.kickerText}" above ${candidate.headingTag} "${candidate.headingText}"`,
}));
}
@@ -2388,7 +2391,7 @@ function parseColorResolved(str, customPropMap) {
return parseAnyColor(resolved);
}
-const REPEATED_KICKER_SKIP_SELECTOR = [
+const KICKER_SKIP_SELECTOR = [
'nav',
'form',
'table',
@@ -2407,7 +2410,7 @@ const REPEATED_KICKER_SKIP_SELECTOR = [
'[data-impeccable-allow-kickers]',
].join(',');
-const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [
+const KICKER_CARD_CONTEXT_SELECTOR = [
'article',
'button',
'a',
@@ -2425,23 +2428,32 @@ function cleanInlineText(el) {
.trim();
}
-function isRepeatedKickerCardContext(heading, kicker) {
- const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR);
+function isKickerCardContext(heading, kicker) {
+ const item = heading.closest?.(KICKER_CARD_CONTEXT_SELECTOR);
return Boolean(item && (!item.contains || item.contains(kicker)));
}
-function isRepeatedKickerCandidate(opts) {
+// Meta lines above headlines join category and date (or path crumbs) with
+// separator glyphs, or carry a year. A kicker is one short phrase; metadata
+// keeps its markers.
+const KICKER_META_TEXT_RE = /[·•|]|\s[\/›»>]\s|\b(19|20)\d{2}\b/;
+// Legal and document numbering: "Section 4.2", "Article IX", "§ 12.3",
+// dotted decimal outlines. The label identifies the clause, so it stays.
+const KICKER_DOC_NUMBERING_RE = /^(§|\d+(\.\d+)+\b|(section|article|clause|appendix|exhibit|schedule|chapter|part|rule|title)\s+([\divxlc]+\b|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)\b)/i;
+
+function isKickerCandidate(opts) {
const {
- headingTag,
+ headingLevel,
headingText,
headingFontSize,
kickerTag,
kickerText,
kickerTextTransform,
+ kickerFontVariant,
kickerFontSize,
kickerLetterSpacing,
} = opts;
- if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
+ if (!headingLevel || headingLevel > 4) return false;
if (!headingText || headingText.length < 3) return false;
if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false;
if (!(headingFontSize >= 20)) return false;
@@ -2449,9 +2461,13 @@ function isRepeatedKickerCandidate(opts) {
if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;
if (!kickerText || kickerText.length < 2 || kickerText.length > 34) return false;
if (/^step\s*\d+/i.test(kickerText) || /^\d{1,2}$/.test(kickerText)) return false;
+ if (KICKER_META_TEXT_RE.test(kickerText)) return false;
+ if (KICKER_DOC_NUMBERING_RE.test(kickerText)) return false;
+ const isSmallCaps = /small-caps/.test(kickerFontVariant || '');
const isUppercased = kickerTextTransform === 'uppercase'
- || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText));
+ || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText))
+ || isSmallCaps;
if (!isUppercased) return false;
if (!(kickerFontSize > 0 && kickerFontSize <= 14)) return false;
const minTrackedSpacing = Math.max(1, kickerFontSize * 0.08);
@@ -2459,37 +2475,64 @@ function isRepeatedKickerCandidate(opts) {
return true;
}
-function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpacing) {
+// Resolve a heading level for the anchor element: 1-4 for h1-h4, aria-level
+// (default 2) for role="heading" elements, 0 otherwise.
+function kickerHeadingLevel(heading) {
+ const tag = heading.tagName.toLowerCase();
+ const byTag = /^h([1-6])$/.exec(tag);
+ if (byTag) return parseInt(byTag[1], 10);
+ const role = heading.getAttribute?.('role') || '';
+ if (role.toLowerCase() !== 'heading') return 0;
+ const ariaLevel = parseInt(heading.getAttribute?.('aria-level') || '', 10);
+ return Number.isFinite(ariaLevel) && ariaLevel >= 1 ? ariaLevel : 2;
+}
+
+function collectKickerCandidates(doc, getStyle, resolveLetterSpacing) {
const candidates = [];
- for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ for (const heading of doc.querySelectorAll('h1, h2, h3, h4, [role="heading"]')) {
+ const headingLevel = kickerHeadingLevel(heading);
+ if (!headingLevel || headingLevel > 4) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ // Application contexts (tab panels, dialogs) use compact context labels
+ // above headings to describe state, not to decorate. Same carve-out the
+ // hero-eyebrow rule makes.
+ if (heading.closest?.('[role="tabpanel"], [role="dialog"], [role="application"], dialog')) continue;
const kicker = heading.previousElementSibling;
- if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
- if (isRepeatedKickerCardContext(heading, kicker)) continue;
+ if (!kicker || kicker.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ if (isKickerCardContext(heading, kicker)) continue;
const headingStyle = getStyle(heading);
const kickerStyle = getStyle(kicker);
+ const headingTag = heading.tagName.toLowerCase();
const headingText = (heading.textContent || '').replace(/\s+/g, ' ').trim();
const kickerText = cleanInlineText(kicker) || (kicker.textContent || '').replace(/\s+/g, ' ').trim();
const headingFontSize = resolveLetterSpacing(headingStyle.fontSize || '', 16) || parseFloat(headingStyle.fontSize) || 0;
const kickerFontSize = resolveLetterSpacing(kickerStyle.fontSize || '', 16) || parseFloat(kickerStyle.fontSize) || 0;
const kickerLetterSpacing = resolveLetterSpacing(kickerStyle.letterSpacing || '', kickerFontSize);
- if (!isRepeatedKickerCandidate({
- headingTag: heading.tagName.toLowerCase(),
+ if (!isKickerCandidate({
+ headingLevel,
headingText,
headingFontSize,
kickerTag: kicker.tagName.toLowerCase(),
kickerText,
kickerTextTransform: kickerStyle.textTransform || '',
+ kickerFontVariant: `${kickerStyle.fontVariant || ''} ${kickerStyle.fontVariantCaps || ''}`,
kickerFontSize,
kickerLetterSpacing,
})) {
continue;
}
+ // A tracked-caps eyebrow above a hero-scale h1 belongs to
+ // hero-eyebrow-chip (which also covers the accent-bold and dash-prefix
+ // stylings there). Stand down so one element gets one finding.
+ if (headingTag === 'h1' && headingFontSize >= 48 && kickerLetterSpacing >= 1.6) {
+ continue;
+ }
+
candidates.push({
- headingTag: heading.tagName.toLowerCase(),
+ headingTag,
headingText: headingText.replace(/^"|"$/g, '').slice(0, 60),
kickerText: kickerText.slice(0, 40),
});
@@ -2497,17 +2540,17 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac
return candidates;
}
-function checkRepeatedSectionKickersDOM() {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingDOM() {
+ const candidates = collectKickerCandidates(
document,
(el) => getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
// ── Numbered section labels ─────────────────────────────────────────────────
-// Sibling of the repeated-kicker rule: instead of a tracked uppercase word,
+// Sibling of the kicker-above-heading rule: instead of a tracked uppercase word,
// the section scaffold is a tiny numeric index riding beside each section
// heading — bare and zero-padded, or an index joined to a short micro-label
// by a separator glyph. The kicker rule deliberately excludes bare 1-2 digit
@@ -2561,7 +2604,7 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
const candidates = [];
const seenLabels = new Set();
for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
// The index sits either directly before the heading, or before the
// wrapper the heading leads (label | …
…).
let label = heading.previousElementSibling;
@@ -2571,9 +2614,9 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
if (firstChild === heading) label = parent.previousElementSibling;
}
if (!label || seenLabels.has(label)) continue;
- if (label.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (label.closest?.(KICKER_SKIP_SELECTOR)) continue;
if (HEADING_TAGS.has(label.tagName.toLowerCase())) continue;
- if (isRepeatedKickerCardContext(heading, label)) continue;
+ if (isKickerCardContext(heading, label)) continue;
const labelText = cleanInlineText(label) || (label.textContent || '').replace(/\s+/g, ' ').trim();
const parsed = parseNumberedLabelText(labelText);
@@ -3746,13 +3789,13 @@ function checkElementHeroEyebrow(el, style, tag, window, customPropMap) {
});
}
-function checkRepeatedSectionKickersFromDoc(doc, win) {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingFromDoc(doc, win) {
+ const candidates = collectKickerCandidates(
doc,
(el) => win.getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
function checkElementMotion(tag, style) {
@@ -5393,7 +5436,7 @@ export {
checkItalicSerif,
isAccentColor,
checkHeroEyebrow,
- checkRepeatedSectionKickers,
+ checkKickerAboveHeading,
checkMotion,
checkGlow,
scanCssTextForGlow,
@@ -5424,9 +5467,9 @@ export {
parseAnyColor,
parseColorResolved,
cleanInlineText,
- isRepeatedKickerCandidate,
- collectRepeatedSectionKickerCandidates,
- checkRepeatedSectionKickersDOM,
+ isKickerCandidate,
+ collectKickerCandidates,
+ checkKickerAboveHeadingDOM,
parseNumberedLabelText,
isNumberedSectionLabelCandidate,
collectNumberedSectionLabelCandidates,
@@ -5458,7 +5501,7 @@ export {
checkElementIconTile,
checkElementItalicSerif,
checkElementHeroEyebrow,
- checkRepeatedSectionKickersFromDoc,
+ checkKickerAboveHeadingFromDoc,
checkElementMotion,
checkElementGlow,
checkTypography,
diff --git a/.gemini/skills/impeccable/reference/craft-floor.md b/.gemini/skills/impeccable/reference/craft-floor.md
index e3adc3a83..34c13f18c 100644
--- a/.gemini/skills/impeccable/reference/craft-floor.md
+++ b/.gemini/skills/impeccable/reference/craft-floor.md
@@ -23,7 +23,7 @@ Page scaffolds:
- Same-size cards of icon plus heading plus text as the page structure. Cards are the lazy container; nested cards are always wrong.
- The hero-metric template: big number, small label, supporting stats, accent.
-- A tracked uppercase eyebrow over every section. One named kicker is a system; an eyebrow everywhere is grammar you did not choose.
+- A kicker or eyebrow above a heading. This one is a ban, not a default: no brief earns it back. The heading carries its own weight; delete the label and let the heading speak.
- Section numbers (01 / 02 / 03) unless the sequence itself carries information the reader needs.
- A modal for a task that needs neither interruption nor protected focus.
diff --git a/.gemini/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.gemini/skills/impeccable/scripts/detector/browser/injected/index.mjs
index e61c536ee..dfc725a8e 100644
--- a/.gemini/skills/impeccable/scripts/detector/browser/injected/index.mjs
+++ b/.gemini/skills/impeccable/scripts/detector/browser/injected/index.mjs
@@ -1529,7 +1529,7 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, typoFindings);
}
- const sectionKickerFindings = checkRepeatedSectionKickersDOM()
+ const sectionKickerFindings = checkKickerAboveHeadingDOM()
.map(f => ({ type: f.id, detail: f.snippet }))
.filter(f => _ruleOk(f.type));
if (sectionKickerFindings.length > 0) {
diff --git a/.gemini/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.gemini/skills/impeccable/scripts/detector/detect-antipatterns-browser.js
index a728d4f9e..3d948ad39 100644
--- a/.gemini/skills/impeccable/scripts/detector/detect-antipatterns-browser.js
+++ b/.gemini/skills/impeccable/scripts/detector/detect-antipatterns-browser.js
@@ -309,15 +309,14 @@ const ANTIPATTERNS = [
skillGuideline: 'tiny uppercase tracked label above the hero headline',
},
{
- id: 'repeated-section-kickers',
+ id: 'kicker-above-heading',
category: 'slop',
scopes: ['type'],
- severity: 'advisory',
- name: 'Repeated section kicker labels',
+ name: 'Kicker / eyebrow label above heading',
description:
- 'Repeating tiny uppercase tracked labels above section headings turns a brand page into AI editorial scaffolding. Replace them with stronger structure, artifacts, imagery, or a deliberate brand system.',
+ 'A tiny tracked uppercase or small-caps label sitting as its own block directly above a heading is banned outright, repeated or not. Generated kickers never earn their place: the heading carries its own weight. Delete the label and let the heading speak; if the words matter, work them into the heading or the body.',
skillSection: 'Typography',
- skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding',
+ skillGuideline: 'kicker or eyebrow labels above headings',
},
{
id: 'numbered-section-labels',
@@ -1258,12 +1257,15 @@ function checkHeroEyebrow(opts) {
}];
}
-function checkRepeatedSectionKickers(opts) {
- const { candidates, minCount = 3 } = opts;
- if (!Array.isArray(candidates) || candidates.length < minCount) return [];
+// Outright ban: one kicker is one too many, so every collected candidate is
+// a finding. The judgment lives in the candidate gate (isKickerCandidate) and
+// the collector's context skips, not in a repetition count.
+function checkKickerAboveHeading(opts) {
+ const { candidates } = opts;
+ if (!Array.isArray(candidates)) return [];
return candidates.map(candidate => ({
- id: 'repeated-section-kickers',
- snippet: `repeated section kicker "${candidate.kickerText}" before ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`,
+ id: 'kicker-above-heading',
+ snippet: `kicker "${candidate.kickerText}" above ${candidate.headingTag} "${candidate.headingText}"`,
}));
}
@@ -3190,7 +3192,7 @@ function parseColorResolved(str, customPropMap) {
return parseAnyColor(resolved);
}
-const REPEATED_KICKER_SKIP_SELECTOR = [
+const KICKER_SKIP_SELECTOR = [
'nav',
'form',
'table',
@@ -3209,7 +3211,7 @@ const REPEATED_KICKER_SKIP_SELECTOR = [
'[data-impeccable-allow-kickers]',
].join(',');
-const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [
+const KICKER_CARD_CONTEXT_SELECTOR = [
'article',
'button',
'a',
@@ -3227,23 +3229,32 @@ function cleanInlineText(el) {
.trim();
}
-function isRepeatedKickerCardContext(heading, kicker) {
- const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR);
+function isKickerCardContext(heading, kicker) {
+ const item = heading.closest?.(KICKER_CARD_CONTEXT_SELECTOR);
return Boolean(item && (!item.contains || item.contains(kicker)));
}
-function isRepeatedKickerCandidate(opts) {
+// Meta lines above headlines join category and date (or path crumbs) with
+// separator glyphs, or carry a year. A kicker is one short phrase; metadata
+// keeps its markers.
+const KICKER_META_TEXT_RE = /[·•|]|\s[\/›»>]\s|\b(19|20)\d{2}\b/;
+// Legal and document numbering: "Section 4.2", "Article IX", "§ 12.3",
+// dotted decimal outlines. The label identifies the clause, so it stays.
+const KICKER_DOC_NUMBERING_RE = /^(§|\d+(\.\d+)+\b|(section|article|clause|appendix|exhibit|schedule|chapter|part|rule|title)\s+([\divxlc]+\b|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)\b)/i;
+
+function isKickerCandidate(opts) {
const {
- headingTag,
+ headingLevel,
headingText,
headingFontSize,
kickerTag,
kickerText,
kickerTextTransform,
+ kickerFontVariant,
kickerFontSize,
kickerLetterSpacing,
} = opts;
- if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
+ if (!headingLevel || headingLevel > 4) return false;
if (!headingText || headingText.length < 3) return false;
if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false;
if (!(headingFontSize >= 20)) return false;
@@ -3251,9 +3262,13 @@ function isRepeatedKickerCandidate(opts) {
if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;
if (!kickerText || kickerText.length < 2 || kickerText.length > 34) return false;
if (/^step\s*\d+/i.test(kickerText) || /^\d{1,2}$/.test(kickerText)) return false;
+ if (KICKER_META_TEXT_RE.test(kickerText)) return false;
+ if (KICKER_DOC_NUMBERING_RE.test(kickerText)) return false;
+ const isSmallCaps = /small-caps/.test(kickerFontVariant || '');
const isUppercased = kickerTextTransform === 'uppercase'
- || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText));
+ || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText))
+ || isSmallCaps;
if (!isUppercased) return false;
if (!(kickerFontSize > 0 && kickerFontSize <= 14)) return false;
const minTrackedSpacing = Math.max(1, kickerFontSize * 0.08);
@@ -3261,37 +3276,64 @@ function isRepeatedKickerCandidate(opts) {
return true;
}
-function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpacing) {
+// Resolve a heading level for the anchor element: 1-4 for h1-h4, aria-level
+// (default 2) for role="heading" elements, 0 otherwise.
+function kickerHeadingLevel(heading) {
+ const tag = heading.tagName.toLowerCase();
+ const byTag = /^h([1-6])$/.exec(tag);
+ if (byTag) return parseInt(byTag[1], 10);
+ const role = heading.getAttribute?.('role') || '';
+ if (role.toLowerCase() !== 'heading') return 0;
+ const ariaLevel = parseInt(heading.getAttribute?.('aria-level') || '', 10);
+ return Number.isFinite(ariaLevel) && ariaLevel >= 1 ? ariaLevel : 2;
+}
+
+function collectKickerCandidates(doc, getStyle, resolveLetterSpacing) {
const candidates = [];
- for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ for (const heading of doc.querySelectorAll('h1, h2, h3, h4, [role="heading"]')) {
+ const headingLevel = kickerHeadingLevel(heading);
+ if (!headingLevel || headingLevel > 4) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ // Application contexts (tab panels, dialogs) use compact context labels
+ // above headings to describe state, not to decorate. Same carve-out the
+ // hero-eyebrow rule makes.
+ if (heading.closest?.('[role="tabpanel"], [role="dialog"], [role="application"], dialog')) continue;
const kicker = heading.previousElementSibling;
- if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
- if (isRepeatedKickerCardContext(heading, kicker)) continue;
+ if (!kicker || kicker.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ if (isKickerCardContext(heading, kicker)) continue;
const headingStyle = getStyle(heading);
const kickerStyle = getStyle(kicker);
+ const headingTag = heading.tagName.toLowerCase();
const headingText = (heading.textContent || '').replace(/\s+/g, ' ').trim();
const kickerText = cleanInlineText(kicker) || (kicker.textContent || '').replace(/\s+/g, ' ').trim();
const headingFontSize = resolveLetterSpacing(headingStyle.fontSize || '', 16) || parseFloat(headingStyle.fontSize) || 0;
const kickerFontSize = resolveLetterSpacing(kickerStyle.fontSize || '', 16) || parseFloat(kickerStyle.fontSize) || 0;
const kickerLetterSpacing = resolveLetterSpacing(kickerStyle.letterSpacing || '', kickerFontSize);
- if (!isRepeatedKickerCandidate({
- headingTag: heading.tagName.toLowerCase(),
+ if (!isKickerCandidate({
+ headingLevel,
headingText,
headingFontSize,
kickerTag: kicker.tagName.toLowerCase(),
kickerText,
kickerTextTransform: kickerStyle.textTransform || '',
+ kickerFontVariant: `${kickerStyle.fontVariant || ''} ${kickerStyle.fontVariantCaps || ''}`,
kickerFontSize,
kickerLetterSpacing,
})) {
continue;
}
+ // A tracked-caps eyebrow above a hero-scale h1 belongs to
+ // hero-eyebrow-chip (which also covers the accent-bold and dash-prefix
+ // stylings there). Stand down so one element gets one finding.
+ if (headingTag === 'h1' && headingFontSize >= 48 && kickerLetterSpacing >= 1.6) {
+ continue;
+ }
+
candidates.push({
- headingTag: heading.tagName.toLowerCase(),
+ headingTag,
headingText: headingText.replace(/^"|"$/g, '').slice(0, 60),
kickerText: kickerText.slice(0, 40),
});
@@ -3299,17 +3341,17 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac
return candidates;
}
-function checkRepeatedSectionKickersDOM() {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingDOM() {
+ const candidates = collectKickerCandidates(
document,
(el) => getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
// ── Numbered section labels ─────────────────────────────────────────────────
-// Sibling of the repeated-kicker rule: instead of a tracked uppercase word,
+// Sibling of the kicker-above-heading rule: instead of a tracked uppercase word,
// the section scaffold is a tiny numeric index riding beside each section
// heading — bare and zero-padded, or an index joined to a short micro-label
// by a separator glyph. The kicker rule deliberately excludes bare 1-2 digit
@@ -3363,7 +3405,7 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
const candidates = [];
const seenLabels = new Set();
for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
// The index sits either directly before the heading, or before the
// wrapper the heading leads (label | …
…).
let label = heading.previousElementSibling;
@@ -3373,9 +3415,9 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
if (firstChild === heading) label = parent.previousElementSibling;
}
if (!label || seenLabels.has(label)) continue;
- if (label.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (label.closest?.(KICKER_SKIP_SELECTOR)) continue;
if (HEADING_TAGS.has(label.tagName.toLowerCase())) continue;
- if (isRepeatedKickerCardContext(heading, label)) continue;
+ if (isKickerCardContext(heading, label)) continue;
const labelText = cleanInlineText(label) || (label.textContent || '').replace(/\s+/g, ' ').trim();
const parsed = parseNumberedLabelText(labelText);
@@ -4548,13 +4590,13 @@ function checkElementHeroEyebrow(el, style, tag, window, customPropMap) {
});
}
-function checkRepeatedSectionKickersFromDoc(doc, win) {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingFromDoc(doc, win) {
+ const candidates = collectKickerCandidates(
doc,
(el) => win.getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
function checkElementMotion(tag, style) {
@@ -7712,7 +7754,7 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, typoFindings);
}
- const sectionKickerFindings = checkRepeatedSectionKickersDOM()
+ const sectionKickerFindings = checkKickerAboveHeadingDOM()
.map(f => ({ type: f.id, detail: f.snippet }))
.filter(f => _ruleOk(f.type));
if (sectionKickerFindings.length > 0) {
diff --git a/.gemini/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.gemini/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs
index 53d7be48c..939eed779 100644
--- a/.gemini/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs
+++ b/.gemini/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs
@@ -223,7 +223,7 @@ function unwrapCssAtLayer(source) {
// ---------------------------------------------------------------------------
const STATIC_INHERITED_PROPS = new Set([
- 'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight',
+ 'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight', 'fontVariant',
'lineHeight', 'letterSpacing', 'textTransform', 'textAlign', 'hyphens',
'webkitHyphens',
]);
@@ -252,6 +252,7 @@ const STATIC_DEFAULT_STYLE = {
fontFamily: '',
fontSize: '16px',
fontStyle: 'normal',
+ fontVariant: 'normal',
fontWeight: '400',
lineHeight: 'normal',
letterSpacing: 'normal',
diff --git a/.gemini/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.gemini/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs
index 1ebb3e427..4e2074042 100644
--- a/.gemini/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs
+++ b/.gemini/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs
@@ -27,11 +27,11 @@ import {
checkElementRadialSpotlight,
checkCreamPalette,
checkHtmlPatterns,
+ checkKickerAboveHeadingFromDoc,
checkNumberedSectionLabelsFromDoc,
checkPageLayout,
checkPageQualityFromDoc,
checkRepeatedContainerTextFromDoc,
- checkRepeatedSectionKickersFromDoc,
resolveBackground,
resolveBorderRadiusPx,
} from '../../rules/checks.mjs';
@@ -202,7 +202,7 @@ async function detectHtml(filePath, options = {}) {
for (const f of runPageCheck('typography-rules', () => checkStaticPageTypography(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
- for (const f of runPageCheck('repeated-section-kickers', () => checkRepeatedSectionKickersFromDoc(document, window))) {
+ for (const f of runPageCheck('kicker-above-heading', () => checkKickerAboveHeadingFromDoc(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of runPageCheck('numbered-section-labels', () => checkNumberedSectionLabelsFromDoc(document, window))) {
diff --git a/.gemini/skills/impeccable/scripts/detector/registry/antipatterns.mjs b/.gemini/skills/impeccable/scripts/detector/registry/antipatterns.mjs
index c47d39524..38ce07390 100644
--- a/.gemini/skills/impeccable/scripts/detector/registry/antipatterns.mjs
+++ b/.gemini/skills/impeccable/scripts/detector/registry/antipatterns.mjs
@@ -198,15 +198,14 @@ const ANTIPATTERNS = [
skillGuideline: 'tiny uppercase tracked label above the hero headline',
},
{
- id: 'repeated-section-kickers',
+ id: 'kicker-above-heading',
category: 'slop',
scopes: ['type'],
- severity: 'advisory',
- name: 'Repeated section kicker labels',
+ name: 'Kicker / eyebrow label above heading',
description:
- 'Repeating tiny uppercase tracked labels above section headings turns a brand page into AI editorial scaffolding. Replace them with stronger structure, artifacts, imagery, or a deliberate brand system.',
+ 'A tiny tracked uppercase or small-caps label sitting as its own block directly above a heading is banned outright, repeated or not. Generated kickers never earn their place: the heading carries its own weight. Delete the label and let the heading speak; if the words matter, work them into the heading or the body.',
skillSection: 'Typography',
- skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding',
+ skillGuideline: 'kicker or eyebrow labels above headings',
},
{
id: 'numbered-section-labels',
diff --git a/.gemini/skills/impeccable/scripts/detector/rules/checks.mjs b/.gemini/skills/impeccable/scripts/detector/rules/checks.mjs
index 3e810076b..3bf26b9b5 100644
--- a/.gemini/skills/impeccable/scripts/detector/rules/checks.mjs
+++ b/.gemini/skills/impeccable/scripts/detector/rules/checks.mjs
@@ -456,12 +456,15 @@ function checkHeroEyebrow(opts) {
}];
}
-function checkRepeatedSectionKickers(opts) {
- const { candidates, minCount = 3 } = opts;
- if (!Array.isArray(candidates) || candidates.length < minCount) return [];
+// Outright ban: one kicker is one too many, so every collected candidate is
+// a finding. The judgment lives in the candidate gate (isKickerCandidate) and
+// the collector's context skips, not in a repetition count.
+function checkKickerAboveHeading(opts) {
+ const { candidates } = opts;
+ if (!Array.isArray(candidates)) return [];
return candidates.map(candidate => ({
- id: 'repeated-section-kickers',
- snippet: `repeated section kicker "${candidate.kickerText}" before ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`,
+ id: 'kicker-above-heading',
+ snippet: `kicker "${candidate.kickerText}" above ${candidate.headingTag} "${candidate.headingText}"`,
}));
}
@@ -2388,7 +2391,7 @@ function parseColorResolved(str, customPropMap) {
return parseAnyColor(resolved);
}
-const REPEATED_KICKER_SKIP_SELECTOR = [
+const KICKER_SKIP_SELECTOR = [
'nav',
'form',
'table',
@@ -2407,7 +2410,7 @@ const REPEATED_KICKER_SKIP_SELECTOR = [
'[data-impeccable-allow-kickers]',
].join(',');
-const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [
+const KICKER_CARD_CONTEXT_SELECTOR = [
'article',
'button',
'a',
@@ -2425,23 +2428,32 @@ function cleanInlineText(el) {
.trim();
}
-function isRepeatedKickerCardContext(heading, kicker) {
- const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR);
+function isKickerCardContext(heading, kicker) {
+ const item = heading.closest?.(KICKER_CARD_CONTEXT_SELECTOR);
return Boolean(item && (!item.contains || item.contains(kicker)));
}
-function isRepeatedKickerCandidate(opts) {
+// Meta lines above headlines join category and date (or path crumbs) with
+// separator glyphs, or carry a year. A kicker is one short phrase; metadata
+// keeps its markers.
+const KICKER_META_TEXT_RE = /[·•|]|\s[\/›»>]\s|\b(19|20)\d{2}\b/;
+// Legal and document numbering: "Section 4.2", "Article IX", "§ 12.3",
+// dotted decimal outlines. The label identifies the clause, so it stays.
+const KICKER_DOC_NUMBERING_RE = /^(§|\d+(\.\d+)+\b|(section|article|clause|appendix|exhibit|schedule|chapter|part|rule|title)\s+([\divxlc]+\b|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)\b)/i;
+
+function isKickerCandidate(opts) {
const {
- headingTag,
+ headingLevel,
headingText,
headingFontSize,
kickerTag,
kickerText,
kickerTextTransform,
+ kickerFontVariant,
kickerFontSize,
kickerLetterSpacing,
} = opts;
- if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
+ if (!headingLevel || headingLevel > 4) return false;
if (!headingText || headingText.length < 3) return false;
if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false;
if (!(headingFontSize >= 20)) return false;
@@ -2449,9 +2461,13 @@ function isRepeatedKickerCandidate(opts) {
if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;
if (!kickerText || kickerText.length < 2 || kickerText.length > 34) return false;
if (/^step\s*\d+/i.test(kickerText) || /^\d{1,2}$/.test(kickerText)) return false;
+ if (KICKER_META_TEXT_RE.test(kickerText)) return false;
+ if (KICKER_DOC_NUMBERING_RE.test(kickerText)) return false;
+ const isSmallCaps = /small-caps/.test(kickerFontVariant || '');
const isUppercased = kickerTextTransform === 'uppercase'
- || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText));
+ || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText))
+ || isSmallCaps;
if (!isUppercased) return false;
if (!(kickerFontSize > 0 && kickerFontSize <= 14)) return false;
const minTrackedSpacing = Math.max(1, kickerFontSize * 0.08);
@@ -2459,37 +2475,64 @@ function isRepeatedKickerCandidate(opts) {
return true;
}
-function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpacing) {
+// Resolve a heading level for the anchor element: 1-4 for h1-h4, aria-level
+// (default 2) for role="heading" elements, 0 otherwise.
+function kickerHeadingLevel(heading) {
+ const tag = heading.tagName.toLowerCase();
+ const byTag = /^h([1-6])$/.exec(tag);
+ if (byTag) return parseInt(byTag[1], 10);
+ const role = heading.getAttribute?.('role') || '';
+ if (role.toLowerCase() !== 'heading') return 0;
+ const ariaLevel = parseInt(heading.getAttribute?.('aria-level') || '', 10);
+ return Number.isFinite(ariaLevel) && ariaLevel >= 1 ? ariaLevel : 2;
+}
+
+function collectKickerCandidates(doc, getStyle, resolveLetterSpacing) {
const candidates = [];
- for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ for (const heading of doc.querySelectorAll('h1, h2, h3, h4, [role="heading"]')) {
+ const headingLevel = kickerHeadingLevel(heading);
+ if (!headingLevel || headingLevel > 4) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ // Application contexts (tab panels, dialogs) use compact context labels
+ // above headings to describe state, not to decorate. Same carve-out the
+ // hero-eyebrow rule makes.
+ if (heading.closest?.('[role="tabpanel"], [role="dialog"], [role="application"], dialog')) continue;
const kicker = heading.previousElementSibling;
- if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
- if (isRepeatedKickerCardContext(heading, kicker)) continue;
+ if (!kicker || kicker.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ if (isKickerCardContext(heading, kicker)) continue;
const headingStyle = getStyle(heading);
const kickerStyle = getStyle(kicker);
+ const headingTag = heading.tagName.toLowerCase();
const headingText = (heading.textContent || '').replace(/\s+/g, ' ').trim();
const kickerText = cleanInlineText(kicker) || (kicker.textContent || '').replace(/\s+/g, ' ').trim();
const headingFontSize = resolveLetterSpacing(headingStyle.fontSize || '', 16) || parseFloat(headingStyle.fontSize) || 0;
const kickerFontSize = resolveLetterSpacing(kickerStyle.fontSize || '', 16) || parseFloat(kickerStyle.fontSize) || 0;
const kickerLetterSpacing = resolveLetterSpacing(kickerStyle.letterSpacing || '', kickerFontSize);
- if (!isRepeatedKickerCandidate({
- headingTag: heading.tagName.toLowerCase(),
+ if (!isKickerCandidate({
+ headingLevel,
headingText,
headingFontSize,
kickerTag: kicker.tagName.toLowerCase(),
kickerText,
kickerTextTransform: kickerStyle.textTransform || '',
+ kickerFontVariant: `${kickerStyle.fontVariant || ''} ${kickerStyle.fontVariantCaps || ''}`,
kickerFontSize,
kickerLetterSpacing,
})) {
continue;
}
+ // A tracked-caps eyebrow above a hero-scale h1 belongs to
+ // hero-eyebrow-chip (which also covers the accent-bold and dash-prefix
+ // stylings there). Stand down so one element gets one finding.
+ if (headingTag === 'h1' && headingFontSize >= 48 && kickerLetterSpacing >= 1.6) {
+ continue;
+ }
+
candidates.push({
- headingTag: heading.tagName.toLowerCase(),
+ headingTag,
headingText: headingText.replace(/^"|"$/g, '').slice(0, 60),
kickerText: kickerText.slice(0, 40),
});
@@ -2497,17 +2540,17 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac
return candidates;
}
-function checkRepeatedSectionKickersDOM() {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingDOM() {
+ const candidates = collectKickerCandidates(
document,
(el) => getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
// ── Numbered section labels ─────────────────────────────────────────────────
-// Sibling of the repeated-kicker rule: instead of a tracked uppercase word,
+// Sibling of the kicker-above-heading rule: instead of a tracked uppercase word,
// the section scaffold is a tiny numeric index riding beside each section
// heading — bare and zero-padded, or an index joined to a short micro-label
// by a separator glyph. The kicker rule deliberately excludes bare 1-2 digit
@@ -2561,7 +2604,7 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
const candidates = [];
const seenLabels = new Set();
for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
// The index sits either directly before the heading, or before the
// wrapper the heading leads (label | …
…).
let label = heading.previousElementSibling;
@@ -2571,9 +2614,9 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
if (firstChild === heading) label = parent.previousElementSibling;
}
if (!label || seenLabels.has(label)) continue;
- if (label.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (label.closest?.(KICKER_SKIP_SELECTOR)) continue;
if (HEADING_TAGS.has(label.tagName.toLowerCase())) continue;
- if (isRepeatedKickerCardContext(heading, label)) continue;
+ if (isKickerCardContext(heading, label)) continue;
const labelText = cleanInlineText(label) || (label.textContent || '').replace(/\s+/g, ' ').trim();
const parsed = parseNumberedLabelText(labelText);
@@ -3746,13 +3789,13 @@ function checkElementHeroEyebrow(el, style, tag, window, customPropMap) {
});
}
-function checkRepeatedSectionKickersFromDoc(doc, win) {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingFromDoc(doc, win) {
+ const candidates = collectKickerCandidates(
doc,
(el) => win.getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
function checkElementMotion(tag, style) {
@@ -5393,7 +5436,7 @@ export {
checkItalicSerif,
isAccentColor,
checkHeroEyebrow,
- checkRepeatedSectionKickers,
+ checkKickerAboveHeading,
checkMotion,
checkGlow,
scanCssTextForGlow,
@@ -5424,9 +5467,9 @@ export {
parseAnyColor,
parseColorResolved,
cleanInlineText,
- isRepeatedKickerCandidate,
- collectRepeatedSectionKickerCandidates,
- checkRepeatedSectionKickersDOM,
+ isKickerCandidate,
+ collectKickerCandidates,
+ checkKickerAboveHeadingDOM,
parseNumberedLabelText,
isNumberedSectionLabelCandidate,
collectNumberedSectionLabelCandidates,
@@ -5458,7 +5501,7 @@ export {
checkElementIconTile,
checkElementItalicSerif,
checkElementHeroEyebrow,
- checkRepeatedSectionKickersFromDoc,
+ checkKickerAboveHeadingFromDoc,
checkElementMotion,
checkElementGlow,
checkTypography,
diff --git a/.github/skills/impeccable/reference/craft-floor.md b/.github/skills/impeccable/reference/craft-floor.md
index 3d0c7a8fb..4ea9b8fa4 100644
--- a/.github/skills/impeccable/reference/craft-floor.md
+++ b/.github/skills/impeccable/reference/craft-floor.md
@@ -23,7 +23,7 @@ Page scaffolds:
- Same-size cards of icon plus heading plus text as the page structure. Cards are the lazy container; nested cards are always wrong.
- The hero-metric template: big number, small label, supporting stats, accent.
-- A tracked uppercase eyebrow over every section. One named kicker is a system; an eyebrow everywhere is grammar you did not choose.
+- A kicker or eyebrow above a heading. This one is a ban, not a default: no brief earns it back. The heading carries its own weight; delete the label and let the heading speak.
- Section numbers (01 / 02 / 03) unless the sequence itself carries information the reader needs.
- A modal for a task that needs neither interruption nor protected focus.
diff --git a/.github/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.github/skills/impeccable/scripts/detector/browser/injected/index.mjs
index e61c536ee..dfc725a8e 100644
--- a/.github/skills/impeccable/scripts/detector/browser/injected/index.mjs
+++ b/.github/skills/impeccable/scripts/detector/browser/injected/index.mjs
@@ -1529,7 +1529,7 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, typoFindings);
}
- const sectionKickerFindings = checkRepeatedSectionKickersDOM()
+ const sectionKickerFindings = checkKickerAboveHeadingDOM()
.map(f => ({ type: f.id, detail: f.snippet }))
.filter(f => _ruleOk(f.type));
if (sectionKickerFindings.length > 0) {
diff --git a/.github/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.github/skills/impeccable/scripts/detector/detect-antipatterns-browser.js
index a728d4f9e..3d948ad39 100644
--- a/.github/skills/impeccable/scripts/detector/detect-antipatterns-browser.js
+++ b/.github/skills/impeccable/scripts/detector/detect-antipatterns-browser.js
@@ -309,15 +309,14 @@ const ANTIPATTERNS = [
skillGuideline: 'tiny uppercase tracked label above the hero headline',
},
{
- id: 'repeated-section-kickers',
+ id: 'kicker-above-heading',
category: 'slop',
scopes: ['type'],
- severity: 'advisory',
- name: 'Repeated section kicker labels',
+ name: 'Kicker / eyebrow label above heading',
description:
- 'Repeating tiny uppercase tracked labels above section headings turns a brand page into AI editorial scaffolding. Replace them with stronger structure, artifacts, imagery, or a deliberate brand system.',
+ 'A tiny tracked uppercase or small-caps label sitting as its own block directly above a heading is banned outright, repeated or not. Generated kickers never earn their place: the heading carries its own weight. Delete the label and let the heading speak; if the words matter, work them into the heading or the body.',
skillSection: 'Typography',
- skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding',
+ skillGuideline: 'kicker or eyebrow labels above headings',
},
{
id: 'numbered-section-labels',
@@ -1258,12 +1257,15 @@ function checkHeroEyebrow(opts) {
}];
}
-function checkRepeatedSectionKickers(opts) {
- const { candidates, minCount = 3 } = opts;
- if (!Array.isArray(candidates) || candidates.length < minCount) return [];
+// Outright ban: one kicker is one too many, so every collected candidate is
+// a finding. The judgment lives in the candidate gate (isKickerCandidate) and
+// the collector's context skips, not in a repetition count.
+function checkKickerAboveHeading(opts) {
+ const { candidates } = opts;
+ if (!Array.isArray(candidates)) return [];
return candidates.map(candidate => ({
- id: 'repeated-section-kickers',
- snippet: `repeated section kicker "${candidate.kickerText}" before ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`,
+ id: 'kicker-above-heading',
+ snippet: `kicker "${candidate.kickerText}" above ${candidate.headingTag} "${candidate.headingText}"`,
}));
}
@@ -3190,7 +3192,7 @@ function parseColorResolved(str, customPropMap) {
return parseAnyColor(resolved);
}
-const REPEATED_KICKER_SKIP_SELECTOR = [
+const KICKER_SKIP_SELECTOR = [
'nav',
'form',
'table',
@@ -3209,7 +3211,7 @@ const REPEATED_KICKER_SKIP_SELECTOR = [
'[data-impeccable-allow-kickers]',
].join(',');
-const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [
+const KICKER_CARD_CONTEXT_SELECTOR = [
'article',
'button',
'a',
@@ -3227,23 +3229,32 @@ function cleanInlineText(el) {
.trim();
}
-function isRepeatedKickerCardContext(heading, kicker) {
- const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR);
+function isKickerCardContext(heading, kicker) {
+ const item = heading.closest?.(KICKER_CARD_CONTEXT_SELECTOR);
return Boolean(item && (!item.contains || item.contains(kicker)));
}
-function isRepeatedKickerCandidate(opts) {
+// Meta lines above headlines join category and date (or path crumbs) with
+// separator glyphs, or carry a year. A kicker is one short phrase; metadata
+// keeps its markers.
+const KICKER_META_TEXT_RE = /[·•|]|\s[\/›»>]\s|\b(19|20)\d{2}\b/;
+// Legal and document numbering: "Section 4.2", "Article IX", "§ 12.3",
+// dotted decimal outlines. The label identifies the clause, so it stays.
+const KICKER_DOC_NUMBERING_RE = /^(§|\d+(\.\d+)+\b|(section|article|clause|appendix|exhibit|schedule|chapter|part|rule|title)\s+([\divxlc]+\b|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)\b)/i;
+
+function isKickerCandidate(opts) {
const {
- headingTag,
+ headingLevel,
headingText,
headingFontSize,
kickerTag,
kickerText,
kickerTextTransform,
+ kickerFontVariant,
kickerFontSize,
kickerLetterSpacing,
} = opts;
- if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
+ if (!headingLevel || headingLevel > 4) return false;
if (!headingText || headingText.length < 3) return false;
if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false;
if (!(headingFontSize >= 20)) return false;
@@ -3251,9 +3262,13 @@ function isRepeatedKickerCandidate(opts) {
if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;
if (!kickerText || kickerText.length < 2 || kickerText.length > 34) return false;
if (/^step\s*\d+/i.test(kickerText) || /^\d{1,2}$/.test(kickerText)) return false;
+ if (KICKER_META_TEXT_RE.test(kickerText)) return false;
+ if (KICKER_DOC_NUMBERING_RE.test(kickerText)) return false;
+ const isSmallCaps = /small-caps/.test(kickerFontVariant || '');
const isUppercased = kickerTextTransform === 'uppercase'
- || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText));
+ || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText))
+ || isSmallCaps;
if (!isUppercased) return false;
if (!(kickerFontSize > 0 && kickerFontSize <= 14)) return false;
const minTrackedSpacing = Math.max(1, kickerFontSize * 0.08);
@@ -3261,37 +3276,64 @@ function isRepeatedKickerCandidate(opts) {
return true;
}
-function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpacing) {
+// Resolve a heading level for the anchor element: 1-4 for h1-h4, aria-level
+// (default 2) for role="heading" elements, 0 otherwise.
+function kickerHeadingLevel(heading) {
+ const tag = heading.tagName.toLowerCase();
+ const byTag = /^h([1-6])$/.exec(tag);
+ if (byTag) return parseInt(byTag[1], 10);
+ const role = heading.getAttribute?.('role') || '';
+ if (role.toLowerCase() !== 'heading') return 0;
+ const ariaLevel = parseInt(heading.getAttribute?.('aria-level') || '', 10);
+ return Number.isFinite(ariaLevel) && ariaLevel >= 1 ? ariaLevel : 2;
+}
+
+function collectKickerCandidates(doc, getStyle, resolveLetterSpacing) {
const candidates = [];
- for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ for (const heading of doc.querySelectorAll('h1, h2, h3, h4, [role="heading"]')) {
+ const headingLevel = kickerHeadingLevel(heading);
+ if (!headingLevel || headingLevel > 4) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ // Application contexts (tab panels, dialogs) use compact context labels
+ // above headings to describe state, not to decorate. Same carve-out the
+ // hero-eyebrow rule makes.
+ if (heading.closest?.('[role="tabpanel"], [role="dialog"], [role="application"], dialog')) continue;
const kicker = heading.previousElementSibling;
- if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
- if (isRepeatedKickerCardContext(heading, kicker)) continue;
+ if (!kicker || kicker.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ if (isKickerCardContext(heading, kicker)) continue;
const headingStyle = getStyle(heading);
const kickerStyle = getStyle(kicker);
+ const headingTag = heading.tagName.toLowerCase();
const headingText = (heading.textContent || '').replace(/\s+/g, ' ').trim();
const kickerText = cleanInlineText(kicker) || (kicker.textContent || '').replace(/\s+/g, ' ').trim();
const headingFontSize = resolveLetterSpacing(headingStyle.fontSize || '', 16) || parseFloat(headingStyle.fontSize) || 0;
const kickerFontSize = resolveLetterSpacing(kickerStyle.fontSize || '', 16) || parseFloat(kickerStyle.fontSize) || 0;
const kickerLetterSpacing = resolveLetterSpacing(kickerStyle.letterSpacing || '', kickerFontSize);
- if (!isRepeatedKickerCandidate({
- headingTag: heading.tagName.toLowerCase(),
+ if (!isKickerCandidate({
+ headingLevel,
headingText,
headingFontSize,
kickerTag: kicker.tagName.toLowerCase(),
kickerText,
kickerTextTransform: kickerStyle.textTransform || '',
+ kickerFontVariant: `${kickerStyle.fontVariant || ''} ${kickerStyle.fontVariantCaps || ''}`,
kickerFontSize,
kickerLetterSpacing,
})) {
continue;
}
+ // A tracked-caps eyebrow above a hero-scale h1 belongs to
+ // hero-eyebrow-chip (which also covers the accent-bold and dash-prefix
+ // stylings there). Stand down so one element gets one finding.
+ if (headingTag === 'h1' && headingFontSize >= 48 && kickerLetterSpacing >= 1.6) {
+ continue;
+ }
+
candidates.push({
- headingTag: heading.tagName.toLowerCase(),
+ headingTag,
headingText: headingText.replace(/^"|"$/g, '').slice(0, 60),
kickerText: kickerText.slice(0, 40),
});
@@ -3299,17 +3341,17 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac
return candidates;
}
-function checkRepeatedSectionKickersDOM() {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingDOM() {
+ const candidates = collectKickerCandidates(
document,
(el) => getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
// ── Numbered section labels ─────────────────────────────────────────────────
-// Sibling of the repeated-kicker rule: instead of a tracked uppercase word,
+// Sibling of the kicker-above-heading rule: instead of a tracked uppercase word,
// the section scaffold is a tiny numeric index riding beside each section
// heading — bare and zero-padded, or an index joined to a short micro-label
// by a separator glyph. The kicker rule deliberately excludes bare 1-2 digit
@@ -3363,7 +3405,7 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
const candidates = [];
const seenLabels = new Set();
for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
// The index sits either directly before the heading, or before the
// wrapper the heading leads (label | …
…).
let label = heading.previousElementSibling;
@@ -3373,9 +3415,9 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
if (firstChild === heading) label = parent.previousElementSibling;
}
if (!label || seenLabels.has(label)) continue;
- if (label.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (label.closest?.(KICKER_SKIP_SELECTOR)) continue;
if (HEADING_TAGS.has(label.tagName.toLowerCase())) continue;
- if (isRepeatedKickerCardContext(heading, label)) continue;
+ if (isKickerCardContext(heading, label)) continue;
const labelText = cleanInlineText(label) || (label.textContent || '').replace(/\s+/g, ' ').trim();
const parsed = parseNumberedLabelText(labelText);
@@ -4548,13 +4590,13 @@ function checkElementHeroEyebrow(el, style, tag, window, customPropMap) {
});
}
-function checkRepeatedSectionKickersFromDoc(doc, win) {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingFromDoc(doc, win) {
+ const candidates = collectKickerCandidates(
doc,
(el) => win.getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
function checkElementMotion(tag, style) {
@@ -7712,7 +7754,7 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, typoFindings);
}
- const sectionKickerFindings = checkRepeatedSectionKickersDOM()
+ const sectionKickerFindings = checkKickerAboveHeadingDOM()
.map(f => ({ type: f.id, detail: f.snippet }))
.filter(f => _ruleOk(f.type));
if (sectionKickerFindings.length > 0) {
diff --git a/.github/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.github/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs
index 53d7be48c..939eed779 100644
--- a/.github/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs
+++ b/.github/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs
@@ -223,7 +223,7 @@ function unwrapCssAtLayer(source) {
// ---------------------------------------------------------------------------
const STATIC_INHERITED_PROPS = new Set([
- 'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight',
+ 'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight', 'fontVariant',
'lineHeight', 'letterSpacing', 'textTransform', 'textAlign', 'hyphens',
'webkitHyphens',
]);
@@ -252,6 +252,7 @@ const STATIC_DEFAULT_STYLE = {
fontFamily: '',
fontSize: '16px',
fontStyle: 'normal',
+ fontVariant: 'normal',
fontWeight: '400',
lineHeight: 'normal',
letterSpacing: 'normal',
diff --git a/.github/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.github/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs
index 1ebb3e427..4e2074042 100644
--- a/.github/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs
+++ b/.github/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs
@@ -27,11 +27,11 @@ import {
checkElementRadialSpotlight,
checkCreamPalette,
checkHtmlPatterns,
+ checkKickerAboveHeadingFromDoc,
checkNumberedSectionLabelsFromDoc,
checkPageLayout,
checkPageQualityFromDoc,
checkRepeatedContainerTextFromDoc,
- checkRepeatedSectionKickersFromDoc,
resolveBackground,
resolveBorderRadiusPx,
} from '../../rules/checks.mjs';
@@ -202,7 +202,7 @@ async function detectHtml(filePath, options = {}) {
for (const f of runPageCheck('typography-rules', () => checkStaticPageTypography(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
- for (const f of runPageCheck('repeated-section-kickers', () => checkRepeatedSectionKickersFromDoc(document, window))) {
+ for (const f of runPageCheck('kicker-above-heading', () => checkKickerAboveHeadingFromDoc(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of runPageCheck('numbered-section-labels', () => checkNumberedSectionLabelsFromDoc(document, window))) {
diff --git a/.github/skills/impeccable/scripts/detector/registry/antipatterns.mjs b/.github/skills/impeccable/scripts/detector/registry/antipatterns.mjs
index c47d39524..38ce07390 100644
--- a/.github/skills/impeccable/scripts/detector/registry/antipatterns.mjs
+++ b/.github/skills/impeccable/scripts/detector/registry/antipatterns.mjs
@@ -198,15 +198,14 @@ const ANTIPATTERNS = [
skillGuideline: 'tiny uppercase tracked label above the hero headline',
},
{
- id: 'repeated-section-kickers',
+ id: 'kicker-above-heading',
category: 'slop',
scopes: ['type'],
- severity: 'advisory',
- name: 'Repeated section kicker labels',
+ name: 'Kicker / eyebrow label above heading',
description:
- 'Repeating tiny uppercase tracked labels above section headings turns a brand page into AI editorial scaffolding. Replace them with stronger structure, artifacts, imagery, or a deliberate brand system.',
+ 'A tiny tracked uppercase or small-caps label sitting as its own block directly above a heading is banned outright, repeated or not. Generated kickers never earn their place: the heading carries its own weight. Delete the label and let the heading speak; if the words matter, work them into the heading or the body.',
skillSection: 'Typography',
- skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding',
+ skillGuideline: 'kicker or eyebrow labels above headings',
},
{
id: 'numbered-section-labels',
diff --git a/.github/skills/impeccable/scripts/detector/rules/checks.mjs b/.github/skills/impeccable/scripts/detector/rules/checks.mjs
index 3e810076b..3bf26b9b5 100644
--- a/.github/skills/impeccable/scripts/detector/rules/checks.mjs
+++ b/.github/skills/impeccable/scripts/detector/rules/checks.mjs
@@ -456,12 +456,15 @@ function checkHeroEyebrow(opts) {
}];
}
-function checkRepeatedSectionKickers(opts) {
- const { candidates, minCount = 3 } = opts;
- if (!Array.isArray(candidates) || candidates.length < minCount) return [];
+// Outright ban: one kicker is one too many, so every collected candidate is
+// a finding. The judgment lives in the candidate gate (isKickerCandidate) and
+// the collector's context skips, not in a repetition count.
+function checkKickerAboveHeading(opts) {
+ const { candidates } = opts;
+ if (!Array.isArray(candidates)) return [];
return candidates.map(candidate => ({
- id: 'repeated-section-kickers',
- snippet: `repeated section kicker "${candidate.kickerText}" before ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`,
+ id: 'kicker-above-heading',
+ snippet: `kicker "${candidate.kickerText}" above ${candidate.headingTag} "${candidate.headingText}"`,
}));
}
@@ -2388,7 +2391,7 @@ function parseColorResolved(str, customPropMap) {
return parseAnyColor(resolved);
}
-const REPEATED_KICKER_SKIP_SELECTOR = [
+const KICKER_SKIP_SELECTOR = [
'nav',
'form',
'table',
@@ -2407,7 +2410,7 @@ const REPEATED_KICKER_SKIP_SELECTOR = [
'[data-impeccable-allow-kickers]',
].join(',');
-const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [
+const KICKER_CARD_CONTEXT_SELECTOR = [
'article',
'button',
'a',
@@ -2425,23 +2428,32 @@ function cleanInlineText(el) {
.trim();
}
-function isRepeatedKickerCardContext(heading, kicker) {
- const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR);
+function isKickerCardContext(heading, kicker) {
+ const item = heading.closest?.(KICKER_CARD_CONTEXT_SELECTOR);
return Boolean(item && (!item.contains || item.contains(kicker)));
}
-function isRepeatedKickerCandidate(opts) {
+// Meta lines above headlines join category and date (or path crumbs) with
+// separator glyphs, or carry a year. A kicker is one short phrase; metadata
+// keeps its markers.
+const KICKER_META_TEXT_RE = /[·•|]|\s[\/›»>]\s|\b(19|20)\d{2}\b/;
+// Legal and document numbering: "Section 4.2", "Article IX", "§ 12.3",
+// dotted decimal outlines. The label identifies the clause, so it stays.
+const KICKER_DOC_NUMBERING_RE = /^(§|\d+(\.\d+)+\b|(section|article|clause|appendix|exhibit|schedule|chapter|part|rule|title)\s+([\divxlc]+\b|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)\b)/i;
+
+function isKickerCandidate(opts) {
const {
- headingTag,
+ headingLevel,
headingText,
headingFontSize,
kickerTag,
kickerText,
kickerTextTransform,
+ kickerFontVariant,
kickerFontSize,
kickerLetterSpacing,
} = opts;
- if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
+ if (!headingLevel || headingLevel > 4) return false;
if (!headingText || headingText.length < 3) return false;
if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false;
if (!(headingFontSize >= 20)) return false;
@@ -2449,9 +2461,13 @@ function isRepeatedKickerCandidate(opts) {
if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;
if (!kickerText || kickerText.length < 2 || kickerText.length > 34) return false;
if (/^step\s*\d+/i.test(kickerText) || /^\d{1,2}$/.test(kickerText)) return false;
+ if (KICKER_META_TEXT_RE.test(kickerText)) return false;
+ if (KICKER_DOC_NUMBERING_RE.test(kickerText)) return false;
+ const isSmallCaps = /small-caps/.test(kickerFontVariant || '');
const isUppercased = kickerTextTransform === 'uppercase'
- || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText));
+ || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText))
+ || isSmallCaps;
if (!isUppercased) return false;
if (!(kickerFontSize > 0 && kickerFontSize <= 14)) return false;
const minTrackedSpacing = Math.max(1, kickerFontSize * 0.08);
@@ -2459,37 +2475,64 @@ function isRepeatedKickerCandidate(opts) {
return true;
}
-function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpacing) {
+// Resolve a heading level for the anchor element: 1-4 for h1-h4, aria-level
+// (default 2) for role="heading" elements, 0 otherwise.
+function kickerHeadingLevel(heading) {
+ const tag = heading.tagName.toLowerCase();
+ const byTag = /^h([1-6])$/.exec(tag);
+ if (byTag) return parseInt(byTag[1], 10);
+ const role = heading.getAttribute?.('role') || '';
+ if (role.toLowerCase() !== 'heading') return 0;
+ const ariaLevel = parseInt(heading.getAttribute?.('aria-level') || '', 10);
+ return Number.isFinite(ariaLevel) && ariaLevel >= 1 ? ariaLevel : 2;
+}
+
+function collectKickerCandidates(doc, getStyle, resolveLetterSpacing) {
const candidates = [];
- for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ for (const heading of doc.querySelectorAll('h1, h2, h3, h4, [role="heading"]')) {
+ const headingLevel = kickerHeadingLevel(heading);
+ if (!headingLevel || headingLevel > 4) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ // Application contexts (tab panels, dialogs) use compact context labels
+ // above headings to describe state, not to decorate. Same carve-out the
+ // hero-eyebrow rule makes.
+ if (heading.closest?.('[role="tabpanel"], [role="dialog"], [role="application"], dialog')) continue;
const kicker = heading.previousElementSibling;
- if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
- if (isRepeatedKickerCardContext(heading, kicker)) continue;
+ if (!kicker || kicker.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ if (isKickerCardContext(heading, kicker)) continue;
const headingStyle = getStyle(heading);
const kickerStyle = getStyle(kicker);
+ const headingTag = heading.tagName.toLowerCase();
const headingText = (heading.textContent || '').replace(/\s+/g, ' ').trim();
const kickerText = cleanInlineText(kicker) || (kicker.textContent || '').replace(/\s+/g, ' ').trim();
const headingFontSize = resolveLetterSpacing(headingStyle.fontSize || '', 16) || parseFloat(headingStyle.fontSize) || 0;
const kickerFontSize = resolveLetterSpacing(kickerStyle.fontSize || '', 16) || parseFloat(kickerStyle.fontSize) || 0;
const kickerLetterSpacing = resolveLetterSpacing(kickerStyle.letterSpacing || '', kickerFontSize);
- if (!isRepeatedKickerCandidate({
- headingTag: heading.tagName.toLowerCase(),
+ if (!isKickerCandidate({
+ headingLevel,
headingText,
headingFontSize,
kickerTag: kicker.tagName.toLowerCase(),
kickerText,
kickerTextTransform: kickerStyle.textTransform || '',
+ kickerFontVariant: `${kickerStyle.fontVariant || ''} ${kickerStyle.fontVariantCaps || ''}`,
kickerFontSize,
kickerLetterSpacing,
})) {
continue;
}
+ // A tracked-caps eyebrow above a hero-scale h1 belongs to
+ // hero-eyebrow-chip (which also covers the accent-bold and dash-prefix
+ // stylings there). Stand down so one element gets one finding.
+ if (headingTag === 'h1' && headingFontSize >= 48 && kickerLetterSpacing >= 1.6) {
+ continue;
+ }
+
candidates.push({
- headingTag: heading.tagName.toLowerCase(),
+ headingTag,
headingText: headingText.replace(/^"|"$/g, '').slice(0, 60),
kickerText: kickerText.slice(0, 40),
});
@@ -2497,17 +2540,17 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac
return candidates;
}
-function checkRepeatedSectionKickersDOM() {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingDOM() {
+ const candidates = collectKickerCandidates(
document,
(el) => getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
// ── Numbered section labels ─────────────────────────────────────────────────
-// Sibling of the repeated-kicker rule: instead of a tracked uppercase word,
+// Sibling of the kicker-above-heading rule: instead of a tracked uppercase word,
// the section scaffold is a tiny numeric index riding beside each section
// heading — bare and zero-padded, or an index joined to a short micro-label
// by a separator glyph. The kicker rule deliberately excludes bare 1-2 digit
@@ -2561,7 +2604,7 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
const candidates = [];
const seenLabels = new Set();
for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
// The index sits either directly before the heading, or before the
// wrapper the heading leads (label | …
…).
let label = heading.previousElementSibling;
@@ -2571,9 +2614,9 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
if (firstChild === heading) label = parent.previousElementSibling;
}
if (!label || seenLabels.has(label)) continue;
- if (label.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (label.closest?.(KICKER_SKIP_SELECTOR)) continue;
if (HEADING_TAGS.has(label.tagName.toLowerCase())) continue;
- if (isRepeatedKickerCardContext(heading, label)) continue;
+ if (isKickerCardContext(heading, label)) continue;
const labelText = cleanInlineText(label) || (label.textContent || '').replace(/\s+/g, ' ').trim();
const parsed = parseNumberedLabelText(labelText);
@@ -3746,13 +3789,13 @@ function checkElementHeroEyebrow(el, style, tag, window, customPropMap) {
});
}
-function checkRepeatedSectionKickersFromDoc(doc, win) {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingFromDoc(doc, win) {
+ const candidates = collectKickerCandidates(
doc,
(el) => win.getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
function checkElementMotion(tag, style) {
@@ -5393,7 +5436,7 @@ export {
checkItalicSerif,
isAccentColor,
checkHeroEyebrow,
- checkRepeatedSectionKickers,
+ checkKickerAboveHeading,
checkMotion,
checkGlow,
scanCssTextForGlow,
@@ -5424,9 +5467,9 @@ export {
parseAnyColor,
parseColorResolved,
cleanInlineText,
- isRepeatedKickerCandidate,
- collectRepeatedSectionKickerCandidates,
- checkRepeatedSectionKickersDOM,
+ isKickerCandidate,
+ collectKickerCandidates,
+ checkKickerAboveHeadingDOM,
parseNumberedLabelText,
isNumberedSectionLabelCandidate,
collectNumberedSectionLabelCandidates,
@@ -5458,7 +5501,7 @@ export {
checkElementIconTile,
checkElementItalicSerif,
checkElementHeroEyebrow,
- checkRepeatedSectionKickersFromDoc,
+ checkKickerAboveHeadingFromDoc,
checkElementMotion,
checkElementGlow,
checkTypography,
diff --git a/.grok/skills/impeccable/reference/craft-floor.md b/.grok/skills/impeccable/reference/craft-floor.md
index 3d0c7a8fb..4ea9b8fa4 100644
--- a/.grok/skills/impeccable/reference/craft-floor.md
+++ b/.grok/skills/impeccable/reference/craft-floor.md
@@ -23,7 +23,7 @@ Page scaffolds:
- Same-size cards of icon plus heading plus text as the page structure. Cards are the lazy container; nested cards are always wrong.
- The hero-metric template: big number, small label, supporting stats, accent.
-- A tracked uppercase eyebrow over every section. One named kicker is a system; an eyebrow everywhere is grammar you did not choose.
+- A kicker or eyebrow above a heading. This one is a ban, not a default: no brief earns it back. The heading carries its own weight; delete the label and let the heading speak.
- Section numbers (01 / 02 / 03) unless the sequence itself carries information the reader needs.
- A modal for a task that needs neither interruption nor protected focus.
diff --git a/.grok/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.grok/skills/impeccable/scripts/detector/browser/injected/index.mjs
index e61c536ee..dfc725a8e 100644
--- a/.grok/skills/impeccable/scripts/detector/browser/injected/index.mjs
+++ b/.grok/skills/impeccable/scripts/detector/browser/injected/index.mjs
@@ -1529,7 +1529,7 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, typoFindings);
}
- const sectionKickerFindings = checkRepeatedSectionKickersDOM()
+ const sectionKickerFindings = checkKickerAboveHeadingDOM()
.map(f => ({ type: f.id, detail: f.snippet }))
.filter(f => _ruleOk(f.type));
if (sectionKickerFindings.length > 0) {
diff --git a/.grok/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.grok/skills/impeccable/scripts/detector/detect-antipatterns-browser.js
index a728d4f9e..3d948ad39 100644
--- a/.grok/skills/impeccable/scripts/detector/detect-antipatterns-browser.js
+++ b/.grok/skills/impeccable/scripts/detector/detect-antipatterns-browser.js
@@ -309,15 +309,14 @@ const ANTIPATTERNS = [
skillGuideline: 'tiny uppercase tracked label above the hero headline',
},
{
- id: 'repeated-section-kickers',
+ id: 'kicker-above-heading',
category: 'slop',
scopes: ['type'],
- severity: 'advisory',
- name: 'Repeated section kicker labels',
+ name: 'Kicker / eyebrow label above heading',
description:
- 'Repeating tiny uppercase tracked labels above section headings turns a brand page into AI editorial scaffolding. Replace them with stronger structure, artifacts, imagery, or a deliberate brand system.',
+ 'A tiny tracked uppercase or small-caps label sitting as its own block directly above a heading is banned outright, repeated or not. Generated kickers never earn their place: the heading carries its own weight. Delete the label and let the heading speak; if the words matter, work them into the heading or the body.',
skillSection: 'Typography',
- skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding',
+ skillGuideline: 'kicker or eyebrow labels above headings',
},
{
id: 'numbered-section-labels',
@@ -1258,12 +1257,15 @@ function checkHeroEyebrow(opts) {
}];
}
-function checkRepeatedSectionKickers(opts) {
- const { candidates, minCount = 3 } = opts;
- if (!Array.isArray(candidates) || candidates.length < minCount) return [];
+// Outright ban: one kicker is one too many, so every collected candidate is
+// a finding. The judgment lives in the candidate gate (isKickerCandidate) and
+// the collector's context skips, not in a repetition count.
+function checkKickerAboveHeading(opts) {
+ const { candidates } = opts;
+ if (!Array.isArray(candidates)) return [];
return candidates.map(candidate => ({
- id: 'repeated-section-kickers',
- snippet: `repeated section kicker "${candidate.kickerText}" before ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`,
+ id: 'kicker-above-heading',
+ snippet: `kicker "${candidate.kickerText}" above ${candidate.headingTag} "${candidate.headingText}"`,
}));
}
@@ -3190,7 +3192,7 @@ function parseColorResolved(str, customPropMap) {
return parseAnyColor(resolved);
}
-const REPEATED_KICKER_SKIP_SELECTOR = [
+const KICKER_SKIP_SELECTOR = [
'nav',
'form',
'table',
@@ -3209,7 +3211,7 @@ const REPEATED_KICKER_SKIP_SELECTOR = [
'[data-impeccable-allow-kickers]',
].join(',');
-const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [
+const KICKER_CARD_CONTEXT_SELECTOR = [
'article',
'button',
'a',
@@ -3227,23 +3229,32 @@ function cleanInlineText(el) {
.trim();
}
-function isRepeatedKickerCardContext(heading, kicker) {
- const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR);
+function isKickerCardContext(heading, kicker) {
+ const item = heading.closest?.(KICKER_CARD_CONTEXT_SELECTOR);
return Boolean(item && (!item.contains || item.contains(kicker)));
}
-function isRepeatedKickerCandidate(opts) {
+// Meta lines above headlines join category and date (or path crumbs) with
+// separator glyphs, or carry a year. A kicker is one short phrase; metadata
+// keeps its markers.
+const KICKER_META_TEXT_RE = /[·•|]|\s[\/›»>]\s|\b(19|20)\d{2}\b/;
+// Legal and document numbering: "Section 4.2", "Article IX", "§ 12.3",
+// dotted decimal outlines. The label identifies the clause, so it stays.
+const KICKER_DOC_NUMBERING_RE = /^(§|\d+(\.\d+)+\b|(section|article|clause|appendix|exhibit|schedule|chapter|part|rule|title)\s+([\divxlc]+\b|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)\b)/i;
+
+function isKickerCandidate(opts) {
const {
- headingTag,
+ headingLevel,
headingText,
headingFontSize,
kickerTag,
kickerText,
kickerTextTransform,
+ kickerFontVariant,
kickerFontSize,
kickerLetterSpacing,
} = opts;
- if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
+ if (!headingLevel || headingLevel > 4) return false;
if (!headingText || headingText.length < 3) return false;
if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false;
if (!(headingFontSize >= 20)) return false;
@@ -3251,9 +3262,13 @@ function isRepeatedKickerCandidate(opts) {
if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;
if (!kickerText || kickerText.length < 2 || kickerText.length > 34) return false;
if (/^step\s*\d+/i.test(kickerText) || /^\d{1,2}$/.test(kickerText)) return false;
+ if (KICKER_META_TEXT_RE.test(kickerText)) return false;
+ if (KICKER_DOC_NUMBERING_RE.test(kickerText)) return false;
+ const isSmallCaps = /small-caps/.test(kickerFontVariant || '');
const isUppercased = kickerTextTransform === 'uppercase'
- || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText));
+ || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText))
+ || isSmallCaps;
if (!isUppercased) return false;
if (!(kickerFontSize > 0 && kickerFontSize <= 14)) return false;
const minTrackedSpacing = Math.max(1, kickerFontSize * 0.08);
@@ -3261,37 +3276,64 @@ function isRepeatedKickerCandidate(opts) {
return true;
}
-function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpacing) {
+// Resolve a heading level for the anchor element: 1-4 for h1-h4, aria-level
+// (default 2) for role="heading" elements, 0 otherwise.
+function kickerHeadingLevel(heading) {
+ const tag = heading.tagName.toLowerCase();
+ const byTag = /^h([1-6])$/.exec(tag);
+ if (byTag) return parseInt(byTag[1], 10);
+ const role = heading.getAttribute?.('role') || '';
+ if (role.toLowerCase() !== 'heading') return 0;
+ const ariaLevel = parseInt(heading.getAttribute?.('aria-level') || '', 10);
+ return Number.isFinite(ariaLevel) && ariaLevel >= 1 ? ariaLevel : 2;
+}
+
+function collectKickerCandidates(doc, getStyle, resolveLetterSpacing) {
const candidates = [];
- for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ for (const heading of doc.querySelectorAll('h1, h2, h3, h4, [role="heading"]')) {
+ const headingLevel = kickerHeadingLevel(heading);
+ if (!headingLevel || headingLevel > 4) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ // Application contexts (tab panels, dialogs) use compact context labels
+ // above headings to describe state, not to decorate. Same carve-out the
+ // hero-eyebrow rule makes.
+ if (heading.closest?.('[role="tabpanel"], [role="dialog"], [role="application"], dialog')) continue;
const kicker = heading.previousElementSibling;
- if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
- if (isRepeatedKickerCardContext(heading, kicker)) continue;
+ if (!kicker || kicker.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ if (isKickerCardContext(heading, kicker)) continue;
const headingStyle = getStyle(heading);
const kickerStyle = getStyle(kicker);
+ const headingTag = heading.tagName.toLowerCase();
const headingText = (heading.textContent || '').replace(/\s+/g, ' ').trim();
const kickerText = cleanInlineText(kicker) || (kicker.textContent || '').replace(/\s+/g, ' ').trim();
const headingFontSize = resolveLetterSpacing(headingStyle.fontSize || '', 16) || parseFloat(headingStyle.fontSize) || 0;
const kickerFontSize = resolveLetterSpacing(kickerStyle.fontSize || '', 16) || parseFloat(kickerStyle.fontSize) || 0;
const kickerLetterSpacing = resolveLetterSpacing(kickerStyle.letterSpacing || '', kickerFontSize);
- if (!isRepeatedKickerCandidate({
- headingTag: heading.tagName.toLowerCase(),
+ if (!isKickerCandidate({
+ headingLevel,
headingText,
headingFontSize,
kickerTag: kicker.tagName.toLowerCase(),
kickerText,
kickerTextTransform: kickerStyle.textTransform || '',
+ kickerFontVariant: `${kickerStyle.fontVariant || ''} ${kickerStyle.fontVariantCaps || ''}`,
kickerFontSize,
kickerLetterSpacing,
})) {
continue;
}
+ // A tracked-caps eyebrow above a hero-scale h1 belongs to
+ // hero-eyebrow-chip (which also covers the accent-bold and dash-prefix
+ // stylings there). Stand down so one element gets one finding.
+ if (headingTag === 'h1' && headingFontSize >= 48 && kickerLetterSpacing >= 1.6) {
+ continue;
+ }
+
candidates.push({
- headingTag: heading.tagName.toLowerCase(),
+ headingTag,
headingText: headingText.replace(/^"|"$/g, '').slice(0, 60),
kickerText: kickerText.slice(0, 40),
});
@@ -3299,17 +3341,17 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac
return candidates;
}
-function checkRepeatedSectionKickersDOM() {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingDOM() {
+ const candidates = collectKickerCandidates(
document,
(el) => getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
// ── Numbered section labels ─────────────────────────────────────────────────
-// Sibling of the repeated-kicker rule: instead of a tracked uppercase word,
+// Sibling of the kicker-above-heading rule: instead of a tracked uppercase word,
// the section scaffold is a tiny numeric index riding beside each section
// heading — bare and zero-padded, or an index joined to a short micro-label
// by a separator glyph. The kicker rule deliberately excludes bare 1-2 digit
@@ -3363,7 +3405,7 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
const candidates = [];
const seenLabels = new Set();
for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
// The index sits either directly before the heading, or before the
// wrapper the heading leads (label | …
…).
let label = heading.previousElementSibling;
@@ -3373,9 +3415,9 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
if (firstChild === heading) label = parent.previousElementSibling;
}
if (!label || seenLabels.has(label)) continue;
- if (label.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (label.closest?.(KICKER_SKIP_SELECTOR)) continue;
if (HEADING_TAGS.has(label.tagName.toLowerCase())) continue;
- if (isRepeatedKickerCardContext(heading, label)) continue;
+ if (isKickerCardContext(heading, label)) continue;
const labelText = cleanInlineText(label) || (label.textContent || '').replace(/\s+/g, ' ').trim();
const parsed = parseNumberedLabelText(labelText);
@@ -4548,13 +4590,13 @@ function checkElementHeroEyebrow(el, style, tag, window, customPropMap) {
});
}
-function checkRepeatedSectionKickersFromDoc(doc, win) {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingFromDoc(doc, win) {
+ const candidates = collectKickerCandidates(
doc,
(el) => win.getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
function checkElementMotion(tag, style) {
@@ -7712,7 +7754,7 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, typoFindings);
}
- const sectionKickerFindings = checkRepeatedSectionKickersDOM()
+ const sectionKickerFindings = checkKickerAboveHeadingDOM()
.map(f => ({ type: f.id, detail: f.snippet }))
.filter(f => _ruleOk(f.type));
if (sectionKickerFindings.length > 0) {
diff --git a/.grok/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.grok/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs
index 53d7be48c..939eed779 100644
--- a/.grok/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs
+++ b/.grok/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs
@@ -223,7 +223,7 @@ function unwrapCssAtLayer(source) {
// ---------------------------------------------------------------------------
const STATIC_INHERITED_PROPS = new Set([
- 'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight',
+ 'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight', 'fontVariant',
'lineHeight', 'letterSpacing', 'textTransform', 'textAlign', 'hyphens',
'webkitHyphens',
]);
@@ -252,6 +252,7 @@ const STATIC_DEFAULT_STYLE = {
fontFamily: '',
fontSize: '16px',
fontStyle: 'normal',
+ fontVariant: 'normal',
fontWeight: '400',
lineHeight: 'normal',
letterSpacing: 'normal',
diff --git a/.grok/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.grok/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs
index 1ebb3e427..4e2074042 100644
--- a/.grok/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs
+++ b/.grok/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs
@@ -27,11 +27,11 @@ import {
checkElementRadialSpotlight,
checkCreamPalette,
checkHtmlPatterns,
+ checkKickerAboveHeadingFromDoc,
checkNumberedSectionLabelsFromDoc,
checkPageLayout,
checkPageQualityFromDoc,
checkRepeatedContainerTextFromDoc,
- checkRepeatedSectionKickersFromDoc,
resolveBackground,
resolveBorderRadiusPx,
} from '../../rules/checks.mjs';
@@ -202,7 +202,7 @@ async function detectHtml(filePath, options = {}) {
for (const f of runPageCheck('typography-rules', () => checkStaticPageTypography(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
- for (const f of runPageCheck('repeated-section-kickers', () => checkRepeatedSectionKickersFromDoc(document, window))) {
+ for (const f of runPageCheck('kicker-above-heading', () => checkKickerAboveHeadingFromDoc(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of runPageCheck('numbered-section-labels', () => checkNumberedSectionLabelsFromDoc(document, window))) {
diff --git a/.grok/skills/impeccable/scripts/detector/registry/antipatterns.mjs b/.grok/skills/impeccable/scripts/detector/registry/antipatterns.mjs
index c47d39524..38ce07390 100644
--- a/.grok/skills/impeccable/scripts/detector/registry/antipatterns.mjs
+++ b/.grok/skills/impeccable/scripts/detector/registry/antipatterns.mjs
@@ -198,15 +198,14 @@ const ANTIPATTERNS = [
skillGuideline: 'tiny uppercase tracked label above the hero headline',
},
{
- id: 'repeated-section-kickers',
+ id: 'kicker-above-heading',
category: 'slop',
scopes: ['type'],
- severity: 'advisory',
- name: 'Repeated section kicker labels',
+ name: 'Kicker / eyebrow label above heading',
description:
- 'Repeating tiny uppercase tracked labels above section headings turns a brand page into AI editorial scaffolding. Replace them with stronger structure, artifacts, imagery, or a deliberate brand system.',
+ 'A tiny tracked uppercase or small-caps label sitting as its own block directly above a heading is banned outright, repeated or not. Generated kickers never earn their place: the heading carries its own weight. Delete the label and let the heading speak; if the words matter, work them into the heading or the body.',
skillSection: 'Typography',
- skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding',
+ skillGuideline: 'kicker or eyebrow labels above headings',
},
{
id: 'numbered-section-labels',
diff --git a/.grok/skills/impeccable/scripts/detector/rules/checks.mjs b/.grok/skills/impeccable/scripts/detector/rules/checks.mjs
index 3e810076b..3bf26b9b5 100644
--- a/.grok/skills/impeccable/scripts/detector/rules/checks.mjs
+++ b/.grok/skills/impeccable/scripts/detector/rules/checks.mjs
@@ -456,12 +456,15 @@ function checkHeroEyebrow(opts) {
}];
}
-function checkRepeatedSectionKickers(opts) {
- const { candidates, minCount = 3 } = opts;
- if (!Array.isArray(candidates) || candidates.length < minCount) return [];
+// Outright ban: one kicker is one too many, so every collected candidate is
+// a finding. The judgment lives in the candidate gate (isKickerCandidate) and
+// the collector's context skips, not in a repetition count.
+function checkKickerAboveHeading(opts) {
+ const { candidates } = opts;
+ if (!Array.isArray(candidates)) return [];
return candidates.map(candidate => ({
- id: 'repeated-section-kickers',
- snippet: `repeated section kicker "${candidate.kickerText}" before ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`,
+ id: 'kicker-above-heading',
+ snippet: `kicker "${candidate.kickerText}" above ${candidate.headingTag} "${candidate.headingText}"`,
}));
}
@@ -2388,7 +2391,7 @@ function parseColorResolved(str, customPropMap) {
return parseAnyColor(resolved);
}
-const REPEATED_KICKER_SKIP_SELECTOR = [
+const KICKER_SKIP_SELECTOR = [
'nav',
'form',
'table',
@@ -2407,7 +2410,7 @@ const REPEATED_KICKER_SKIP_SELECTOR = [
'[data-impeccable-allow-kickers]',
].join(',');
-const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [
+const KICKER_CARD_CONTEXT_SELECTOR = [
'article',
'button',
'a',
@@ -2425,23 +2428,32 @@ function cleanInlineText(el) {
.trim();
}
-function isRepeatedKickerCardContext(heading, kicker) {
- const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR);
+function isKickerCardContext(heading, kicker) {
+ const item = heading.closest?.(KICKER_CARD_CONTEXT_SELECTOR);
return Boolean(item && (!item.contains || item.contains(kicker)));
}
-function isRepeatedKickerCandidate(opts) {
+// Meta lines above headlines join category and date (or path crumbs) with
+// separator glyphs, or carry a year. A kicker is one short phrase; metadata
+// keeps its markers.
+const KICKER_META_TEXT_RE = /[·•|]|\s[\/›»>]\s|\b(19|20)\d{2}\b/;
+// Legal and document numbering: "Section 4.2", "Article IX", "§ 12.3",
+// dotted decimal outlines. The label identifies the clause, so it stays.
+const KICKER_DOC_NUMBERING_RE = /^(§|\d+(\.\d+)+\b|(section|article|clause|appendix|exhibit|schedule|chapter|part|rule|title)\s+([\divxlc]+\b|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)\b)/i;
+
+function isKickerCandidate(opts) {
const {
- headingTag,
+ headingLevel,
headingText,
headingFontSize,
kickerTag,
kickerText,
kickerTextTransform,
+ kickerFontVariant,
kickerFontSize,
kickerLetterSpacing,
} = opts;
- if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
+ if (!headingLevel || headingLevel > 4) return false;
if (!headingText || headingText.length < 3) return false;
if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false;
if (!(headingFontSize >= 20)) return false;
@@ -2449,9 +2461,13 @@ function isRepeatedKickerCandidate(opts) {
if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;
if (!kickerText || kickerText.length < 2 || kickerText.length > 34) return false;
if (/^step\s*\d+/i.test(kickerText) || /^\d{1,2}$/.test(kickerText)) return false;
+ if (KICKER_META_TEXT_RE.test(kickerText)) return false;
+ if (KICKER_DOC_NUMBERING_RE.test(kickerText)) return false;
+ const isSmallCaps = /small-caps/.test(kickerFontVariant || '');
const isUppercased = kickerTextTransform === 'uppercase'
- || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText));
+ || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText))
+ || isSmallCaps;
if (!isUppercased) return false;
if (!(kickerFontSize > 0 && kickerFontSize <= 14)) return false;
const minTrackedSpacing = Math.max(1, kickerFontSize * 0.08);
@@ -2459,37 +2475,64 @@ function isRepeatedKickerCandidate(opts) {
return true;
}
-function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpacing) {
+// Resolve a heading level for the anchor element: 1-4 for h1-h4, aria-level
+// (default 2) for role="heading" elements, 0 otherwise.
+function kickerHeadingLevel(heading) {
+ const tag = heading.tagName.toLowerCase();
+ const byTag = /^h([1-6])$/.exec(tag);
+ if (byTag) return parseInt(byTag[1], 10);
+ const role = heading.getAttribute?.('role') || '';
+ if (role.toLowerCase() !== 'heading') return 0;
+ const ariaLevel = parseInt(heading.getAttribute?.('aria-level') || '', 10);
+ return Number.isFinite(ariaLevel) && ariaLevel >= 1 ? ariaLevel : 2;
+}
+
+function collectKickerCandidates(doc, getStyle, resolveLetterSpacing) {
const candidates = [];
- for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ for (const heading of doc.querySelectorAll('h1, h2, h3, h4, [role="heading"]')) {
+ const headingLevel = kickerHeadingLevel(heading);
+ if (!headingLevel || headingLevel > 4) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ // Application contexts (tab panels, dialogs) use compact context labels
+ // above headings to describe state, not to decorate. Same carve-out the
+ // hero-eyebrow rule makes.
+ if (heading.closest?.('[role="tabpanel"], [role="dialog"], [role="application"], dialog')) continue;
const kicker = heading.previousElementSibling;
- if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
- if (isRepeatedKickerCardContext(heading, kicker)) continue;
+ if (!kicker || kicker.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ if (isKickerCardContext(heading, kicker)) continue;
const headingStyle = getStyle(heading);
const kickerStyle = getStyle(kicker);
+ const headingTag = heading.tagName.toLowerCase();
const headingText = (heading.textContent || '').replace(/\s+/g, ' ').trim();
const kickerText = cleanInlineText(kicker) || (kicker.textContent || '').replace(/\s+/g, ' ').trim();
const headingFontSize = resolveLetterSpacing(headingStyle.fontSize || '', 16) || parseFloat(headingStyle.fontSize) || 0;
const kickerFontSize = resolveLetterSpacing(kickerStyle.fontSize || '', 16) || parseFloat(kickerStyle.fontSize) || 0;
const kickerLetterSpacing = resolveLetterSpacing(kickerStyle.letterSpacing || '', kickerFontSize);
- if (!isRepeatedKickerCandidate({
- headingTag: heading.tagName.toLowerCase(),
+ if (!isKickerCandidate({
+ headingLevel,
headingText,
headingFontSize,
kickerTag: kicker.tagName.toLowerCase(),
kickerText,
kickerTextTransform: kickerStyle.textTransform || '',
+ kickerFontVariant: `${kickerStyle.fontVariant || ''} ${kickerStyle.fontVariantCaps || ''}`,
kickerFontSize,
kickerLetterSpacing,
})) {
continue;
}
+ // A tracked-caps eyebrow above a hero-scale h1 belongs to
+ // hero-eyebrow-chip (which also covers the accent-bold and dash-prefix
+ // stylings there). Stand down so one element gets one finding.
+ if (headingTag === 'h1' && headingFontSize >= 48 && kickerLetterSpacing >= 1.6) {
+ continue;
+ }
+
candidates.push({
- headingTag: heading.tagName.toLowerCase(),
+ headingTag,
headingText: headingText.replace(/^"|"$/g, '').slice(0, 60),
kickerText: kickerText.slice(0, 40),
});
@@ -2497,17 +2540,17 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac
return candidates;
}
-function checkRepeatedSectionKickersDOM() {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingDOM() {
+ const candidates = collectKickerCandidates(
document,
(el) => getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
// ── Numbered section labels ─────────────────────────────────────────────────
-// Sibling of the repeated-kicker rule: instead of a tracked uppercase word,
+// Sibling of the kicker-above-heading rule: instead of a tracked uppercase word,
// the section scaffold is a tiny numeric index riding beside each section
// heading — bare and zero-padded, or an index joined to a short micro-label
// by a separator glyph. The kicker rule deliberately excludes bare 1-2 digit
@@ -2561,7 +2604,7 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
const candidates = [];
const seenLabels = new Set();
for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
// The index sits either directly before the heading, or before the
// wrapper the heading leads (label | …
…).
let label = heading.previousElementSibling;
@@ -2571,9 +2614,9 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
if (firstChild === heading) label = parent.previousElementSibling;
}
if (!label || seenLabels.has(label)) continue;
- if (label.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (label.closest?.(KICKER_SKIP_SELECTOR)) continue;
if (HEADING_TAGS.has(label.tagName.toLowerCase())) continue;
- if (isRepeatedKickerCardContext(heading, label)) continue;
+ if (isKickerCardContext(heading, label)) continue;
const labelText = cleanInlineText(label) || (label.textContent || '').replace(/\s+/g, ' ').trim();
const parsed = parseNumberedLabelText(labelText);
@@ -3746,13 +3789,13 @@ function checkElementHeroEyebrow(el, style, tag, window, customPropMap) {
});
}
-function checkRepeatedSectionKickersFromDoc(doc, win) {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingFromDoc(doc, win) {
+ const candidates = collectKickerCandidates(
doc,
(el) => win.getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
function checkElementMotion(tag, style) {
@@ -5393,7 +5436,7 @@ export {
checkItalicSerif,
isAccentColor,
checkHeroEyebrow,
- checkRepeatedSectionKickers,
+ checkKickerAboveHeading,
checkMotion,
checkGlow,
scanCssTextForGlow,
@@ -5424,9 +5467,9 @@ export {
parseAnyColor,
parseColorResolved,
cleanInlineText,
- isRepeatedKickerCandidate,
- collectRepeatedSectionKickerCandidates,
- checkRepeatedSectionKickersDOM,
+ isKickerCandidate,
+ collectKickerCandidates,
+ checkKickerAboveHeadingDOM,
parseNumberedLabelText,
isNumberedSectionLabelCandidate,
collectNumberedSectionLabelCandidates,
@@ -5458,7 +5501,7 @@ export {
checkElementIconTile,
checkElementItalicSerif,
checkElementHeroEyebrow,
- checkRepeatedSectionKickersFromDoc,
+ checkKickerAboveHeadingFromDoc,
checkElementMotion,
checkElementGlow,
checkTypography,
diff --git a/.kiro/skills/impeccable/reference/craft-floor.md b/.kiro/skills/impeccable/reference/craft-floor.md
index 3d0c7a8fb..4ea9b8fa4 100644
--- a/.kiro/skills/impeccable/reference/craft-floor.md
+++ b/.kiro/skills/impeccable/reference/craft-floor.md
@@ -23,7 +23,7 @@ Page scaffolds:
- Same-size cards of icon plus heading plus text as the page structure. Cards are the lazy container; nested cards are always wrong.
- The hero-metric template: big number, small label, supporting stats, accent.
-- A tracked uppercase eyebrow over every section. One named kicker is a system; an eyebrow everywhere is grammar you did not choose.
+- A kicker or eyebrow above a heading. This one is a ban, not a default: no brief earns it back. The heading carries its own weight; delete the label and let the heading speak.
- Section numbers (01 / 02 / 03) unless the sequence itself carries information the reader needs.
- A modal for a task that needs neither interruption nor protected focus.
diff --git a/.kiro/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.kiro/skills/impeccable/scripts/detector/browser/injected/index.mjs
index e61c536ee..dfc725a8e 100644
--- a/.kiro/skills/impeccable/scripts/detector/browser/injected/index.mjs
+++ b/.kiro/skills/impeccable/scripts/detector/browser/injected/index.mjs
@@ -1529,7 +1529,7 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, typoFindings);
}
- const sectionKickerFindings = checkRepeatedSectionKickersDOM()
+ const sectionKickerFindings = checkKickerAboveHeadingDOM()
.map(f => ({ type: f.id, detail: f.snippet }))
.filter(f => _ruleOk(f.type));
if (sectionKickerFindings.length > 0) {
diff --git a/.kiro/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.kiro/skills/impeccable/scripts/detector/detect-antipatterns-browser.js
index a728d4f9e..3d948ad39 100644
--- a/.kiro/skills/impeccable/scripts/detector/detect-antipatterns-browser.js
+++ b/.kiro/skills/impeccable/scripts/detector/detect-antipatterns-browser.js
@@ -309,15 +309,14 @@ const ANTIPATTERNS = [
skillGuideline: 'tiny uppercase tracked label above the hero headline',
},
{
- id: 'repeated-section-kickers',
+ id: 'kicker-above-heading',
category: 'slop',
scopes: ['type'],
- severity: 'advisory',
- name: 'Repeated section kicker labels',
+ name: 'Kicker / eyebrow label above heading',
description:
- 'Repeating tiny uppercase tracked labels above section headings turns a brand page into AI editorial scaffolding. Replace them with stronger structure, artifacts, imagery, or a deliberate brand system.',
+ 'A tiny tracked uppercase or small-caps label sitting as its own block directly above a heading is banned outright, repeated or not. Generated kickers never earn their place: the heading carries its own weight. Delete the label and let the heading speak; if the words matter, work them into the heading or the body.',
skillSection: 'Typography',
- skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding',
+ skillGuideline: 'kicker or eyebrow labels above headings',
},
{
id: 'numbered-section-labels',
@@ -1258,12 +1257,15 @@ function checkHeroEyebrow(opts) {
}];
}
-function checkRepeatedSectionKickers(opts) {
- const { candidates, minCount = 3 } = opts;
- if (!Array.isArray(candidates) || candidates.length < minCount) return [];
+// Outright ban: one kicker is one too many, so every collected candidate is
+// a finding. The judgment lives in the candidate gate (isKickerCandidate) and
+// the collector's context skips, not in a repetition count.
+function checkKickerAboveHeading(opts) {
+ const { candidates } = opts;
+ if (!Array.isArray(candidates)) return [];
return candidates.map(candidate => ({
- id: 'repeated-section-kickers',
- snippet: `repeated section kicker "${candidate.kickerText}" before ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`,
+ id: 'kicker-above-heading',
+ snippet: `kicker "${candidate.kickerText}" above ${candidate.headingTag} "${candidate.headingText}"`,
}));
}
@@ -3190,7 +3192,7 @@ function parseColorResolved(str, customPropMap) {
return parseAnyColor(resolved);
}
-const REPEATED_KICKER_SKIP_SELECTOR = [
+const KICKER_SKIP_SELECTOR = [
'nav',
'form',
'table',
@@ -3209,7 +3211,7 @@ const REPEATED_KICKER_SKIP_SELECTOR = [
'[data-impeccable-allow-kickers]',
].join(',');
-const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [
+const KICKER_CARD_CONTEXT_SELECTOR = [
'article',
'button',
'a',
@@ -3227,23 +3229,32 @@ function cleanInlineText(el) {
.trim();
}
-function isRepeatedKickerCardContext(heading, kicker) {
- const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR);
+function isKickerCardContext(heading, kicker) {
+ const item = heading.closest?.(KICKER_CARD_CONTEXT_SELECTOR);
return Boolean(item && (!item.contains || item.contains(kicker)));
}
-function isRepeatedKickerCandidate(opts) {
+// Meta lines above headlines join category and date (or path crumbs) with
+// separator glyphs, or carry a year. A kicker is one short phrase; metadata
+// keeps its markers.
+const KICKER_META_TEXT_RE = /[·•|]|\s[\/›»>]\s|\b(19|20)\d{2}\b/;
+// Legal and document numbering: "Section 4.2", "Article IX", "§ 12.3",
+// dotted decimal outlines. The label identifies the clause, so it stays.
+const KICKER_DOC_NUMBERING_RE = /^(§|\d+(\.\d+)+\b|(section|article|clause|appendix|exhibit|schedule|chapter|part|rule|title)\s+([\divxlc]+\b|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)\b)/i;
+
+function isKickerCandidate(opts) {
const {
- headingTag,
+ headingLevel,
headingText,
headingFontSize,
kickerTag,
kickerText,
kickerTextTransform,
+ kickerFontVariant,
kickerFontSize,
kickerLetterSpacing,
} = opts;
- if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
+ if (!headingLevel || headingLevel > 4) return false;
if (!headingText || headingText.length < 3) return false;
if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false;
if (!(headingFontSize >= 20)) return false;
@@ -3251,9 +3262,13 @@ function isRepeatedKickerCandidate(opts) {
if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;
if (!kickerText || kickerText.length < 2 || kickerText.length > 34) return false;
if (/^step\s*\d+/i.test(kickerText) || /^\d{1,2}$/.test(kickerText)) return false;
+ if (KICKER_META_TEXT_RE.test(kickerText)) return false;
+ if (KICKER_DOC_NUMBERING_RE.test(kickerText)) return false;
+ const isSmallCaps = /small-caps/.test(kickerFontVariant || '');
const isUppercased = kickerTextTransform === 'uppercase'
- || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText));
+ || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText))
+ || isSmallCaps;
if (!isUppercased) return false;
if (!(kickerFontSize > 0 && kickerFontSize <= 14)) return false;
const minTrackedSpacing = Math.max(1, kickerFontSize * 0.08);
@@ -3261,37 +3276,64 @@ function isRepeatedKickerCandidate(opts) {
return true;
}
-function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpacing) {
+// Resolve a heading level for the anchor element: 1-4 for h1-h4, aria-level
+// (default 2) for role="heading" elements, 0 otherwise.
+function kickerHeadingLevel(heading) {
+ const tag = heading.tagName.toLowerCase();
+ const byTag = /^h([1-6])$/.exec(tag);
+ if (byTag) return parseInt(byTag[1], 10);
+ const role = heading.getAttribute?.('role') || '';
+ if (role.toLowerCase() !== 'heading') return 0;
+ const ariaLevel = parseInt(heading.getAttribute?.('aria-level') || '', 10);
+ return Number.isFinite(ariaLevel) && ariaLevel >= 1 ? ariaLevel : 2;
+}
+
+function collectKickerCandidates(doc, getStyle, resolveLetterSpacing) {
const candidates = [];
- for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ for (const heading of doc.querySelectorAll('h1, h2, h3, h4, [role="heading"]')) {
+ const headingLevel = kickerHeadingLevel(heading);
+ if (!headingLevel || headingLevel > 4) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ // Application contexts (tab panels, dialogs) use compact context labels
+ // above headings to describe state, not to decorate. Same carve-out the
+ // hero-eyebrow rule makes.
+ if (heading.closest?.('[role="tabpanel"], [role="dialog"], [role="application"], dialog')) continue;
const kicker = heading.previousElementSibling;
- if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
- if (isRepeatedKickerCardContext(heading, kicker)) continue;
+ if (!kicker || kicker.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ if (isKickerCardContext(heading, kicker)) continue;
const headingStyle = getStyle(heading);
const kickerStyle = getStyle(kicker);
+ const headingTag = heading.tagName.toLowerCase();
const headingText = (heading.textContent || '').replace(/\s+/g, ' ').trim();
const kickerText = cleanInlineText(kicker) || (kicker.textContent || '').replace(/\s+/g, ' ').trim();
const headingFontSize = resolveLetterSpacing(headingStyle.fontSize || '', 16) || parseFloat(headingStyle.fontSize) || 0;
const kickerFontSize = resolveLetterSpacing(kickerStyle.fontSize || '', 16) || parseFloat(kickerStyle.fontSize) || 0;
const kickerLetterSpacing = resolveLetterSpacing(kickerStyle.letterSpacing || '', kickerFontSize);
- if (!isRepeatedKickerCandidate({
- headingTag: heading.tagName.toLowerCase(),
+ if (!isKickerCandidate({
+ headingLevel,
headingText,
headingFontSize,
kickerTag: kicker.tagName.toLowerCase(),
kickerText,
kickerTextTransform: kickerStyle.textTransform || '',
+ kickerFontVariant: `${kickerStyle.fontVariant || ''} ${kickerStyle.fontVariantCaps || ''}`,
kickerFontSize,
kickerLetterSpacing,
})) {
continue;
}
+ // A tracked-caps eyebrow above a hero-scale h1 belongs to
+ // hero-eyebrow-chip (which also covers the accent-bold and dash-prefix
+ // stylings there). Stand down so one element gets one finding.
+ if (headingTag === 'h1' && headingFontSize >= 48 && kickerLetterSpacing >= 1.6) {
+ continue;
+ }
+
candidates.push({
- headingTag: heading.tagName.toLowerCase(),
+ headingTag,
headingText: headingText.replace(/^"|"$/g, '').slice(0, 60),
kickerText: kickerText.slice(0, 40),
});
@@ -3299,17 +3341,17 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac
return candidates;
}
-function checkRepeatedSectionKickersDOM() {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingDOM() {
+ const candidates = collectKickerCandidates(
document,
(el) => getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
// ── Numbered section labels ─────────────────────────────────────────────────
-// Sibling of the repeated-kicker rule: instead of a tracked uppercase word,
+// Sibling of the kicker-above-heading rule: instead of a tracked uppercase word,
// the section scaffold is a tiny numeric index riding beside each section
// heading — bare and zero-padded, or an index joined to a short micro-label
// by a separator glyph. The kicker rule deliberately excludes bare 1-2 digit
@@ -3363,7 +3405,7 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
const candidates = [];
const seenLabels = new Set();
for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
// The index sits either directly before the heading, or before the
// wrapper the heading leads (label | …
…).
let label = heading.previousElementSibling;
@@ -3373,9 +3415,9 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
if (firstChild === heading) label = parent.previousElementSibling;
}
if (!label || seenLabels.has(label)) continue;
- if (label.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (label.closest?.(KICKER_SKIP_SELECTOR)) continue;
if (HEADING_TAGS.has(label.tagName.toLowerCase())) continue;
- if (isRepeatedKickerCardContext(heading, label)) continue;
+ if (isKickerCardContext(heading, label)) continue;
const labelText = cleanInlineText(label) || (label.textContent || '').replace(/\s+/g, ' ').trim();
const parsed = parseNumberedLabelText(labelText);
@@ -4548,13 +4590,13 @@ function checkElementHeroEyebrow(el, style, tag, window, customPropMap) {
});
}
-function checkRepeatedSectionKickersFromDoc(doc, win) {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingFromDoc(doc, win) {
+ const candidates = collectKickerCandidates(
doc,
(el) => win.getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
function checkElementMotion(tag, style) {
@@ -7712,7 +7754,7 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, typoFindings);
}
- const sectionKickerFindings = checkRepeatedSectionKickersDOM()
+ const sectionKickerFindings = checkKickerAboveHeadingDOM()
.map(f => ({ type: f.id, detail: f.snippet }))
.filter(f => _ruleOk(f.type));
if (sectionKickerFindings.length > 0) {
diff --git a/.kiro/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.kiro/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs
index 53d7be48c..939eed779 100644
--- a/.kiro/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs
+++ b/.kiro/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs
@@ -223,7 +223,7 @@ function unwrapCssAtLayer(source) {
// ---------------------------------------------------------------------------
const STATIC_INHERITED_PROPS = new Set([
- 'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight',
+ 'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight', 'fontVariant',
'lineHeight', 'letterSpacing', 'textTransform', 'textAlign', 'hyphens',
'webkitHyphens',
]);
@@ -252,6 +252,7 @@ const STATIC_DEFAULT_STYLE = {
fontFamily: '',
fontSize: '16px',
fontStyle: 'normal',
+ fontVariant: 'normal',
fontWeight: '400',
lineHeight: 'normal',
letterSpacing: 'normal',
diff --git a/.kiro/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.kiro/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs
index 1ebb3e427..4e2074042 100644
--- a/.kiro/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs
+++ b/.kiro/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs
@@ -27,11 +27,11 @@ import {
checkElementRadialSpotlight,
checkCreamPalette,
checkHtmlPatterns,
+ checkKickerAboveHeadingFromDoc,
checkNumberedSectionLabelsFromDoc,
checkPageLayout,
checkPageQualityFromDoc,
checkRepeatedContainerTextFromDoc,
- checkRepeatedSectionKickersFromDoc,
resolveBackground,
resolveBorderRadiusPx,
} from '../../rules/checks.mjs';
@@ -202,7 +202,7 @@ async function detectHtml(filePath, options = {}) {
for (const f of runPageCheck('typography-rules', () => checkStaticPageTypography(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
- for (const f of runPageCheck('repeated-section-kickers', () => checkRepeatedSectionKickersFromDoc(document, window))) {
+ for (const f of runPageCheck('kicker-above-heading', () => checkKickerAboveHeadingFromDoc(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of runPageCheck('numbered-section-labels', () => checkNumberedSectionLabelsFromDoc(document, window))) {
diff --git a/.kiro/skills/impeccable/scripts/detector/registry/antipatterns.mjs b/.kiro/skills/impeccable/scripts/detector/registry/antipatterns.mjs
index c47d39524..38ce07390 100644
--- a/.kiro/skills/impeccable/scripts/detector/registry/antipatterns.mjs
+++ b/.kiro/skills/impeccable/scripts/detector/registry/antipatterns.mjs
@@ -198,15 +198,14 @@ const ANTIPATTERNS = [
skillGuideline: 'tiny uppercase tracked label above the hero headline',
},
{
- id: 'repeated-section-kickers',
+ id: 'kicker-above-heading',
category: 'slop',
scopes: ['type'],
- severity: 'advisory',
- name: 'Repeated section kicker labels',
+ name: 'Kicker / eyebrow label above heading',
description:
- 'Repeating tiny uppercase tracked labels above section headings turns a brand page into AI editorial scaffolding. Replace them with stronger structure, artifacts, imagery, or a deliberate brand system.',
+ 'A tiny tracked uppercase or small-caps label sitting as its own block directly above a heading is banned outright, repeated or not. Generated kickers never earn their place: the heading carries its own weight. Delete the label and let the heading speak; if the words matter, work them into the heading or the body.',
skillSection: 'Typography',
- skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding',
+ skillGuideline: 'kicker or eyebrow labels above headings',
},
{
id: 'numbered-section-labels',
diff --git a/.kiro/skills/impeccable/scripts/detector/rules/checks.mjs b/.kiro/skills/impeccable/scripts/detector/rules/checks.mjs
index 3e810076b..3bf26b9b5 100644
--- a/.kiro/skills/impeccable/scripts/detector/rules/checks.mjs
+++ b/.kiro/skills/impeccable/scripts/detector/rules/checks.mjs
@@ -456,12 +456,15 @@ function checkHeroEyebrow(opts) {
}];
}
-function checkRepeatedSectionKickers(opts) {
- const { candidates, minCount = 3 } = opts;
- if (!Array.isArray(candidates) || candidates.length < minCount) return [];
+// Outright ban: one kicker is one too many, so every collected candidate is
+// a finding. The judgment lives in the candidate gate (isKickerCandidate) and
+// the collector's context skips, not in a repetition count.
+function checkKickerAboveHeading(opts) {
+ const { candidates } = opts;
+ if (!Array.isArray(candidates)) return [];
return candidates.map(candidate => ({
- id: 'repeated-section-kickers',
- snippet: `repeated section kicker "${candidate.kickerText}" before ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`,
+ id: 'kicker-above-heading',
+ snippet: `kicker "${candidate.kickerText}" above ${candidate.headingTag} "${candidate.headingText}"`,
}));
}
@@ -2388,7 +2391,7 @@ function parseColorResolved(str, customPropMap) {
return parseAnyColor(resolved);
}
-const REPEATED_KICKER_SKIP_SELECTOR = [
+const KICKER_SKIP_SELECTOR = [
'nav',
'form',
'table',
@@ -2407,7 +2410,7 @@ const REPEATED_KICKER_SKIP_SELECTOR = [
'[data-impeccable-allow-kickers]',
].join(',');
-const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [
+const KICKER_CARD_CONTEXT_SELECTOR = [
'article',
'button',
'a',
@@ -2425,23 +2428,32 @@ function cleanInlineText(el) {
.trim();
}
-function isRepeatedKickerCardContext(heading, kicker) {
- const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR);
+function isKickerCardContext(heading, kicker) {
+ const item = heading.closest?.(KICKER_CARD_CONTEXT_SELECTOR);
return Boolean(item && (!item.contains || item.contains(kicker)));
}
-function isRepeatedKickerCandidate(opts) {
+// Meta lines above headlines join category and date (or path crumbs) with
+// separator glyphs, or carry a year. A kicker is one short phrase; metadata
+// keeps its markers.
+const KICKER_META_TEXT_RE = /[·•|]|\s[\/›»>]\s|\b(19|20)\d{2}\b/;
+// Legal and document numbering: "Section 4.2", "Article IX", "§ 12.3",
+// dotted decimal outlines. The label identifies the clause, so it stays.
+const KICKER_DOC_NUMBERING_RE = /^(§|\d+(\.\d+)+\b|(section|article|clause|appendix|exhibit|schedule|chapter|part|rule|title)\s+([\divxlc]+\b|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)\b)/i;
+
+function isKickerCandidate(opts) {
const {
- headingTag,
+ headingLevel,
headingText,
headingFontSize,
kickerTag,
kickerText,
kickerTextTransform,
+ kickerFontVariant,
kickerFontSize,
kickerLetterSpacing,
} = opts;
- if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
+ if (!headingLevel || headingLevel > 4) return false;
if (!headingText || headingText.length < 3) return false;
if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false;
if (!(headingFontSize >= 20)) return false;
@@ -2449,9 +2461,13 @@ function isRepeatedKickerCandidate(opts) {
if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;
if (!kickerText || kickerText.length < 2 || kickerText.length > 34) return false;
if (/^step\s*\d+/i.test(kickerText) || /^\d{1,2}$/.test(kickerText)) return false;
+ if (KICKER_META_TEXT_RE.test(kickerText)) return false;
+ if (KICKER_DOC_NUMBERING_RE.test(kickerText)) return false;
+ const isSmallCaps = /small-caps/.test(kickerFontVariant || '');
const isUppercased = kickerTextTransform === 'uppercase'
- || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText));
+ || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText))
+ || isSmallCaps;
if (!isUppercased) return false;
if (!(kickerFontSize > 0 && kickerFontSize <= 14)) return false;
const minTrackedSpacing = Math.max(1, kickerFontSize * 0.08);
@@ -2459,37 +2475,64 @@ function isRepeatedKickerCandidate(opts) {
return true;
}
-function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpacing) {
+// Resolve a heading level for the anchor element: 1-4 for h1-h4, aria-level
+// (default 2) for role="heading" elements, 0 otherwise.
+function kickerHeadingLevel(heading) {
+ const tag = heading.tagName.toLowerCase();
+ const byTag = /^h([1-6])$/.exec(tag);
+ if (byTag) return parseInt(byTag[1], 10);
+ const role = heading.getAttribute?.('role') || '';
+ if (role.toLowerCase() !== 'heading') return 0;
+ const ariaLevel = parseInt(heading.getAttribute?.('aria-level') || '', 10);
+ return Number.isFinite(ariaLevel) && ariaLevel >= 1 ? ariaLevel : 2;
+}
+
+function collectKickerCandidates(doc, getStyle, resolveLetterSpacing) {
const candidates = [];
- for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ for (const heading of doc.querySelectorAll('h1, h2, h3, h4, [role="heading"]')) {
+ const headingLevel = kickerHeadingLevel(heading);
+ if (!headingLevel || headingLevel > 4) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ // Application contexts (tab panels, dialogs) use compact context labels
+ // above headings to describe state, not to decorate. Same carve-out the
+ // hero-eyebrow rule makes.
+ if (heading.closest?.('[role="tabpanel"], [role="dialog"], [role="application"], dialog')) continue;
const kicker = heading.previousElementSibling;
- if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
- if (isRepeatedKickerCardContext(heading, kicker)) continue;
+ if (!kicker || kicker.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ if (isKickerCardContext(heading, kicker)) continue;
const headingStyle = getStyle(heading);
const kickerStyle = getStyle(kicker);
+ const headingTag = heading.tagName.toLowerCase();
const headingText = (heading.textContent || '').replace(/\s+/g, ' ').trim();
const kickerText = cleanInlineText(kicker) || (kicker.textContent || '').replace(/\s+/g, ' ').trim();
const headingFontSize = resolveLetterSpacing(headingStyle.fontSize || '', 16) || parseFloat(headingStyle.fontSize) || 0;
const kickerFontSize = resolveLetterSpacing(kickerStyle.fontSize || '', 16) || parseFloat(kickerStyle.fontSize) || 0;
const kickerLetterSpacing = resolveLetterSpacing(kickerStyle.letterSpacing || '', kickerFontSize);
- if (!isRepeatedKickerCandidate({
- headingTag: heading.tagName.toLowerCase(),
+ if (!isKickerCandidate({
+ headingLevel,
headingText,
headingFontSize,
kickerTag: kicker.tagName.toLowerCase(),
kickerText,
kickerTextTransform: kickerStyle.textTransform || '',
+ kickerFontVariant: `${kickerStyle.fontVariant || ''} ${kickerStyle.fontVariantCaps || ''}`,
kickerFontSize,
kickerLetterSpacing,
})) {
continue;
}
+ // A tracked-caps eyebrow above a hero-scale h1 belongs to
+ // hero-eyebrow-chip (which also covers the accent-bold and dash-prefix
+ // stylings there). Stand down so one element gets one finding.
+ if (headingTag === 'h1' && headingFontSize >= 48 && kickerLetterSpacing >= 1.6) {
+ continue;
+ }
+
candidates.push({
- headingTag: heading.tagName.toLowerCase(),
+ headingTag,
headingText: headingText.replace(/^"|"$/g, '').slice(0, 60),
kickerText: kickerText.slice(0, 40),
});
@@ -2497,17 +2540,17 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac
return candidates;
}
-function checkRepeatedSectionKickersDOM() {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingDOM() {
+ const candidates = collectKickerCandidates(
document,
(el) => getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
// ── Numbered section labels ─────────────────────────────────────────────────
-// Sibling of the repeated-kicker rule: instead of a tracked uppercase word,
+// Sibling of the kicker-above-heading rule: instead of a tracked uppercase word,
// the section scaffold is a tiny numeric index riding beside each section
// heading — bare and zero-padded, or an index joined to a short micro-label
// by a separator glyph. The kicker rule deliberately excludes bare 1-2 digit
@@ -2561,7 +2604,7 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
const candidates = [];
const seenLabels = new Set();
for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
// The index sits either directly before the heading, or before the
// wrapper the heading leads (label | …
…).
let label = heading.previousElementSibling;
@@ -2571,9 +2614,9 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
if (firstChild === heading) label = parent.previousElementSibling;
}
if (!label || seenLabels.has(label)) continue;
- if (label.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (label.closest?.(KICKER_SKIP_SELECTOR)) continue;
if (HEADING_TAGS.has(label.tagName.toLowerCase())) continue;
- if (isRepeatedKickerCardContext(heading, label)) continue;
+ if (isKickerCardContext(heading, label)) continue;
const labelText = cleanInlineText(label) || (label.textContent || '').replace(/\s+/g, ' ').trim();
const parsed = parseNumberedLabelText(labelText);
@@ -3746,13 +3789,13 @@ function checkElementHeroEyebrow(el, style, tag, window, customPropMap) {
});
}
-function checkRepeatedSectionKickersFromDoc(doc, win) {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingFromDoc(doc, win) {
+ const candidates = collectKickerCandidates(
doc,
(el) => win.getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
function checkElementMotion(tag, style) {
@@ -5393,7 +5436,7 @@ export {
checkItalicSerif,
isAccentColor,
checkHeroEyebrow,
- checkRepeatedSectionKickers,
+ checkKickerAboveHeading,
checkMotion,
checkGlow,
scanCssTextForGlow,
@@ -5424,9 +5467,9 @@ export {
parseAnyColor,
parseColorResolved,
cleanInlineText,
- isRepeatedKickerCandidate,
- collectRepeatedSectionKickerCandidates,
- checkRepeatedSectionKickersDOM,
+ isKickerCandidate,
+ collectKickerCandidates,
+ checkKickerAboveHeadingDOM,
parseNumberedLabelText,
isNumberedSectionLabelCandidate,
collectNumberedSectionLabelCandidates,
@@ -5458,7 +5501,7 @@ export {
checkElementIconTile,
checkElementItalicSerif,
checkElementHeroEyebrow,
- checkRepeatedSectionKickersFromDoc,
+ checkKickerAboveHeadingFromDoc,
checkElementMotion,
checkElementGlow,
checkTypography,
diff --git a/.opencode/skills/impeccable/reference/craft-floor.md b/.opencode/skills/impeccable/reference/craft-floor.md
index 3d0c7a8fb..4ea9b8fa4 100644
--- a/.opencode/skills/impeccable/reference/craft-floor.md
+++ b/.opencode/skills/impeccable/reference/craft-floor.md
@@ -23,7 +23,7 @@ Page scaffolds:
- Same-size cards of icon plus heading plus text as the page structure. Cards are the lazy container; nested cards are always wrong.
- The hero-metric template: big number, small label, supporting stats, accent.
-- A tracked uppercase eyebrow over every section. One named kicker is a system; an eyebrow everywhere is grammar you did not choose.
+- A kicker or eyebrow above a heading. This one is a ban, not a default: no brief earns it back. The heading carries its own weight; delete the label and let the heading speak.
- Section numbers (01 / 02 / 03) unless the sequence itself carries information the reader needs.
- A modal for a task that needs neither interruption nor protected focus.
diff --git a/.opencode/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.opencode/skills/impeccable/scripts/detector/browser/injected/index.mjs
index e61c536ee..dfc725a8e 100644
--- a/.opencode/skills/impeccable/scripts/detector/browser/injected/index.mjs
+++ b/.opencode/skills/impeccable/scripts/detector/browser/injected/index.mjs
@@ -1529,7 +1529,7 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, typoFindings);
}
- const sectionKickerFindings = checkRepeatedSectionKickersDOM()
+ const sectionKickerFindings = checkKickerAboveHeadingDOM()
.map(f => ({ type: f.id, detail: f.snippet }))
.filter(f => _ruleOk(f.type));
if (sectionKickerFindings.length > 0) {
diff --git a/.opencode/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.opencode/skills/impeccable/scripts/detector/detect-antipatterns-browser.js
index a728d4f9e..3d948ad39 100644
--- a/.opencode/skills/impeccable/scripts/detector/detect-antipatterns-browser.js
+++ b/.opencode/skills/impeccable/scripts/detector/detect-antipatterns-browser.js
@@ -309,15 +309,14 @@ const ANTIPATTERNS = [
skillGuideline: 'tiny uppercase tracked label above the hero headline',
},
{
- id: 'repeated-section-kickers',
+ id: 'kicker-above-heading',
category: 'slop',
scopes: ['type'],
- severity: 'advisory',
- name: 'Repeated section kicker labels',
+ name: 'Kicker / eyebrow label above heading',
description:
- 'Repeating tiny uppercase tracked labels above section headings turns a brand page into AI editorial scaffolding. Replace them with stronger structure, artifacts, imagery, or a deliberate brand system.',
+ 'A tiny tracked uppercase or small-caps label sitting as its own block directly above a heading is banned outright, repeated or not. Generated kickers never earn their place: the heading carries its own weight. Delete the label and let the heading speak; if the words matter, work them into the heading or the body.',
skillSection: 'Typography',
- skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding',
+ skillGuideline: 'kicker or eyebrow labels above headings',
},
{
id: 'numbered-section-labels',
@@ -1258,12 +1257,15 @@ function checkHeroEyebrow(opts) {
}];
}
-function checkRepeatedSectionKickers(opts) {
- const { candidates, minCount = 3 } = opts;
- if (!Array.isArray(candidates) || candidates.length < minCount) return [];
+// Outright ban: one kicker is one too many, so every collected candidate is
+// a finding. The judgment lives in the candidate gate (isKickerCandidate) and
+// the collector's context skips, not in a repetition count.
+function checkKickerAboveHeading(opts) {
+ const { candidates } = opts;
+ if (!Array.isArray(candidates)) return [];
return candidates.map(candidate => ({
- id: 'repeated-section-kickers',
- snippet: `repeated section kicker "${candidate.kickerText}" before ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`,
+ id: 'kicker-above-heading',
+ snippet: `kicker "${candidate.kickerText}" above ${candidate.headingTag} "${candidate.headingText}"`,
}));
}
@@ -3190,7 +3192,7 @@ function parseColorResolved(str, customPropMap) {
return parseAnyColor(resolved);
}
-const REPEATED_KICKER_SKIP_SELECTOR = [
+const KICKER_SKIP_SELECTOR = [
'nav',
'form',
'table',
@@ -3209,7 +3211,7 @@ const REPEATED_KICKER_SKIP_SELECTOR = [
'[data-impeccable-allow-kickers]',
].join(',');
-const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [
+const KICKER_CARD_CONTEXT_SELECTOR = [
'article',
'button',
'a',
@@ -3227,23 +3229,32 @@ function cleanInlineText(el) {
.trim();
}
-function isRepeatedKickerCardContext(heading, kicker) {
- const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR);
+function isKickerCardContext(heading, kicker) {
+ const item = heading.closest?.(KICKER_CARD_CONTEXT_SELECTOR);
return Boolean(item && (!item.contains || item.contains(kicker)));
}
-function isRepeatedKickerCandidate(opts) {
+// Meta lines above headlines join category and date (or path crumbs) with
+// separator glyphs, or carry a year. A kicker is one short phrase; metadata
+// keeps its markers.
+const KICKER_META_TEXT_RE = /[·•|]|\s[\/›»>]\s|\b(19|20)\d{2}\b/;
+// Legal and document numbering: "Section 4.2", "Article IX", "§ 12.3",
+// dotted decimal outlines. The label identifies the clause, so it stays.
+const KICKER_DOC_NUMBERING_RE = /^(§|\d+(\.\d+)+\b|(section|article|clause|appendix|exhibit|schedule|chapter|part|rule|title)\s+([\divxlc]+\b|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)\b)/i;
+
+function isKickerCandidate(opts) {
const {
- headingTag,
+ headingLevel,
headingText,
headingFontSize,
kickerTag,
kickerText,
kickerTextTransform,
+ kickerFontVariant,
kickerFontSize,
kickerLetterSpacing,
} = opts;
- if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
+ if (!headingLevel || headingLevel > 4) return false;
if (!headingText || headingText.length < 3) return false;
if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false;
if (!(headingFontSize >= 20)) return false;
@@ -3251,9 +3262,13 @@ function isRepeatedKickerCandidate(opts) {
if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;
if (!kickerText || kickerText.length < 2 || kickerText.length > 34) return false;
if (/^step\s*\d+/i.test(kickerText) || /^\d{1,2}$/.test(kickerText)) return false;
+ if (KICKER_META_TEXT_RE.test(kickerText)) return false;
+ if (KICKER_DOC_NUMBERING_RE.test(kickerText)) return false;
+ const isSmallCaps = /small-caps/.test(kickerFontVariant || '');
const isUppercased = kickerTextTransform === 'uppercase'
- || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText));
+ || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText))
+ || isSmallCaps;
if (!isUppercased) return false;
if (!(kickerFontSize > 0 && kickerFontSize <= 14)) return false;
const minTrackedSpacing = Math.max(1, kickerFontSize * 0.08);
@@ -3261,37 +3276,64 @@ function isRepeatedKickerCandidate(opts) {
return true;
}
-function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpacing) {
+// Resolve a heading level for the anchor element: 1-4 for h1-h4, aria-level
+// (default 2) for role="heading" elements, 0 otherwise.
+function kickerHeadingLevel(heading) {
+ const tag = heading.tagName.toLowerCase();
+ const byTag = /^h([1-6])$/.exec(tag);
+ if (byTag) return parseInt(byTag[1], 10);
+ const role = heading.getAttribute?.('role') || '';
+ if (role.toLowerCase() !== 'heading') return 0;
+ const ariaLevel = parseInt(heading.getAttribute?.('aria-level') || '', 10);
+ return Number.isFinite(ariaLevel) && ariaLevel >= 1 ? ariaLevel : 2;
+}
+
+function collectKickerCandidates(doc, getStyle, resolveLetterSpacing) {
const candidates = [];
- for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ for (const heading of doc.querySelectorAll('h1, h2, h3, h4, [role="heading"]')) {
+ const headingLevel = kickerHeadingLevel(heading);
+ if (!headingLevel || headingLevel > 4) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ // Application contexts (tab panels, dialogs) use compact context labels
+ // above headings to describe state, not to decorate. Same carve-out the
+ // hero-eyebrow rule makes.
+ if (heading.closest?.('[role="tabpanel"], [role="dialog"], [role="application"], dialog')) continue;
const kicker = heading.previousElementSibling;
- if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
- if (isRepeatedKickerCardContext(heading, kicker)) continue;
+ if (!kicker || kicker.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ if (isKickerCardContext(heading, kicker)) continue;
const headingStyle = getStyle(heading);
const kickerStyle = getStyle(kicker);
+ const headingTag = heading.tagName.toLowerCase();
const headingText = (heading.textContent || '').replace(/\s+/g, ' ').trim();
const kickerText = cleanInlineText(kicker) || (kicker.textContent || '').replace(/\s+/g, ' ').trim();
const headingFontSize = resolveLetterSpacing(headingStyle.fontSize || '', 16) || parseFloat(headingStyle.fontSize) || 0;
const kickerFontSize = resolveLetterSpacing(kickerStyle.fontSize || '', 16) || parseFloat(kickerStyle.fontSize) || 0;
const kickerLetterSpacing = resolveLetterSpacing(kickerStyle.letterSpacing || '', kickerFontSize);
- if (!isRepeatedKickerCandidate({
- headingTag: heading.tagName.toLowerCase(),
+ if (!isKickerCandidate({
+ headingLevel,
headingText,
headingFontSize,
kickerTag: kicker.tagName.toLowerCase(),
kickerText,
kickerTextTransform: kickerStyle.textTransform || '',
+ kickerFontVariant: `${kickerStyle.fontVariant || ''} ${kickerStyle.fontVariantCaps || ''}`,
kickerFontSize,
kickerLetterSpacing,
})) {
continue;
}
+ // A tracked-caps eyebrow above a hero-scale h1 belongs to
+ // hero-eyebrow-chip (which also covers the accent-bold and dash-prefix
+ // stylings there). Stand down so one element gets one finding.
+ if (headingTag === 'h1' && headingFontSize >= 48 && kickerLetterSpacing >= 1.6) {
+ continue;
+ }
+
candidates.push({
- headingTag: heading.tagName.toLowerCase(),
+ headingTag,
headingText: headingText.replace(/^"|"$/g, '').slice(0, 60),
kickerText: kickerText.slice(0, 40),
});
@@ -3299,17 +3341,17 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac
return candidates;
}
-function checkRepeatedSectionKickersDOM() {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingDOM() {
+ const candidates = collectKickerCandidates(
document,
(el) => getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
// ── Numbered section labels ─────────────────────────────────────────────────
-// Sibling of the repeated-kicker rule: instead of a tracked uppercase word,
+// Sibling of the kicker-above-heading rule: instead of a tracked uppercase word,
// the section scaffold is a tiny numeric index riding beside each section
// heading — bare and zero-padded, or an index joined to a short micro-label
// by a separator glyph. The kicker rule deliberately excludes bare 1-2 digit
@@ -3363,7 +3405,7 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
const candidates = [];
const seenLabels = new Set();
for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
// The index sits either directly before the heading, or before the
// wrapper the heading leads (label | …
…).
let label = heading.previousElementSibling;
@@ -3373,9 +3415,9 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
if (firstChild === heading) label = parent.previousElementSibling;
}
if (!label || seenLabels.has(label)) continue;
- if (label.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (label.closest?.(KICKER_SKIP_SELECTOR)) continue;
if (HEADING_TAGS.has(label.tagName.toLowerCase())) continue;
- if (isRepeatedKickerCardContext(heading, label)) continue;
+ if (isKickerCardContext(heading, label)) continue;
const labelText = cleanInlineText(label) || (label.textContent || '').replace(/\s+/g, ' ').trim();
const parsed = parseNumberedLabelText(labelText);
@@ -4548,13 +4590,13 @@ function checkElementHeroEyebrow(el, style, tag, window, customPropMap) {
});
}
-function checkRepeatedSectionKickersFromDoc(doc, win) {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingFromDoc(doc, win) {
+ const candidates = collectKickerCandidates(
doc,
(el) => win.getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
function checkElementMotion(tag, style) {
@@ -7712,7 +7754,7 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, typoFindings);
}
- const sectionKickerFindings = checkRepeatedSectionKickersDOM()
+ const sectionKickerFindings = checkKickerAboveHeadingDOM()
.map(f => ({ type: f.id, detail: f.snippet }))
.filter(f => _ruleOk(f.type));
if (sectionKickerFindings.length > 0) {
diff --git a/.opencode/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.opencode/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs
index 53d7be48c..939eed779 100644
--- a/.opencode/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs
+++ b/.opencode/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs
@@ -223,7 +223,7 @@ function unwrapCssAtLayer(source) {
// ---------------------------------------------------------------------------
const STATIC_INHERITED_PROPS = new Set([
- 'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight',
+ 'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight', 'fontVariant',
'lineHeight', 'letterSpacing', 'textTransform', 'textAlign', 'hyphens',
'webkitHyphens',
]);
@@ -252,6 +252,7 @@ const STATIC_DEFAULT_STYLE = {
fontFamily: '',
fontSize: '16px',
fontStyle: 'normal',
+ fontVariant: 'normal',
fontWeight: '400',
lineHeight: 'normal',
letterSpacing: 'normal',
diff --git a/.opencode/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.opencode/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs
index 1ebb3e427..4e2074042 100644
--- a/.opencode/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs
+++ b/.opencode/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs
@@ -27,11 +27,11 @@ import {
checkElementRadialSpotlight,
checkCreamPalette,
checkHtmlPatterns,
+ checkKickerAboveHeadingFromDoc,
checkNumberedSectionLabelsFromDoc,
checkPageLayout,
checkPageQualityFromDoc,
checkRepeatedContainerTextFromDoc,
- checkRepeatedSectionKickersFromDoc,
resolveBackground,
resolveBorderRadiusPx,
} from '../../rules/checks.mjs';
@@ -202,7 +202,7 @@ async function detectHtml(filePath, options = {}) {
for (const f of runPageCheck('typography-rules', () => checkStaticPageTypography(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
- for (const f of runPageCheck('repeated-section-kickers', () => checkRepeatedSectionKickersFromDoc(document, window))) {
+ for (const f of runPageCheck('kicker-above-heading', () => checkKickerAboveHeadingFromDoc(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of runPageCheck('numbered-section-labels', () => checkNumberedSectionLabelsFromDoc(document, window))) {
diff --git a/.opencode/skills/impeccable/scripts/detector/registry/antipatterns.mjs b/.opencode/skills/impeccable/scripts/detector/registry/antipatterns.mjs
index c47d39524..38ce07390 100644
--- a/.opencode/skills/impeccable/scripts/detector/registry/antipatterns.mjs
+++ b/.opencode/skills/impeccable/scripts/detector/registry/antipatterns.mjs
@@ -198,15 +198,14 @@ const ANTIPATTERNS = [
skillGuideline: 'tiny uppercase tracked label above the hero headline',
},
{
- id: 'repeated-section-kickers',
+ id: 'kicker-above-heading',
category: 'slop',
scopes: ['type'],
- severity: 'advisory',
- name: 'Repeated section kicker labels',
+ name: 'Kicker / eyebrow label above heading',
description:
- 'Repeating tiny uppercase tracked labels above section headings turns a brand page into AI editorial scaffolding. Replace them with stronger structure, artifacts, imagery, or a deliberate brand system.',
+ 'A tiny tracked uppercase or small-caps label sitting as its own block directly above a heading is banned outright, repeated or not. Generated kickers never earn their place: the heading carries its own weight. Delete the label and let the heading speak; if the words matter, work them into the heading or the body.',
skillSection: 'Typography',
- skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding',
+ skillGuideline: 'kicker or eyebrow labels above headings',
},
{
id: 'numbered-section-labels',
diff --git a/.opencode/skills/impeccable/scripts/detector/rules/checks.mjs b/.opencode/skills/impeccable/scripts/detector/rules/checks.mjs
index 3e810076b..3bf26b9b5 100644
--- a/.opencode/skills/impeccable/scripts/detector/rules/checks.mjs
+++ b/.opencode/skills/impeccable/scripts/detector/rules/checks.mjs
@@ -456,12 +456,15 @@ function checkHeroEyebrow(opts) {
}];
}
-function checkRepeatedSectionKickers(opts) {
- const { candidates, minCount = 3 } = opts;
- if (!Array.isArray(candidates) || candidates.length < minCount) return [];
+// Outright ban: one kicker is one too many, so every collected candidate is
+// a finding. The judgment lives in the candidate gate (isKickerCandidate) and
+// the collector's context skips, not in a repetition count.
+function checkKickerAboveHeading(opts) {
+ const { candidates } = opts;
+ if (!Array.isArray(candidates)) return [];
return candidates.map(candidate => ({
- id: 'repeated-section-kickers',
- snippet: `repeated section kicker "${candidate.kickerText}" before ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`,
+ id: 'kicker-above-heading',
+ snippet: `kicker "${candidate.kickerText}" above ${candidate.headingTag} "${candidate.headingText}"`,
}));
}
@@ -2388,7 +2391,7 @@ function parseColorResolved(str, customPropMap) {
return parseAnyColor(resolved);
}
-const REPEATED_KICKER_SKIP_SELECTOR = [
+const KICKER_SKIP_SELECTOR = [
'nav',
'form',
'table',
@@ -2407,7 +2410,7 @@ const REPEATED_KICKER_SKIP_SELECTOR = [
'[data-impeccable-allow-kickers]',
].join(',');
-const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [
+const KICKER_CARD_CONTEXT_SELECTOR = [
'article',
'button',
'a',
@@ -2425,23 +2428,32 @@ function cleanInlineText(el) {
.trim();
}
-function isRepeatedKickerCardContext(heading, kicker) {
- const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR);
+function isKickerCardContext(heading, kicker) {
+ const item = heading.closest?.(KICKER_CARD_CONTEXT_SELECTOR);
return Boolean(item && (!item.contains || item.contains(kicker)));
}
-function isRepeatedKickerCandidate(opts) {
+// Meta lines above headlines join category and date (or path crumbs) with
+// separator glyphs, or carry a year. A kicker is one short phrase; metadata
+// keeps its markers.
+const KICKER_META_TEXT_RE = /[·•|]|\s[\/›»>]\s|\b(19|20)\d{2}\b/;
+// Legal and document numbering: "Section 4.2", "Article IX", "§ 12.3",
+// dotted decimal outlines. The label identifies the clause, so it stays.
+const KICKER_DOC_NUMBERING_RE = /^(§|\d+(\.\d+)+\b|(section|article|clause|appendix|exhibit|schedule|chapter|part|rule|title)\s+([\divxlc]+\b|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)\b)/i;
+
+function isKickerCandidate(opts) {
const {
- headingTag,
+ headingLevel,
headingText,
headingFontSize,
kickerTag,
kickerText,
kickerTextTransform,
+ kickerFontVariant,
kickerFontSize,
kickerLetterSpacing,
} = opts;
- if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
+ if (!headingLevel || headingLevel > 4) return false;
if (!headingText || headingText.length < 3) return false;
if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false;
if (!(headingFontSize >= 20)) return false;
@@ -2449,9 +2461,13 @@ function isRepeatedKickerCandidate(opts) {
if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;
if (!kickerText || kickerText.length < 2 || kickerText.length > 34) return false;
if (/^step\s*\d+/i.test(kickerText) || /^\d{1,2}$/.test(kickerText)) return false;
+ if (KICKER_META_TEXT_RE.test(kickerText)) return false;
+ if (KICKER_DOC_NUMBERING_RE.test(kickerText)) return false;
+ const isSmallCaps = /small-caps/.test(kickerFontVariant || '');
const isUppercased = kickerTextTransform === 'uppercase'
- || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText));
+ || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText))
+ || isSmallCaps;
if (!isUppercased) return false;
if (!(kickerFontSize > 0 && kickerFontSize <= 14)) return false;
const minTrackedSpacing = Math.max(1, kickerFontSize * 0.08);
@@ -2459,37 +2475,64 @@ function isRepeatedKickerCandidate(opts) {
return true;
}
-function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpacing) {
+// Resolve a heading level for the anchor element: 1-4 for h1-h4, aria-level
+// (default 2) for role="heading" elements, 0 otherwise.
+function kickerHeadingLevel(heading) {
+ const tag = heading.tagName.toLowerCase();
+ const byTag = /^h([1-6])$/.exec(tag);
+ if (byTag) return parseInt(byTag[1], 10);
+ const role = heading.getAttribute?.('role') || '';
+ if (role.toLowerCase() !== 'heading') return 0;
+ const ariaLevel = parseInt(heading.getAttribute?.('aria-level') || '', 10);
+ return Number.isFinite(ariaLevel) && ariaLevel >= 1 ? ariaLevel : 2;
+}
+
+function collectKickerCandidates(doc, getStyle, resolveLetterSpacing) {
const candidates = [];
- for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ for (const heading of doc.querySelectorAll('h1, h2, h3, h4, [role="heading"]')) {
+ const headingLevel = kickerHeadingLevel(heading);
+ if (!headingLevel || headingLevel > 4) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ // Application contexts (tab panels, dialogs) use compact context labels
+ // above headings to describe state, not to decorate. Same carve-out the
+ // hero-eyebrow rule makes.
+ if (heading.closest?.('[role="tabpanel"], [role="dialog"], [role="application"], dialog')) continue;
const kicker = heading.previousElementSibling;
- if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
- if (isRepeatedKickerCardContext(heading, kicker)) continue;
+ if (!kicker || kicker.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ if (isKickerCardContext(heading, kicker)) continue;
const headingStyle = getStyle(heading);
const kickerStyle = getStyle(kicker);
+ const headingTag = heading.tagName.toLowerCase();
const headingText = (heading.textContent || '').replace(/\s+/g, ' ').trim();
const kickerText = cleanInlineText(kicker) || (kicker.textContent || '').replace(/\s+/g, ' ').trim();
const headingFontSize = resolveLetterSpacing(headingStyle.fontSize || '', 16) || parseFloat(headingStyle.fontSize) || 0;
const kickerFontSize = resolveLetterSpacing(kickerStyle.fontSize || '', 16) || parseFloat(kickerStyle.fontSize) || 0;
const kickerLetterSpacing = resolveLetterSpacing(kickerStyle.letterSpacing || '', kickerFontSize);
- if (!isRepeatedKickerCandidate({
- headingTag: heading.tagName.toLowerCase(),
+ if (!isKickerCandidate({
+ headingLevel,
headingText,
headingFontSize,
kickerTag: kicker.tagName.toLowerCase(),
kickerText,
kickerTextTransform: kickerStyle.textTransform || '',
+ kickerFontVariant: `${kickerStyle.fontVariant || ''} ${kickerStyle.fontVariantCaps || ''}`,
kickerFontSize,
kickerLetterSpacing,
})) {
continue;
}
+ // A tracked-caps eyebrow above a hero-scale h1 belongs to
+ // hero-eyebrow-chip (which also covers the accent-bold and dash-prefix
+ // stylings there). Stand down so one element gets one finding.
+ if (headingTag === 'h1' && headingFontSize >= 48 && kickerLetterSpacing >= 1.6) {
+ continue;
+ }
+
candidates.push({
- headingTag: heading.tagName.toLowerCase(),
+ headingTag,
headingText: headingText.replace(/^"|"$/g, '').slice(0, 60),
kickerText: kickerText.slice(0, 40),
});
@@ -2497,17 +2540,17 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac
return candidates;
}
-function checkRepeatedSectionKickersDOM() {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingDOM() {
+ const candidates = collectKickerCandidates(
document,
(el) => getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
// ── Numbered section labels ─────────────────────────────────────────────────
-// Sibling of the repeated-kicker rule: instead of a tracked uppercase word,
+// Sibling of the kicker-above-heading rule: instead of a tracked uppercase word,
// the section scaffold is a tiny numeric index riding beside each section
// heading — bare and zero-padded, or an index joined to a short micro-label
// by a separator glyph. The kicker rule deliberately excludes bare 1-2 digit
@@ -2561,7 +2604,7 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
const candidates = [];
const seenLabels = new Set();
for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
// The index sits either directly before the heading, or before the
// wrapper the heading leads (label | …
…).
let label = heading.previousElementSibling;
@@ -2571,9 +2614,9 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
if (firstChild === heading) label = parent.previousElementSibling;
}
if (!label || seenLabels.has(label)) continue;
- if (label.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (label.closest?.(KICKER_SKIP_SELECTOR)) continue;
if (HEADING_TAGS.has(label.tagName.toLowerCase())) continue;
- if (isRepeatedKickerCardContext(heading, label)) continue;
+ if (isKickerCardContext(heading, label)) continue;
const labelText = cleanInlineText(label) || (label.textContent || '').replace(/\s+/g, ' ').trim();
const parsed = parseNumberedLabelText(labelText);
@@ -3746,13 +3789,13 @@ function checkElementHeroEyebrow(el, style, tag, window, customPropMap) {
});
}
-function checkRepeatedSectionKickersFromDoc(doc, win) {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingFromDoc(doc, win) {
+ const candidates = collectKickerCandidates(
doc,
(el) => win.getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
function checkElementMotion(tag, style) {
@@ -5393,7 +5436,7 @@ export {
checkItalicSerif,
isAccentColor,
checkHeroEyebrow,
- checkRepeatedSectionKickers,
+ checkKickerAboveHeading,
checkMotion,
checkGlow,
scanCssTextForGlow,
@@ -5424,9 +5467,9 @@ export {
parseAnyColor,
parseColorResolved,
cleanInlineText,
- isRepeatedKickerCandidate,
- collectRepeatedSectionKickerCandidates,
- checkRepeatedSectionKickersDOM,
+ isKickerCandidate,
+ collectKickerCandidates,
+ checkKickerAboveHeadingDOM,
parseNumberedLabelText,
isNumberedSectionLabelCandidate,
collectNumberedSectionLabelCandidates,
@@ -5458,7 +5501,7 @@ export {
checkElementIconTile,
checkElementItalicSerif,
checkElementHeroEyebrow,
- checkRepeatedSectionKickersFromDoc,
+ checkKickerAboveHeadingFromDoc,
checkElementMotion,
checkElementGlow,
checkTypography,
diff --git a/.pi/skills/impeccable/reference/craft-floor.md b/.pi/skills/impeccable/reference/craft-floor.md
index 3d0c7a8fb..4ea9b8fa4 100644
--- a/.pi/skills/impeccable/reference/craft-floor.md
+++ b/.pi/skills/impeccable/reference/craft-floor.md
@@ -23,7 +23,7 @@ Page scaffolds:
- Same-size cards of icon plus heading plus text as the page structure. Cards are the lazy container; nested cards are always wrong.
- The hero-metric template: big number, small label, supporting stats, accent.
-- A tracked uppercase eyebrow over every section. One named kicker is a system; an eyebrow everywhere is grammar you did not choose.
+- A kicker or eyebrow above a heading. This one is a ban, not a default: no brief earns it back. The heading carries its own weight; delete the label and let the heading speak.
- Section numbers (01 / 02 / 03) unless the sequence itself carries information the reader needs.
- A modal for a task that needs neither interruption nor protected focus.
diff --git a/.pi/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.pi/skills/impeccable/scripts/detector/browser/injected/index.mjs
index e61c536ee..dfc725a8e 100644
--- a/.pi/skills/impeccable/scripts/detector/browser/injected/index.mjs
+++ b/.pi/skills/impeccable/scripts/detector/browser/injected/index.mjs
@@ -1529,7 +1529,7 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, typoFindings);
}
- const sectionKickerFindings = checkRepeatedSectionKickersDOM()
+ const sectionKickerFindings = checkKickerAboveHeadingDOM()
.map(f => ({ type: f.id, detail: f.snippet }))
.filter(f => _ruleOk(f.type));
if (sectionKickerFindings.length > 0) {
diff --git a/.pi/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.pi/skills/impeccable/scripts/detector/detect-antipatterns-browser.js
index a728d4f9e..3d948ad39 100644
--- a/.pi/skills/impeccable/scripts/detector/detect-antipatterns-browser.js
+++ b/.pi/skills/impeccable/scripts/detector/detect-antipatterns-browser.js
@@ -309,15 +309,14 @@ const ANTIPATTERNS = [
skillGuideline: 'tiny uppercase tracked label above the hero headline',
},
{
- id: 'repeated-section-kickers',
+ id: 'kicker-above-heading',
category: 'slop',
scopes: ['type'],
- severity: 'advisory',
- name: 'Repeated section kicker labels',
+ name: 'Kicker / eyebrow label above heading',
description:
- 'Repeating tiny uppercase tracked labels above section headings turns a brand page into AI editorial scaffolding. Replace them with stronger structure, artifacts, imagery, or a deliberate brand system.',
+ 'A tiny tracked uppercase or small-caps label sitting as its own block directly above a heading is banned outright, repeated or not. Generated kickers never earn their place: the heading carries its own weight. Delete the label and let the heading speak; if the words matter, work them into the heading or the body.',
skillSection: 'Typography',
- skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding',
+ skillGuideline: 'kicker or eyebrow labels above headings',
},
{
id: 'numbered-section-labels',
@@ -1258,12 +1257,15 @@ function checkHeroEyebrow(opts) {
}];
}
-function checkRepeatedSectionKickers(opts) {
- const { candidates, minCount = 3 } = opts;
- if (!Array.isArray(candidates) || candidates.length < minCount) return [];
+// Outright ban: one kicker is one too many, so every collected candidate is
+// a finding. The judgment lives in the candidate gate (isKickerCandidate) and
+// the collector's context skips, not in a repetition count.
+function checkKickerAboveHeading(opts) {
+ const { candidates } = opts;
+ if (!Array.isArray(candidates)) return [];
return candidates.map(candidate => ({
- id: 'repeated-section-kickers',
- snippet: `repeated section kicker "${candidate.kickerText}" before ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`,
+ id: 'kicker-above-heading',
+ snippet: `kicker "${candidate.kickerText}" above ${candidate.headingTag} "${candidate.headingText}"`,
}));
}
@@ -3190,7 +3192,7 @@ function parseColorResolved(str, customPropMap) {
return parseAnyColor(resolved);
}
-const REPEATED_KICKER_SKIP_SELECTOR = [
+const KICKER_SKIP_SELECTOR = [
'nav',
'form',
'table',
@@ -3209,7 +3211,7 @@ const REPEATED_KICKER_SKIP_SELECTOR = [
'[data-impeccable-allow-kickers]',
].join(',');
-const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [
+const KICKER_CARD_CONTEXT_SELECTOR = [
'article',
'button',
'a',
@@ -3227,23 +3229,32 @@ function cleanInlineText(el) {
.trim();
}
-function isRepeatedKickerCardContext(heading, kicker) {
- const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR);
+function isKickerCardContext(heading, kicker) {
+ const item = heading.closest?.(KICKER_CARD_CONTEXT_SELECTOR);
return Boolean(item && (!item.contains || item.contains(kicker)));
}
-function isRepeatedKickerCandidate(opts) {
+// Meta lines above headlines join category and date (or path crumbs) with
+// separator glyphs, or carry a year. A kicker is one short phrase; metadata
+// keeps its markers.
+const KICKER_META_TEXT_RE = /[·•|]|\s[\/›»>]\s|\b(19|20)\d{2}\b/;
+// Legal and document numbering: "Section 4.2", "Article IX", "§ 12.3",
+// dotted decimal outlines. The label identifies the clause, so it stays.
+const KICKER_DOC_NUMBERING_RE = /^(§|\d+(\.\d+)+\b|(section|article|clause|appendix|exhibit|schedule|chapter|part|rule|title)\s+([\divxlc]+\b|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)\b)/i;
+
+function isKickerCandidate(opts) {
const {
- headingTag,
+ headingLevel,
headingText,
headingFontSize,
kickerTag,
kickerText,
kickerTextTransform,
+ kickerFontVariant,
kickerFontSize,
kickerLetterSpacing,
} = opts;
- if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
+ if (!headingLevel || headingLevel > 4) return false;
if (!headingText || headingText.length < 3) return false;
if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false;
if (!(headingFontSize >= 20)) return false;
@@ -3251,9 +3262,13 @@ function isRepeatedKickerCandidate(opts) {
if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;
if (!kickerText || kickerText.length < 2 || kickerText.length > 34) return false;
if (/^step\s*\d+/i.test(kickerText) || /^\d{1,2}$/.test(kickerText)) return false;
+ if (KICKER_META_TEXT_RE.test(kickerText)) return false;
+ if (KICKER_DOC_NUMBERING_RE.test(kickerText)) return false;
+ const isSmallCaps = /small-caps/.test(kickerFontVariant || '');
const isUppercased = kickerTextTransform === 'uppercase'
- || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText));
+ || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText))
+ || isSmallCaps;
if (!isUppercased) return false;
if (!(kickerFontSize > 0 && kickerFontSize <= 14)) return false;
const minTrackedSpacing = Math.max(1, kickerFontSize * 0.08);
@@ -3261,37 +3276,64 @@ function isRepeatedKickerCandidate(opts) {
return true;
}
-function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpacing) {
+// Resolve a heading level for the anchor element: 1-4 for h1-h4, aria-level
+// (default 2) for role="heading" elements, 0 otherwise.
+function kickerHeadingLevel(heading) {
+ const tag = heading.tagName.toLowerCase();
+ const byTag = /^h([1-6])$/.exec(tag);
+ if (byTag) return parseInt(byTag[1], 10);
+ const role = heading.getAttribute?.('role') || '';
+ if (role.toLowerCase() !== 'heading') return 0;
+ const ariaLevel = parseInt(heading.getAttribute?.('aria-level') || '', 10);
+ return Number.isFinite(ariaLevel) && ariaLevel >= 1 ? ariaLevel : 2;
+}
+
+function collectKickerCandidates(doc, getStyle, resolveLetterSpacing) {
const candidates = [];
- for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ for (const heading of doc.querySelectorAll('h1, h2, h3, h4, [role="heading"]')) {
+ const headingLevel = kickerHeadingLevel(heading);
+ if (!headingLevel || headingLevel > 4) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ // Application contexts (tab panels, dialogs) use compact context labels
+ // above headings to describe state, not to decorate. Same carve-out the
+ // hero-eyebrow rule makes.
+ if (heading.closest?.('[role="tabpanel"], [role="dialog"], [role="application"], dialog')) continue;
const kicker = heading.previousElementSibling;
- if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
- if (isRepeatedKickerCardContext(heading, kicker)) continue;
+ if (!kicker || kicker.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ if (isKickerCardContext(heading, kicker)) continue;
const headingStyle = getStyle(heading);
const kickerStyle = getStyle(kicker);
+ const headingTag = heading.tagName.toLowerCase();
const headingText = (heading.textContent || '').replace(/\s+/g, ' ').trim();
const kickerText = cleanInlineText(kicker) || (kicker.textContent || '').replace(/\s+/g, ' ').trim();
const headingFontSize = resolveLetterSpacing(headingStyle.fontSize || '', 16) || parseFloat(headingStyle.fontSize) || 0;
const kickerFontSize = resolveLetterSpacing(kickerStyle.fontSize || '', 16) || parseFloat(kickerStyle.fontSize) || 0;
const kickerLetterSpacing = resolveLetterSpacing(kickerStyle.letterSpacing || '', kickerFontSize);
- if (!isRepeatedKickerCandidate({
- headingTag: heading.tagName.toLowerCase(),
+ if (!isKickerCandidate({
+ headingLevel,
headingText,
headingFontSize,
kickerTag: kicker.tagName.toLowerCase(),
kickerText,
kickerTextTransform: kickerStyle.textTransform || '',
+ kickerFontVariant: `${kickerStyle.fontVariant || ''} ${kickerStyle.fontVariantCaps || ''}`,
kickerFontSize,
kickerLetterSpacing,
})) {
continue;
}
+ // A tracked-caps eyebrow above a hero-scale h1 belongs to
+ // hero-eyebrow-chip (which also covers the accent-bold and dash-prefix
+ // stylings there). Stand down so one element gets one finding.
+ if (headingTag === 'h1' && headingFontSize >= 48 && kickerLetterSpacing >= 1.6) {
+ continue;
+ }
+
candidates.push({
- headingTag: heading.tagName.toLowerCase(),
+ headingTag,
headingText: headingText.replace(/^"|"$/g, '').slice(0, 60),
kickerText: kickerText.slice(0, 40),
});
@@ -3299,17 +3341,17 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac
return candidates;
}
-function checkRepeatedSectionKickersDOM() {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingDOM() {
+ const candidates = collectKickerCandidates(
document,
(el) => getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
// ── Numbered section labels ─────────────────────────────────────────────────
-// Sibling of the repeated-kicker rule: instead of a tracked uppercase word,
+// Sibling of the kicker-above-heading rule: instead of a tracked uppercase word,
// the section scaffold is a tiny numeric index riding beside each section
// heading — bare and zero-padded, or an index joined to a short micro-label
// by a separator glyph. The kicker rule deliberately excludes bare 1-2 digit
@@ -3363,7 +3405,7 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
const candidates = [];
const seenLabels = new Set();
for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
// The index sits either directly before the heading, or before the
// wrapper the heading leads (label | …
…).
let label = heading.previousElementSibling;
@@ -3373,9 +3415,9 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
if (firstChild === heading) label = parent.previousElementSibling;
}
if (!label || seenLabels.has(label)) continue;
- if (label.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (label.closest?.(KICKER_SKIP_SELECTOR)) continue;
if (HEADING_TAGS.has(label.tagName.toLowerCase())) continue;
- if (isRepeatedKickerCardContext(heading, label)) continue;
+ if (isKickerCardContext(heading, label)) continue;
const labelText = cleanInlineText(label) || (label.textContent || '').replace(/\s+/g, ' ').trim();
const parsed = parseNumberedLabelText(labelText);
@@ -4548,13 +4590,13 @@ function checkElementHeroEyebrow(el, style, tag, window, customPropMap) {
});
}
-function checkRepeatedSectionKickersFromDoc(doc, win) {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingFromDoc(doc, win) {
+ const candidates = collectKickerCandidates(
doc,
(el) => win.getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
function checkElementMotion(tag, style) {
@@ -7712,7 +7754,7 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, typoFindings);
}
- const sectionKickerFindings = checkRepeatedSectionKickersDOM()
+ const sectionKickerFindings = checkKickerAboveHeadingDOM()
.map(f => ({ type: f.id, detail: f.snippet }))
.filter(f => _ruleOk(f.type));
if (sectionKickerFindings.length > 0) {
diff --git a/.pi/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.pi/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs
index 53d7be48c..939eed779 100644
--- a/.pi/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs
+++ b/.pi/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs
@@ -223,7 +223,7 @@ function unwrapCssAtLayer(source) {
// ---------------------------------------------------------------------------
const STATIC_INHERITED_PROPS = new Set([
- 'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight',
+ 'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight', 'fontVariant',
'lineHeight', 'letterSpacing', 'textTransform', 'textAlign', 'hyphens',
'webkitHyphens',
]);
@@ -252,6 +252,7 @@ const STATIC_DEFAULT_STYLE = {
fontFamily: '',
fontSize: '16px',
fontStyle: 'normal',
+ fontVariant: 'normal',
fontWeight: '400',
lineHeight: 'normal',
letterSpacing: 'normal',
diff --git a/.pi/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.pi/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs
index 1ebb3e427..4e2074042 100644
--- a/.pi/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs
+++ b/.pi/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs
@@ -27,11 +27,11 @@ import {
checkElementRadialSpotlight,
checkCreamPalette,
checkHtmlPatterns,
+ checkKickerAboveHeadingFromDoc,
checkNumberedSectionLabelsFromDoc,
checkPageLayout,
checkPageQualityFromDoc,
checkRepeatedContainerTextFromDoc,
- checkRepeatedSectionKickersFromDoc,
resolveBackground,
resolveBorderRadiusPx,
} from '../../rules/checks.mjs';
@@ -202,7 +202,7 @@ async function detectHtml(filePath, options = {}) {
for (const f of runPageCheck('typography-rules', () => checkStaticPageTypography(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
- for (const f of runPageCheck('repeated-section-kickers', () => checkRepeatedSectionKickersFromDoc(document, window))) {
+ for (const f of runPageCheck('kicker-above-heading', () => checkKickerAboveHeadingFromDoc(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of runPageCheck('numbered-section-labels', () => checkNumberedSectionLabelsFromDoc(document, window))) {
diff --git a/.pi/skills/impeccable/scripts/detector/registry/antipatterns.mjs b/.pi/skills/impeccable/scripts/detector/registry/antipatterns.mjs
index c47d39524..38ce07390 100644
--- a/.pi/skills/impeccable/scripts/detector/registry/antipatterns.mjs
+++ b/.pi/skills/impeccable/scripts/detector/registry/antipatterns.mjs
@@ -198,15 +198,14 @@ const ANTIPATTERNS = [
skillGuideline: 'tiny uppercase tracked label above the hero headline',
},
{
- id: 'repeated-section-kickers',
+ id: 'kicker-above-heading',
category: 'slop',
scopes: ['type'],
- severity: 'advisory',
- name: 'Repeated section kicker labels',
+ name: 'Kicker / eyebrow label above heading',
description:
- 'Repeating tiny uppercase tracked labels above section headings turns a brand page into AI editorial scaffolding. Replace them with stronger structure, artifacts, imagery, or a deliberate brand system.',
+ 'A tiny tracked uppercase or small-caps label sitting as its own block directly above a heading is banned outright, repeated or not. Generated kickers never earn their place: the heading carries its own weight. Delete the label and let the heading speak; if the words matter, work them into the heading or the body.',
skillSection: 'Typography',
- skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding',
+ skillGuideline: 'kicker or eyebrow labels above headings',
},
{
id: 'numbered-section-labels',
diff --git a/.pi/skills/impeccable/scripts/detector/rules/checks.mjs b/.pi/skills/impeccable/scripts/detector/rules/checks.mjs
index 3e810076b..3bf26b9b5 100644
--- a/.pi/skills/impeccable/scripts/detector/rules/checks.mjs
+++ b/.pi/skills/impeccable/scripts/detector/rules/checks.mjs
@@ -456,12 +456,15 @@ function checkHeroEyebrow(opts) {
}];
}
-function checkRepeatedSectionKickers(opts) {
- const { candidates, minCount = 3 } = opts;
- if (!Array.isArray(candidates) || candidates.length < minCount) return [];
+// Outright ban: one kicker is one too many, so every collected candidate is
+// a finding. The judgment lives in the candidate gate (isKickerCandidate) and
+// the collector's context skips, not in a repetition count.
+function checkKickerAboveHeading(opts) {
+ const { candidates } = opts;
+ if (!Array.isArray(candidates)) return [];
return candidates.map(candidate => ({
- id: 'repeated-section-kickers',
- snippet: `repeated section kicker "${candidate.kickerText}" before ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`,
+ id: 'kicker-above-heading',
+ snippet: `kicker "${candidate.kickerText}" above ${candidate.headingTag} "${candidate.headingText}"`,
}));
}
@@ -2388,7 +2391,7 @@ function parseColorResolved(str, customPropMap) {
return parseAnyColor(resolved);
}
-const REPEATED_KICKER_SKIP_SELECTOR = [
+const KICKER_SKIP_SELECTOR = [
'nav',
'form',
'table',
@@ -2407,7 +2410,7 @@ const REPEATED_KICKER_SKIP_SELECTOR = [
'[data-impeccable-allow-kickers]',
].join(',');
-const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [
+const KICKER_CARD_CONTEXT_SELECTOR = [
'article',
'button',
'a',
@@ -2425,23 +2428,32 @@ function cleanInlineText(el) {
.trim();
}
-function isRepeatedKickerCardContext(heading, kicker) {
- const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR);
+function isKickerCardContext(heading, kicker) {
+ const item = heading.closest?.(KICKER_CARD_CONTEXT_SELECTOR);
return Boolean(item && (!item.contains || item.contains(kicker)));
}
-function isRepeatedKickerCandidate(opts) {
+// Meta lines above headlines join category and date (or path crumbs) with
+// separator glyphs, or carry a year. A kicker is one short phrase; metadata
+// keeps its markers.
+const KICKER_META_TEXT_RE = /[·•|]|\s[\/›»>]\s|\b(19|20)\d{2}\b/;
+// Legal and document numbering: "Section 4.2", "Article IX", "§ 12.3",
+// dotted decimal outlines. The label identifies the clause, so it stays.
+const KICKER_DOC_NUMBERING_RE = /^(§|\d+(\.\d+)+\b|(section|article|clause|appendix|exhibit|schedule|chapter|part|rule|title)\s+([\divxlc]+\b|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)\b)/i;
+
+function isKickerCandidate(opts) {
const {
- headingTag,
+ headingLevel,
headingText,
headingFontSize,
kickerTag,
kickerText,
kickerTextTransform,
+ kickerFontVariant,
kickerFontSize,
kickerLetterSpacing,
} = opts;
- if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
+ if (!headingLevel || headingLevel > 4) return false;
if (!headingText || headingText.length < 3) return false;
if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false;
if (!(headingFontSize >= 20)) return false;
@@ -2449,9 +2461,13 @@ function isRepeatedKickerCandidate(opts) {
if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;
if (!kickerText || kickerText.length < 2 || kickerText.length > 34) return false;
if (/^step\s*\d+/i.test(kickerText) || /^\d{1,2}$/.test(kickerText)) return false;
+ if (KICKER_META_TEXT_RE.test(kickerText)) return false;
+ if (KICKER_DOC_NUMBERING_RE.test(kickerText)) return false;
+ const isSmallCaps = /small-caps/.test(kickerFontVariant || '');
const isUppercased = kickerTextTransform === 'uppercase'
- || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText));
+ || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText))
+ || isSmallCaps;
if (!isUppercased) return false;
if (!(kickerFontSize > 0 && kickerFontSize <= 14)) return false;
const minTrackedSpacing = Math.max(1, kickerFontSize * 0.08);
@@ -2459,37 +2475,64 @@ function isRepeatedKickerCandidate(opts) {
return true;
}
-function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpacing) {
+// Resolve a heading level for the anchor element: 1-4 for h1-h4, aria-level
+// (default 2) for role="heading" elements, 0 otherwise.
+function kickerHeadingLevel(heading) {
+ const tag = heading.tagName.toLowerCase();
+ const byTag = /^h([1-6])$/.exec(tag);
+ if (byTag) return parseInt(byTag[1], 10);
+ const role = heading.getAttribute?.('role') || '';
+ if (role.toLowerCase() !== 'heading') return 0;
+ const ariaLevel = parseInt(heading.getAttribute?.('aria-level') || '', 10);
+ return Number.isFinite(ariaLevel) && ariaLevel >= 1 ? ariaLevel : 2;
+}
+
+function collectKickerCandidates(doc, getStyle, resolveLetterSpacing) {
const candidates = [];
- for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ for (const heading of doc.querySelectorAll('h1, h2, h3, h4, [role="heading"]')) {
+ const headingLevel = kickerHeadingLevel(heading);
+ if (!headingLevel || headingLevel > 4) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ // Application contexts (tab panels, dialogs) use compact context labels
+ // above headings to describe state, not to decorate. Same carve-out the
+ // hero-eyebrow rule makes.
+ if (heading.closest?.('[role="tabpanel"], [role="dialog"], [role="application"], dialog')) continue;
const kicker = heading.previousElementSibling;
- if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
- if (isRepeatedKickerCardContext(heading, kicker)) continue;
+ if (!kicker || kicker.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ if (isKickerCardContext(heading, kicker)) continue;
const headingStyle = getStyle(heading);
const kickerStyle = getStyle(kicker);
+ const headingTag = heading.tagName.toLowerCase();
const headingText = (heading.textContent || '').replace(/\s+/g, ' ').trim();
const kickerText = cleanInlineText(kicker) || (kicker.textContent || '').replace(/\s+/g, ' ').trim();
const headingFontSize = resolveLetterSpacing(headingStyle.fontSize || '', 16) || parseFloat(headingStyle.fontSize) || 0;
const kickerFontSize = resolveLetterSpacing(kickerStyle.fontSize || '', 16) || parseFloat(kickerStyle.fontSize) || 0;
const kickerLetterSpacing = resolveLetterSpacing(kickerStyle.letterSpacing || '', kickerFontSize);
- if (!isRepeatedKickerCandidate({
- headingTag: heading.tagName.toLowerCase(),
+ if (!isKickerCandidate({
+ headingLevel,
headingText,
headingFontSize,
kickerTag: kicker.tagName.toLowerCase(),
kickerText,
kickerTextTransform: kickerStyle.textTransform || '',
+ kickerFontVariant: `${kickerStyle.fontVariant || ''} ${kickerStyle.fontVariantCaps || ''}`,
kickerFontSize,
kickerLetterSpacing,
})) {
continue;
}
+ // A tracked-caps eyebrow above a hero-scale h1 belongs to
+ // hero-eyebrow-chip (which also covers the accent-bold and dash-prefix
+ // stylings there). Stand down so one element gets one finding.
+ if (headingTag === 'h1' && headingFontSize >= 48 && kickerLetterSpacing >= 1.6) {
+ continue;
+ }
+
candidates.push({
- headingTag: heading.tagName.toLowerCase(),
+ headingTag,
headingText: headingText.replace(/^"|"$/g, '').slice(0, 60),
kickerText: kickerText.slice(0, 40),
});
@@ -2497,17 +2540,17 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac
return candidates;
}
-function checkRepeatedSectionKickersDOM() {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingDOM() {
+ const candidates = collectKickerCandidates(
document,
(el) => getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
// ── Numbered section labels ─────────────────────────────────────────────────
-// Sibling of the repeated-kicker rule: instead of a tracked uppercase word,
+// Sibling of the kicker-above-heading rule: instead of a tracked uppercase word,
// the section scaffold is a tiny numeric index riding beside each section
// heading — bare and zero-padded, or an index joined to a short micro-label
// by a separator glyph. The kicker rule deliberately excludes bare 1-2 digit
@@ -2561,7 +2604,7 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
const candidates = [];
const seenLabels = new Set();
for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
// The index sits either directly before the heading, or before the
// wrapper the heading leads (label | …
…).
let label = heading.previousElementSibling;
@@ -2571,9 +2614,9 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
if (firstChild === heading) label = parent.previousElementSibling;
}
if (!label || seenLabels.has(label)) continue;
- if (label.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (label.closest?.(KICKER_SKIP_SELECTOR)) continue;
if (HEADING_TAGS.has(label.tagName.toLowerCase())) continue;
- if (isRepeatedKickerCardContext(heading, label)) continue;
+ if (isKickerCardContext(heading, label)) continue;
const labelText = cleanInlineText(label) || (label.textContent || '').replace(/\s+/g, ' ').trim();
const parsed = parseNumberedLabelText(labelText);
@@ -3746,13 +3789,13 @@ function checkElementHeroEyebrow(el, style, tag, window, customPropMap) {
});
}
-function checkRepeatedSectionKickersFromDoc(doc, win) {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingFromDoc(doc, win) {
+ const candidates = collectKickerCandidates(
doc,
(el) => win.getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
function checkElementMotion(tag, style) {
@@ -5393,7 +5436,7 @@ export {
checkItalicSerif,
isAccentColor,
checkHeroEyebrow,
- checkRepeatedSectionKickers,
+ checkKickerAboveHeading,
checkMotion,
checkGlow,
scanCssTextForGlow,
@@ -5424,9 +5467,9 @@ export {
parseAnyColor,
parseColorResolved,
cleanInlineText,
- isRepeatedKickerCandidate,
- collectRepeatedSectionKickerCandidates,
- checkRepeatedSectionKickersDOM,
+ isKickerCandidate,
+ collectKickerCandidates,
+ checkKickerAboveHeadingDOM,
parseNumberedLabelText,
isNumberedSectionLabelCandidate,
collectNumberedSectionLabelCandidates,
@@ -5458,7 +5501,7 @@ export {
checkElementIconTile,
checkElementItalicSerif,
checkElementHeroEyebrow,
- checkRepeatedSectionKickersFromDoc,
+ checkKickerAboveHeadingFromDoc,
checkElementMotion,
checkElementGlow,
checkTypography,
diff --git a/.qoder/skills/impeccable/reference/craft-floor.md b/.qoder/skills/impeccable/reference/craft-floor.md
index 3d0c7a8fb..4ea9b8fa4 100644
--- a/.qoder/skills/impeccable/reference/craft-floor.md
+++ b/.qoder/skills/impeccable/reference/craft-floor.md
@@ -23,7 +23,7 @@ Page scaffolds:
- Same-size cards of icon plus heading plus text as the page structure. Cards are the lazy container; nested cards are always wrong.
- The hero-metric template: big number, small label, supporting stats, accent.
-- A tracked uppercase eyebrow over every section. One named kicker is a system; an eyebrow everywhere is grammar you did not choose.
+- A kicker or eyebrow above a heading. This one is a ban, not a default: no brief earns it back. The heading carries its own weight; delete the label and let the heading speak.
- Section numbers (01 / 02 / 03) unless the sequence itself carries information the reader needs.
- A modal for a task that needs neither interruption nor protected focus.
diff --git a/.qoder/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.qoder/skills/impeccable/scripts/detector/browser/injected/index.mjs
index e61c536ee..dfc725a8e 100644
--- a/.qoder/skills/impeccable/scripts/detector/browser/injected/index.mjs
+++ b/.qoder/skills/impeccable/scripts/detector/browser/injected/index.mjs
@@ -1529,7 +1529,7 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, typoFindings);
}
- const sectionKickerFindings = checkRepeatedSectionKickersDOM()
+ const sectionKickerFindings = checkKickerAboveHeadingDOM()
.map(f => ({ type: f.id, detail: f.snippet }))
.filter(f => _ruleOk(f.type));
if (sectionKickerFindings.length > 0) {
diff --git a/.qoder/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.qoder/skills/impeccable/scripts/detector/detect-antipatterns-browser.js
index a728d4f9e..3d948ad39 100644
--- a/.qoder/skills/impeccable/scripts/detector/detect-antipatterns-browser.js
+++ b/.qoder/skills/impeccable/scripts/detector/detect-antipatterns-browser.js
@@ -309,15 +309,14 @@ const ANTIPATTERNS = [
skillGuideline: 'tiny uppercase tracked label above the hero headline',
},
{
- id: 'repeated-section-kickers',
+ id: 'kicker-above-heading',
category: 'slop',
scopes: ['type'],
- severity: 'advisory',
- name: 'Repeated section kicker labels',
+ name: 'Kicker / eyebrow label above heading',
description:
- 'Repeating tiny uppercase tracked labels above section headings turns a brand page into AI editorial scaffolding. Replace them with stronger structure, artifacts, imagery, or a deliberate brand system.',
+ 'A tiny tracked uppercase or small-caps label sitting as its own block directly above a heading is banned outright, repeated or not. Generated kickers never earn their place: the heading carries its own weight. Delete the label and let the heading speak; if the words matter, work them into the heading or the body.',
skillSection: 'Typography',
- skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding',
+ skillGuideline: 'kicker or eyebrow labels above headings',
},
{
id: 'numbered-section-labels',
@@ -1258,12 +1257,15 @@ function checkHeroEyebrow(opts) {
}];
}
-function checkRepeatedSectionKickers(opts) {
- const { candidates, minCount = 3 } = opts;
- if (!Array.isArray(candidates) || candidates.length < minCount) return [];
+// Outright ban: one kicker is one too many, so every collected candidate is
+// a finding. The judgment lives in the candidate gate (isKickerCandidate) and
+// the collector's context skips, not in a repetition count.
+function checkKickerAboveHeading(opts) {
+ const { candidates } = opts;
+ if (!Array.isArray(candidates)) return [];
return candidates.map(candidate => ({
- id: 'repeated-section-kickers',
- snippet: `repeated section kicker "${candidate.kickerText}" before ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`,
+ id: 'kicker-above-heading',
+ snippet: `kicker "${candidate.kickerText}" above ${candidate.headingTag} "${candidate.headingText}"`,
}));
}
@@ -3190,7 +3192,7 @@ function parseColorResolved(str, customPropMap) {
return parseAnyColor(resolved);
}
-const REPEATED_KICKER_SKIP_SELECTOR = [
+const KICKER_SKIP_SELECTOR = [
'nav',
'form',
'table',
@@ -3209,7 +3211,7 @@ const REPEATED_KICKER_SKIP_SELECTOR = [
'[data-impeccable-allow-kickers]',
].join(',');
-const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [
+const KICKER_CARD_CONTEXT_SELECTOR = [
'article',
'button',
'a',
@@ -3227,23 +3229,32 @@ function cleanInlineText(el) {
.trim();
}
-function isRepeatedKickerCardContext(heading, kicker) {
- const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR);
+function isKickerCardContext(heading, kicker) {
+ const item = heading.closest?.(KICKER_CARD_CONTEXT_SELECTOR);
return Boolean(item && (!item.contains || item.contains(kicker)));
}
-function isRepeatedKickerCandidate(opts) {
+// Meta lines above headlines join category and date (or path crumbs) with
+// separator glyphs, or carry a year. A kicker is one short phrase; metadata
+// keeps its markers.
+const KICKER_META_TEXT_RE = /[·•|]|\s[\/›»>]\s|\b(19|20)\d{2}\b/;
+// Legal and document numbering: "Section 4.2", "Article IX", "§ 12.3",
+// dotted decimal outlines. The label identifies the clause, so it stays.
+const KICKER_DOC_NUMBERING_RE = /^(§|\d+(\.\d+)+\b|(section|article|clause|appendix|exhibit|schedule|chapter|part|rule|title)\s+([\divxlc]+\b|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)\b)/i;
+
+function isKickerCandidate(opts) {
const {
- headingTag,
+ headingLevel,
headingText,
headingFontSize,
kickerTag,
kickerText,
kickerTextTransform,
+ kickerFontVariant,
kickerFontSize,
kickerLetterSpacing,
} = opts;
- if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
+ if (!headingLevel || headingLevel > 4) return false;
if (!headingText || headingText.length < 3) return false;
if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false;
if (!(headingFontSize >= 20)) return false;
@@ -3251,9 +3262,13 @@ function isRepeatedKickerCandidate(opts) {
if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;
if (!kickerText || kickerText.length < 2 || kickerText.length > 34) return false;
if (/^step\s*\d+/i.test(kickerText) || /^\d{1,2}$/.test(kickerText)) return false;
+ if (KICKER_META_TEXT_RE.test(kickerText)) return false;
+ if (KICKER_DOC_NUMBERING_RE.test(kickerText)) return false;
+ const isSmallCaps = /small-caps/.test(kickerFontVariant || '');
const isUppercased = kickerTextTransform === 'uppercase'
- || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText));
+ || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText))
+ || isSmallCaps;
if (!isUppercased) return false;
if (!(kickerFontSize > 0 && kickerFontSize <= 14)) return false;
const minTrackedSpacing = Math.max(1, kickerFontSize * 0.08);
@@ -3261,37 +3276,64 @@ function isRepeatedKickerCandidate(opts) {
return true;
}
-function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpacing) {
+// Resolve a heading level for the anchor element: 1-4 for h1-h4, aria-level
+// (default 2) for role="heading" elements, 0 otherwise.
+function kickerHeadingLevel(heading) {
+ const tag = heading.tagName.toLowerCase();
+ const byTag = /^h([1-6])$/.exec(tag);
+ if (byTag) return parseInt(byTag[1], 10);
+ const role = heading.getAttribute?.('role') || '';
+ if (role.toLowerCase() !== 'heading') return 0;
+ const ariaLevel = parseInt(heading.getAttribute?.('aria-level') || '', 10);
+ return Number.isFinite(ariaLevel) && ariaLevel >= 1 ? ariaLevel : 2;
+}
+
+function collectKickerCandidates(doc, getStyle, resolveLetterSpacing) {
const candidates = [];
- for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ for (const heading of doc.querySelectorAll('h1, h2, h3, h4, [role="heading"]')) {
+ const headingLevel = kickerHeadingLevel(heading);
+ if (!headingLevel || headingLevel > 4) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ // Application contexts (tab panels, dialogs) use compact context labels
+ // above headings to describe state, not to decorate. Same carve-out the
+ // hero-eyebrow rule makes.
+ if (heading.closest?.('[role="tabpanel"], [role="dialog"], [role="application"], dialog')) continue;
const kicker = heading.previousElementSibling;
- if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
- if (isRepeatedKickerCardContext(heading, kicker)) continue;
+ if (!kicker || kicker.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ if (isKickerCardContext(heading, kicker)) continue;
const headingStyle = getStyle(heading);
const kickerStyle = getStyle(kicker);
+ const headingTag = heading.tagName.toLowerCase();
const headingText = (heading.textContent || '').replace(/\s+/g, ' ').trim();
const kickerText = cleanInlineText(kicker) || (kicker.textContent || '').replace(/\s+/g, ' ').trim();
const headingFontSize = resolveLetterSpacing(headingStyle.fontSize || '', 16) || parseFloat(headingStyle.fontSize) || 0;
const kickerFontSize = resolveLetterSpacing(kickerStyle.fontSize || '', 16) || parseFloat(kickerStyle.fontSize) || 0;
const kickerLetterSpacing = resolveLetterSpacing(kickerStyle.letterSpacing || '', kickerFontSize);
- if (!isRepeatedKickerCandidate({
- headingTag: heading.tagName.toLowerCase(),
+ if (!isKickerCandidate({
+ headingLevel,
headingText,
headingFontSize,
kickerTag: kicker.tagName.toLowerCase(),
kickerText,
kickerTextTransform: kickerStyle.textTransform || '',
+ kickerFontVariant: `${kickerStyle.fontVariant || ''} ${kickerStyle.fontVariantCaps || ''}`,
kickerFontSize,
kickerLetterSpacing,
})) {
continue;
}
+ // A tracked-caps eyebrow above a hero-scale h1 belongs to
+ // hero-eyebrow-chip (which also covers the accent-bold and dash-prefix
+ // stylings there). Stand down so one element gets one finding.
+ if (headingTag === 'h1' && headingFontSize >= 48 && kickerLetterSpacing >= 1.6) {
+ continue;
+ }
+
candidates.push({
- headingTag: heading.tagName.toLowerCase(),
+ headingTag,
headingText: headingText.replace(/^"|"$/g, '').slice(0, 60),
kickerText: kickerText.slice(0, 40),
});
@@ -3299,17 +3341,17 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac
return candidates;
}
-function checkRepeatedSectionKickersDOM() {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingDOM() {
+ const candidates = collectKickerCandidates(
document,
(el) => getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
// ── Numbered section labels ─────────────────────────────────────────────────
-// Sibling of the repeated-kicker rule: instead of a tracked uppercase word,
+// Sibling of the kicker-above-heading rule: instead of a tracked uppercase word,
// the section scaffold is a tiny numeric index riding beside each section
// heading — bare and zero-padded, or an index joined to a short micro-label
// by a separator glyph. The kicker rule deliberately excludes bare 1-2 digit
@@ -3363,7 +3405,7 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
const candidates = [];
const seenLabels = new Set();
for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
// The index sits either directly before the heading, or before the
// wrapper the heading leads (label | …
…).
let label = heading.previousElementSibling;
@@ -3373,9 +3415,9 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
if (firstChild === heading) label = parent.previousElementSibling;
}
if (!label || seenLabels.has(label)) continue;
- if (label.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (label.closest?.(KICKER_SKIP_SELECTOR)) continue;
if (HEADING_TAGS.has(label.tagName.toLowerCase())) continue;
- if (isRepeatedKickerCardContext(heading, label)) continue;
+ if (isKickerCardContext(heading, label)) continue;
const labelText = cleanInlineText(label) || (label.textContent || '').replace(/\s+/g, ' ').trim();
const parsed = parseNumberedLabelText(labelText);
@@ -4548,13 +4590,13 @@ function checkElementHeroEyebrow(el, style, tag, window, customPropMap) {
});
}
-function checkRepeatedSectionKickersFromDoc(doc, win) {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingFromDoc(doc, win) {
+ const candidates = collectKickerCandidates(
doc,
(el) => win.getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
function checkElementMotion(tag, style) {
@@ -7712,7 +7754,7 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, typoFindings);
}
- const sectionKickerFindings = checkRepeatedSectionKickersDOM()
+ const sectionKickerFindings = checkKickerAboveHeadingDOM()
.map(f => ({ type: f.id, detail: f.snippet }))
.filter(f => _ruleOk(f.type));
if (sectionKickerFindings.length > 0) {
diff --git a/.qoder/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.qoder/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs
index 53d7be48c..939eed779 100644
--- a/.qoder/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs
+++ b/.qoder/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs
@@ -223,7 +223,7 @@ function unwrapCssAtLayer(source) {
// ---------------------------------------------------------------------------
const STATIC_INHERITED_PROPS = new Set([
- 'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight',
+ 'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight', 'fontVariant',
'lineHeight', 'letterSpacing', 'textTransform', 'textAlign', 'hyphens',
'webkitHyphens',
]);
@@ -252,6 +252,7 @@ const STATIC_DEFAULT_STYLE = {
fontFamily: '',
fontSize: '16px',
fontStyle: 'normal',
+ fontVariant: 'normal',
fontWeight: '400',
lineHeight: 'normal',
letterSpacing: 'normal',
diff --git a/.qoder/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.qoder/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs
index 1ebb3e427..4e2074042 100644
--- a/.qoder/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs
+++ b/.qoder/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs
@@ -27,11 +27,11 @@ import {
checkElementRadialSpotlight,
checkCreamPalette,
checkHtmlPatterns,
+ checkKickerAboveHeadingFromDoc,
checkNumberedSectionLabelsFromDoc,
checkPageLayout,
checkPageQualityFromDoc,
checkRepeatedContainerTextFromDoc,
- checkRepeatedSectionKickersFromDoc,
resolveBackground,
resolveBorderRadiusPx,
} from '../../rules/checks.mjs';
@@ -202,7 +202,7 @@ async function detectHtml(filePath, options = {}) {
for (const f of runPageCheck('typography-rules', () => checkStaticPageTypography(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
- for (const f of runPageCheck('repeated-section-kickers', () => checkRepeatedSectionKickersFromDoc(document, window))) {
+ for (const f of runPageCheck('kicker-above-heading', () => checkKickerAboveHeadingFromDoc(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of runPageCheck('numbered-section-labels', () => checkNumberedSectionLabelsFromDoc(document, window))) {
diff --git a/.qoder/skills/impeccable/scripts/detector/registry/antipatterns.mjs b/.qoder/skills/impeccable/scripts/detector/registry/antipatterns.mjs
index c47d39524..38ce07390 100644
--- a/.qoder/skills/impeccable/scripts/detector/registry/antipatterns.mjs
+++ b/.qoder/skills/impeccable/scripts/detector/registry/antipatterns.mjs
@@ -198,15 +198,14 @@ const ANTIPATTERNS = [
skillGuideline: 'tiny uppercase tracked label above the hero headline',
},
{
- id: 'repeated-section-kickers',
+ id: 'kicker-above-heading',
category: 'slop',
scopes: ['type'],
- severity: 'advisory',
- name: 'Repeated section kicker labels',
+ name: 'Kicker / eyebrow label above heading',
description:
- 'Repeating tiny uppercase tracked labels above section headings turns a brand page into AI editorial scaffolding. Replace them with stronger structure, artifacts, imagery, or a deliberate brand system.',
+ 'A tiny tracked uppercase or small-caps label sitting as its own block directly above a heading is banned outright, repeated or not. Generated kickers never earn their place: the heading carries its own weight. Delete the label and let the heading speak; if the words matter, work them into the heading or the body.',
skillSection: 'Typography',
- skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding',
+ skillGuideline: 'kicker or eyebrow labels above headings',
},
{
id: 'numbered-section-labels',
diff --git a/.qoder/skills/impeccable/scripts/detector/rules/checks.mjs b/.qoder/skills/impeccable/scripts/detector/rules/checks.mjs
index 3e810076b..3bf26b9b5 100644
--- a/.qoder/skills/impeccable/scripts/detector/rules/checks.mjs
+++ b/.qoder/skills/impeccable/scripts/detector/rules/checks.mjs
@@ -456,12 +456,15 @@ function checkHeroEyebrow(opts) {
}];
}
-function checkRepeatedSectionKickers(opts) {
- const { candidates, minCount = 3 } = opts;
- if (!Array.isArray(candidates) || candidates.length < minCount) return [];
+// Outright ban: one kicker is one too many, so every collected candidate is
+// a finding. The judgment lives in the candidate gate (isKickerCandidate) and
+// the collector's context skips, not in a repetition count.
+function checkKickerAboveHeading(opts) {
+ const { candidates } = opts;
+ if (!Array.isArray(candidates)) return [];
return candidates.map(candidate => ({
- id: 'repeated-section-kickers',
- snippet: `repeated section kicker "${candidate.kickerText}" before ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`,
+ id: 'kicker-above-heading',
+ snippet: `kicker "${candidate.kickerText}" above ${candidate.headingTag} "${candidate.headingText}"`,
}));
}
@@ -2388,7 +2391,7 @@ function parseColorResolved(str, customPropMap) {
return parseAnyColor(resolved);
}
-const REPEATED_KICKER_SKIP_SELECTOR = [
+const KICKER_SKIP_SELECTOR = [
'nav',
'form',
'table',
@@ -2407,7 +2410,7 @@ const REPEATED_KICKER_SKIP_SELECTOR = [
'[data-impeccable-allow-kickers]',
].join(',');
-const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [
+const KICKER_CARD_CONTEXT_SELECTOR = [
'article',
'button',
'a',
@@ -2425,23 +2428,32 @@ function cleanInlineText(el) {
.trim();
}
-function isRepeatedKickerCardContext(heading, kicker) {
- const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR);
+function isKickerCardContext(heading, kicker) {
+ const item = heading.closest?.(KICKER_CARD_CONTEXT_SELECTOR);
return Boolean(item && (!item.contains || item.contains(kicker)));
}
-function isRepeatedKickerCandidate(opts) {
+// Meta lines above headlines join category and date (or path crumbs) with
+// separator glyphs, or carry a year. A kicker is one short phrase; metadata
+// keeps its markers.
+const KICKER_META_TEXT_RE = /[·•|]|\s[\/›»>]\s|\b(19|20)\d{2}\b/;
+// Legal and document numbering: "Section 4.2", "Article IX", "§ 12.3",
+// dotted decimal outlines. The label identifies the clause, so it stays.
+const KICKER_DOC_NUMBERING_RE = /^(§|\d+(\.\d+)+\b|(section|article|clause|appendix|exhibit|schedule|chapter|part|rule|title)\s+([\divxlc]+\b|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)\b)/i;
+
+function isKickerCandidate(opts) {
const {
- headingTag,
+ headingLevel,
headingText,
headingFontSize,
kickerTag,
kickerText,
kickerTextTransform,
+ kickerFontVariant,
kickerFontSize,
kickerLetterSpacing,
} = opts;
- if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
+ if (!headingLevel || headingLevel > 4) return false;
if (!headingText || headingText.length < 3) return false;
if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false;
if (!(headingFontSize >= 20)) return false;
@@ -2449,9 +2461,13 @@ function isRepeatedKickerCandidate(opts) {
if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;
if (!kickerText || kickerText.length < 2 || kickerText.length > 34) return false;
if (/^step\s*\d+/i.test(kickerText) || /^\d{1,2}$/.test(kickerText)) return false;
+ if (KICKER_META_TEXT_RE.test(kickerText)) return false;
+ if (KICKER_DOC_NUMBERING_RE.test(kickerText)) return false;
+ const isSmallCaps = /small-caps/.test(kickerFontVariant || '');
const isUppercased = kickerTextTransform === 'uppercase'
- || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText));
+ || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText))
+ || isSmallCaps;
if (!isUppercased) return false;
if (!(kickerFontSize > 0 && kickerFontSize <= 14)) return false;
const minTrackedSpacing = Math.max(1, kickerFontSize * 0.08);
@@ -2459,37 +2475,64 @@ function isRepeatedKickerCandidate(opts) {
return true;
}
-function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpacing) {
+// Resolve a heading level for the anchor element: 1-4 for h1-h4, aria-level
+// (default 2) for role="heading" elements, 0 otherwise.
+function kickerHeadingLevel(heading) {
+ const tag = heading.tagName.toLowerCase();
+ const byTag = /^h([1-6])$/.exec(tag);
+ if (byTag) return parseInt(byTag[1], 10);
+ const role = heading.getAttribute?.('role') || '';
+ if (role.toLowerCase() !== 'heading') return 0;
+ const ariaLevel = parseInt(heading.getAttribute?.('aria-level') || '', 10);
+ return Number.isFinite(ariaLevel) && ariaLevel >= 1 ? ariaLevel : 2;
+}
+
+function collectKickerCandidates(doc, getStyle, resolveLetterSpacing) {
const candidates = [];
- for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ for (const heading of doc.querySelectorAll('h1, h2, h3, h4, [role="heading"]')) {
+ const headingLevel = kickerHeadingLevel(heading);
+ if (!headingLevel || headingLevel > 4) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ // Application contexts (tab panels, dialogs) use compact context labels
+ // above headings to describe state, not to decorate. Same carve-out the
+ // hero-eyebrow rule makes.
+ if (heading.closest?.('[role="tabpanel"], [role="dialog"], [role="application"], dialog')) continue;
const kicker = heading.previousElementSibling;
- if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
- if (isRepeatedKickerCardContext(heading, kicker)) continue;
+ if (!kicker || kicker.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ if (isKickerCardContext(heading, kicker)) continue;
const headingStyle = getStyle(heading);
const kickerStyle = getStyle(kicker);
+ const headingTag = heading.tagName.toLowerCase();
const headingText = (heading.textContent || '').replace(/\s+/g, ' ').trim();
const kickerText = cleanInlineText(kicker) || (kicker.textContent || '').replace(/\s+/g, ' ').trim();
const headingFontSize = resolveLetterSpacing(headingStyle.fontSize || '', 16) || parseFloat(headingStyle.fontSize) || 0;
const kickerFontSize = resolveLetterSpacing(kickerStyle.fontSize || '', 16) || parseFloat(kickerStyle.fontSize) || 0;
const kickerLetterSpacing = resolveLetterSpacing(kickerStyle.letterSpacing || '', kickerFontSize);
- if (!isRepeatedKickerCandidate({
- headingTag: heading.tagName.toLowerCase(),
+ if (!isKickerCandidate({
+ headingLevel,
headingText,
headingFontSize,
kickerTag: kicker.tagName.toLowerCase(),
kickerText,
kickerTextTransform: kickerStyle.textTransform || '',
+ kickerFontVariant: `${kickerStyle.fontVariant || ''} ${kickerStyle.fontVariantCaps || ''}`,
kickerFontSize,
kickerLetterSpacing,
})) {
continue;
}
+ // A tracked-caps eyebrow above a hero-scale h1 belongs to
+ // hero-eyebrow-chip (which also covers the accent-bold and dash-prefix
+ // stylings there). Stand down so one element gets one finding.
+ if (headingTag === 'h1' && headingFontSize >= 48 && kickerLetterSpacing >= 1.6) {
+ continue;
+ }
+
candidates.push({
- headingTag: heading.tagName.toLowerCase(),
+ headingTag,
headingText: headingText.replace(/^"|"$/g, '').slice(0, 60),
kickerText: kickerText.slice(0, 40),
});
@@ -2497,17 +2540,17 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac
return candidates;
}
-function checkRepeatedSectionKickersDOM() {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingDOM() {
+ const candidates = collectKickerCandidates(
document,
(el) => getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
// ── Numbered section labels ─────────────────────────────────────────────────
-// Sibling of the repeated-kicker rule: instead of a tracked uppercase word,
+// Sibling of the kicker-above-heading rule: instead of a tracked uppercase word,
// the section scaffold is a tiny numeric index riding beside each section
// heading — bare and zero-padded, or an index joined to a short micro-label
// by a separator glyph. The kicker rule deliberately excludes bare 1-2 digit
@@ -2561,7 +2604,7 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
const candidates = [];
const seenLabels = new Set();
for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
// The index sits either directly before the heading, or before the
// wrapper the heading leads (label | …
…).
let label = heading.previousElementSibling;
@@ -2571,9 +2614,9 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
if (firstChild === heading) label = parent.previousElementSibling;
}
if (!label || seenLabels.has(label)) continue;
- if (label.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (label.closest?.(KICKER_SKIP_SELECTOR)) continue;
if (HEADING_TAGS.has(label.tagName.toLowerCase())) continue;
- if (isRepeatedKickerCardContext(heading, label)) continue;
+ if (isKickerCardContext(heading, label)) continue;
const labelText = cleanInlineText(label) || (label.textContent || '').replace(/\s+/g, ' ').trim();
const parsed = parseNumberedLabelText(labelText);
@@ -3746,13 +3789,13 @@ function checkElementHeroEyebrow(el, style, tag, window, customPropMap) {
});
}
-function checkRepeatedSectionKickersFromDoc(doc, win) {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingFromDoc(doc, win) {
+ const candidates = collectKickerCandidates(
doc,
(el) => win.getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
function checkElementMotion(tag, style) {
@@ -5393,7 +5436,7 @@ export {
checkItalicSerif,
isAccentColor,
checkHeroEyebrow,
- checkRepeatedSectionKickers,
+ checkKickerAboveHeading,
checkMotion,
checkGlow,
scanCssTextForGlow,
@@ -5424,9 +5467,9 @@ export {
parseAnyColor,
parseColorResolved,
cleanInlineText,
- isRepeatedKickerCandidate,
- collectRepeatedSectionKickerCandidates,
- checkRepeatedSectionKickersDOM,
+ isKickerCandidate,
+ collectKickerCandidates,
+ checkKickerAboveHeadingDOM,
parseNumberedLabelText,
isNumberedSectionLabelCandidate,
collectNumberedSectionLabelCandidates,
@@ -5458,7 +5501,7 @@ export {
checkElementIconTile,
checkElementItalicSerif,
checkElementHeroEyebrow,
- checkRepeatedSectionKickersFromDoc,
+ checkKickerAboveHeadingFromDoc,
checkElementMotion,
checkElementGlow,
checkTypography,
diff --git a/.rovodev/skills/impeccable/reference/craft-floor.md b/.rovodev/skills/impeccable/reference/craft-floor.md
index 3d0c7a8fb..4ea9b8fa4 100644
--- a/.rovodev/skills/impeccable/reference/craft-floor.md
+++ b/.rovodev/skills/impeccable/reference/craft-floor.md
@@ -23,7 +23,7 @@ Page scaffolds:
- Same-size cards of icon plus heading plus text as the page structure. Cards are the lazy container; nested cards are always wrong.
- The hero-metric template: big number, small label, supporting stats, accent.
-- A tracked uppercase eyebrow over every section. One named kicker is a system; an eyebrow everywhere is grammar you did not choose.
+- A kicker or eyebrow above a heading. This one is a ban, not a default: no brief earns it back. The heading carries its own weight; delete the label and let the heading speak.
- Section numbers (01 / 02 / 03) unless the sequence itself carries information the reader needs.
- A modal for a task that needs neither interruption nor protected focus.
diff --git a/.rovodev/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.rovodev/skills/impeccable/scripts/detector/browser/injected/index.mjs
index e61c536ee..dfc725a8e 100644
--- a/.rovodev/skills/impeccable/scripts/detector/browser/injected/index.mjs
+++ b/.rovodev/skills/impeccable/scripts/detector/browser/injected/index.mjs
@@ -1529,7 +1529,7 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, typoFindings);
}
- const sectionKickerFindings = checkRepeatedSectionKickersDOM()
+ const sectionKickerFindings = checkKickerAboveHeadingDOM()
.map(f => ({ type: f.id, detail: f.snippet }))
.filter(f => _ruleOk(f.type));
if (sectionKickerFindings.length > 0) {
diff --git a/.rovodev/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.rovodev/skills/impeccable/scripts/detector/detect-antipatterns-browser.js
index a728d4f9e..3d948ad39 100644
--- a/.rovodev/skills/impeccable/scripts/detector/detect-antipatterns-browser.js
+++ b/.rovodev/skills/impeccable/scripts/detector/detect-antipatterns-browser.js
@@ -309,15 +309,14 @@ const ANTIPATTERNS = [
skillGuideline: 'tiny uppercase tracked label above the hero headline',
},
{
- id: 'repeated-section-kickers',
+ id: 'kicker-above-heading',
category: 'slop',
scopes: ['type'],
- severity: 'advisory',
- name: 'Repeated section kicker labels',
+ name: 'Kicker / eyebrow label above heading',
description:
- 'Repeating tiny uppercase tracked labels above section headings turns a brand page into AI editorial scaffolding. Replace them with stronger structure, artifacts, imagery, or a deliberate brand system.',
+ 'A tiny tracked uppercase or small-caps label sitting as its own block directly above a heading is banned outright, repeated or not. Generated kickers never earn their place: the heading carries its own weight. Delete the label and let the heading speak; if the words matter, work them into the heading or the body.',
skillSection: 'Typography',
- skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding',
+ skillGuideline: 'kicker or eyebrow labels above headings',
},
{
id: 'numbered-section-labels',
@@ -1258,12 +1257,15 @@ function checkHeroEyebrow(opts) {
}];
}
-function checkRepeatedSectionKickers(opts) {
- const { candidates, minCount = 3 } = opts;
- if (!Array.isArray(candidates) || candidates.length < minCount) return [];
+// Outright ban: one kicker is one too many, so every collected candidate is
+// a finding. The judgment lives in the candidate gate (isKickerCandidate) and
+// the collector's context skips, not in a repetition count.
+function checkKickerAboveHeading(opts) {
+ const { candidates } = opts;
+ if (!Array.isArray(candidates)) return [];
return candidates.map(candidate => ({
- id: 'repeated-section-kickers',
- snippet: `repeated section kicker "${candidate.kickerText}" before ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`,
+ id: 'kicker-above-heading',
+ snippet: `kicker "${candidate.kickerText}" above ${candidate.headingTag} "${candidate.headingText}"`,
}));
}
@@ -3190,7 +3192,7 @@ function parseColorResolved(str, customPropMap) {
return parseAnyColor(resolved);
}
-const REPEATED_KICKER_SKIP_SELECTOR = [
+const KICKER_SKIP_SELECTOR = [
'nav',
'form',
'table',
@@ -3209,7 +3211,7 @@ const REPEATED_KICKER_SKIP_SELECTOR = [
'[data-impeccable-allow-kickers]',
].join(',');
-const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [
+const KICKER_CARD_CONTEXT_SELECTOR = [
'article',
'button',
'a',
@@ -3227,23 +3229,32 @@ function cleanInlineText(el) {
.trim();
}
-function isRepeatedKickerCardContext(heading, kicker) {
- const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR);
+function isKickerCardContext(heading, kicker) {
+ const item = heading.closest?.(KICKER_CARD_CONTEXT_SELECTOR);
return Boolean(item && (!item.contains || item.contains(kicker)));
}
-function isRepeatedKickerCandidate(opts) {
+// Meta lines above headlines join category and date (or path crumbs) with
+// separator glyphs, or carry a year. A kicker is one short phrase; metadata
+// keeps its markers.
+const KICKER_META_TEXT_RE = /[·•|]|\s[\/›»>]\s|\b(19|20)\d{2}\b/;
+// Legal and document numbering: "Section 4.2", "Article IX", "§ 12.3",
+// dotted decimal outlines. The label identifies the clause, so it stays.
+const KICKER_DOC_NUMBERING_RE = /^(§|\d+(\.\d+)+\b|(section|article|clause|appendix|exhibit|schedule|chapter|part|rule|title)\s+([\divxlc]+\b|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)\b)/i;
+
+function isKickerCandidate(opts) {
const {
- headingTag,
+ headingLevel,
headingText,
headingFontSize,
kickerTag,
kickerText,
kickerTextTransform,
+ kickerFontVariant,
kickerFontSize,
kickerLetterSpacing,
} = opts;
- if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
+ if (!headingLevel || headingLevel > 4) return false;
if (!headingText || headingText.length < 3) return false;
if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false;
if (!(headingFontSize >= 20)) return false;
@@ -3251,9 +3262,13 @@ function isRepeatedKickerCandidate(opts) {
if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;
if (!kickerText || kickerText.length < 2 || kickerText.length > 34) return false;
if (/^step\s*\d+/i.test(kickerText) || /^\d{1,2}$/.test(kickerText)) return false;
+ if (KICKER_META_TEXT_RE.test(kickerText)) return false;
+ if (KICKER_DOC_NUMBERING_RE.test(kickerText)) return false;
+ const isSmallCaps = /small-caps/.test(kickerFontVariant || '');
const isUppercased = kickerTextTransform === 'uppercase'
- || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText));
+ || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText))
+ || isSmallCaps;
if (!isUppercased) return false;
if (!(kickerFontSize > 0 && kickerFontSize <= 14)) return false;
const minTrackedSpacing = Math.max(1, kickerFontSize * 0.08);
@@ -3261,37 +3276,64 @@ function isRepeatedKickerCandidate(opts) {
return true;
}
-function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpacing) {
+// Resolve a heading level for the anchor element: 1-4 for h1-h4, aria-level
+// (default 2) for role="heading" elements, 0 otherwise.
+function kickerHeadingLevel(heading) {
+ const tag = heading.tagName.toLowerCase();
+ const byTag = /^h([1-6])$/.exec(tag);
+ if (byTag) return parseInt(byTag[1], 10);
+ const role = heading.getAttribute?.('role') || '';
+ if (role.toLowerCase() !== 'heading') return 0;
+ const ariaLevel = parseInt(heading.getAttribute?.('aria-level') || '', 10);
+ return Number.isFinite(ariaLevel) && ariaLevel >= 1 ? ariaLevel : 2;
+}
+
+function collectKickerCandidates(doc, getStyle, resolveLetterSpacing) {
const candidates = [];
- for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ for (const heading of doc.querySelectorAll('h1, h2, h3, h4, [role="heading"]')) {
+ const headingLevel = kickerHeadingLevel(heading);
+ if (!headingLevel || headingLevel > 4) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ // Application contexts (tab panels, dialogs) use compact context labels
+ // above headings to describe state, not to decorate. Same carve-out the
+ // hero-eyebrow rule makes.
+ if (heading.closest?.('[role="tabpanel"], [role="dialog"], [role="application"], dialog')) continue;
const kicker = heading.previousElementSibling;
- if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
- if (isRepeatedKickerCardContext(heading, kicker)) continue;
+ if (!kicker || kicker.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ if (isKickerCardContext(heading, kicker)) continue;
const headingStyle = getStyle(heading);
const kickerStyle = getStyle(kicker);
+ const headingTag = heading.tagName.toLowerCase();
const headingText = (heading.textContent || '').replace(/\s+/g, ' ').trim();
const kickerText = cleanInlineText(kicker) || (kicker.textContent || '').replace(/\s+/g, ' ').trim();
const headingFontSize = resolveLetterSpacing(headingStyle.fontSize || '', 16) || parseFloat(headingStyle.fontSize) || 0;
const kickerFontSize = resolveLetterSpacing(kickerStyle.fontSize || '', 16) || parseFloat(kickerStyle.fontSize) || 0;
const kickerLetterSpacing = resolveLetterSpacing(kickerStyle.letterSpacing || '', kickerFontSize);
- if (!isRepeatedKickerCandidate({
- headingTag: heading.tagName.toLowerCase(),
+ if (!isKickerCandidate({
+ headingLevel,
headingText,
headingFontSize,
kickerTag: kicker.tagName.toLowerCase(),
kickerText,
kickerTextTransform: kickerStyle.textTransform || '',
+ kickerFontVariant: `${kickerStyle.fontVariant || ''} ${kickerStyle.fontVariantCaps || ''}`,
kickerFontSize,
kickerLetterSpacing,
})) {
continue;
}
+ // A tracked-caps eyebrow above a hero-scale h1 belongs to
+ // hero-eyebrow-chip (which also covers the accent-bold and dash-prefix
+ // stylings there). Stand down so one element gets one finding.
+ if (headingTag === 'h1' && headingFontSize >= 48 && kickerLetterSpacing >= 1.6) {
+ continue;
+ }
+
candidates.push({
- headingTag: heading.tagName.toLowerCase(),
+ headingTag,
headingText: headingText.replace(/^"|"$/g, '').slice(0, 60),
kickerText: kickerText.slice(0, 40),
});
@@ -3299,17 +3341,17 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac
return candidates;
}
-function checkRepeatedSectionKickersDOM() {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingDOM() {
+ const candidates = collectKickerCandidates(
document,
(el) => getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
// ── Numbered section labels ─────────────────────────────────────────────────
-// Sibling of the repeated-kicker rule: instead of a tracked uppercase word,
+// Sibling of the kicker-above-heading rule: instead of a tracked uppercase word,
// the section scaffold is a tiny numeric index riding beside each section
// heading — bare and zero-padded, or an index joined to a short micro-label
// by a separator glyph. The kicker rule deliberately excludes bare 1-2 digit
@@ -3363,7 +3405,7 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
const candidates = [];
const seenLabels = new Set();
for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
// The index sits either directly before the heading, or before the
// wrapper the heading leads (label | …
…).
let label = heading.previousElementSibling;
@@ -3373,9 +3415,9 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
if (firstChild === heading) label = parent.previousElementSibling;
}
if (!label || seenLabels.has(label)) continue;
- if (label.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (label.closest?.(KICKER_SKIP_SELECTOR)) continue;
if (HEADING_TAGS.has(label.tagName.toLowerCase())) continue;
- if (isRepeatedKickerCardContext(heading, label)) continue;
+ if (isKickerCardContext(heading, label)) continue;
const labelText = cleanInlineText(label) || (label.textContent || '').replace(/\s+/g, ' ').trim();
const parsed = parseNumberedLabelText(labelText);
@@ -4548,13 +4590,13 @@ function checkElementHeroEyebrow(el, style, tag, window, customPropMap) {
});
}
-function checkRepeatedSectionKickersFromDoc(doc, win) {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingFromDoc(doc, win) {
+ const candidates = collectKickerCandidates(
doc,
(el) => win.getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
function checkElementMotion(tag, style) {
@@ -7712,7 +7754,7 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, typoFindings);
}
- const sectionKickerFindings = checkRepeatedSectionKickersDOM()
+ const sectionKickerFindings = checkKickerAboveHeadingDOM()
.map(f => ({ type: f.id, detail: f.snippet }))
.filter(f => _ruleOk(f.type));
if (sectionKickerFindings.length > 0) {
diff --git a/.rovodev/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.rovodev/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs
index 53d7be48c..939eed779 100644
--- a/.rovodev/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs
+++ b/.rovodev/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs
@@ -223,7 +223,7 @@ function unwrapCssAtLayer(source) {
// ---------------------------------------------------------------------------
const STATIC_INHERITED_PROPS = new Set([
- 'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight',
+ 'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight', 'fontVariant',
'lineHeight', 'letterSpacing', 'textTransform', 'textAlign', 'hyphens',
'webkitHyphens',
]);
@@ -252,6 +252,7 @@ const STATIC_DEFAULT_STYLE = {
fontFamily: '',
fontSize: '16px',
fontStyle: 'normal',
+ fontVariant: 'normal',
fontWeight: '400',
lineHeight: 'normal',
letterSpacing: 'normal',
diff --git a/.rovodev/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.rovodev/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs
index 1ebb3e427..4e2074042 100644
--- a/.rovodev/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs
+++ b/.rovodev/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs
@@ -27,11 +27,11 @@ import {
checkElementRadialSpotlight,
checkCreamPalette,
checkHtmlPatterns,
+ checkKickerAboveHeadingFromDoc,
checkNumberedSectionLabelsFromDoc,
checkPageLayout,
checkPageQualityFromDoc,
checkRepeatedContainerTextFromDoc,
- checkRepeatedSectionKickersFromDoc,
resolveBackground,
resolveBorderRadiusPx,
} from '../../rules/checks.mjs';
@@ -202,7 +202,7 @@ async function detectHtml(filePath, options = {}) {
for (const f of runPageCheck('typography-rules', () => checkStaticPageTypography(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
- for (const f of runPageCheck('repeated-section-kickers', () => checkRepeatedSectionKickersFromDoc(document, window))) {
+ for (const f of runPageCheck('kicker-above-heading', () => checkKickerAboveHeadingFromDoc(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of runPageCheck('numbered-section-labels', () => checkNumberedSectionLabelsFromDoc(document, window))) {
diff --git a/.rovodev/skills/impeccable/scripts/detector/registry/antipatterns.mjs b/.rovodev/skills/impeccable/scripts/detector/registry/antipatterns.mjs
index c47d39524..38ce07390 100644
--- a/.rovodev/skills/impeccable/scripts/detector/registry/antipatterns.mjs
+++ b/.rovodev/skills/impeccable/scripts/detector/registry/antipatterns.mjs
@@ -198,15 +198,14 @@ const ANTIPATTERNS = [
skillGuideline: 'tiny uppercase tracked label above the hero headline',
},
{
- id: 'repeated-section-kickers',
+ id: 'kicker-above-heading',
category: 'slop',
scopes: ['type'],
- severity: 'advisory',
- name: 'Repeated section kicker labels',
+ name: 'Kicker / eyebrow label above heading',
description:
- 'Repeating tiny uppercase tracked labels above section headings turns a brand page into AI editorial scaffolding. Replace them with stronger structure, artifacts, imagery, or a deliberate brand system.',
+ 'A tiny tracked uppercase or small-caps label sitting as its own block directly above a heading is banned outright, repeated or not. Generated kickers never earn their place: the heading carries its own weight. Delete the label and let the heading speak; if the words matter, work them into the heading or the body.',
skillSection: 'Typography',
- skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding',
+ skillGuideline: 'kicker or eyebrow labels above headings',
},
{
id: 'numbered-section-labels',
diff --git a/.rovodev/skills/impeccable/scripts/detector/rules/checks.mjs b/.rovodev/skills/impeccable/scripts/detector/rules/checks.mjs
index 3e810076b..3bf26b9b5 100644
--- a/.rovodev/skills/impeccable/scripts/detector/rules/checks.mjs
+++ b/.rovodev/skills/impeccable/scripts/detector/rules/checks.mjs
@@ -456,12 +456,15 @@ function checkHeroEyebrow(opts) {
}];
}
-function checkRepeatedSectionKickers(opts) {
- const { candidates, minCount = 3 } = opts;
- if (!Array.isArray(candidates) || candidates.length < minCount) return [];
+// Outright ban: one kicker is one too many, so every collected candidate is
+// a finding. The judgment lives in the candidate gate (isKickerCandidate) and
+// the collector's context skips, not in a repetition count.
+function checkKickerAboveHeading(opts) {
+ const { candidates } = opts;
+ if (!Array.isArray(candidates)) return [];
return candidates.map(candidate => ({
- id: 'repeated-section-kickers',
- snippet: `repeated section kicker "${candidate.kickerText}" before ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`,
+ id: 'kicker-above-heading',
+ snippet: `kicker "${candidate.kickerText}" above ${candidate.headingTag} "${candidate.headingText}"`,
}));
}
@@ -2388,7 +2391,7 @@ function parseColorResolved(str, customPropMap) {
return parseAnyColor(resolved);
}
-const REPEATED_KICKER_SKIP_SELECTOR = [
+const KICKER_SKIP_SELECTOR = [
'nav',
'form',
'table',
@@ -2407,7 +2410,7 @@ const REPEATED_KICKER_SKIP_SELECTOR = [
'[data-impeccable-allow-kickers]',
].join(',');
-const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [
+const KICKER_CARD_CONTEXT_SELECTOR = [
'article',
'button',
'a',
@@ -2425,23 +2428,32 @@ function cleanInlineText(el) {
.trim();
}
-function isRepeatedKickerCardContext(heading, kicker) {
- const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR);
+function isKickerCardContext(heading, kicker) {
+ const item = heading.closest?.(KICKER_CARD_CONTEXT_SELECTOR);
return Boolean(item && (!item.contains || item.contains(kicker)));
}
-function isRepeatedKickerCandidate(opts) {
+// Meta lines above headlines join category and date (or path crumbs) with
+// separator glyphs, or carry a year. A kicker is one short phrase; metadata
+// keeps its markers.
+const KICKER_META_TEXT_RE = /[·•|]|\s[\/›»>]\s|\b(19|20)\d{2}\b/;
+// Legal and document numbering: "Section 4.2", "Article IX", "§ 12.3",
+// dotted decimal outlines. The label identifies the clause, so it stays.
+const KICKER_DOC_NUMBERING_RE = /^(§|\d+(\.\d+)+\b|(section|article|clause|appendix|exhibit|schedule|chapter|part|rule|title)\s+([\divxlc]+\b|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)\b)/i;
+
+function isKickerCandidate(opts) {
const {
- headingTag,
+ headingLevel,
headingText,
headingFontSize,
kickerTag,
kickerText,
kickerTextTransform,
+ kickerFontVariant,
kickerFontSize,
kickerLetterSpacing,
} = opts;
- if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
+ if (!headingLevel || headingLevel > 4) return false;
if (!headingText || headingText.length < 3) return false;
if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false;
if (!(headingFontSize >= 20)) return false;
@@ -2449,9 +2461,13 @@ function isRepeatedKickerCandidate(opts) {
if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;
if (!kickerText || kickerText.length < 2 || kickerText.length > 34) return false;
if (/^step\s*\d+/i.test(kickerText) || /^\d{1,2}$/.test(kickerText)) return false;
+ if (KICKER_META_TEXT_RE.test(kickerText)) return false;
+ if (KICKER_DOC_NUMBERING_RE.test(kickerText)) return false;
+ const isSmallCaps = /small-caps/.test(kickerFontVariant || '');
const isUppercased = kickerTextTransform === 'uppercase'
- || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText));
+ || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText))
+ || isSmallCaps;
if (!isUppercased) return false;
if (!(kickerFontSize > 0 && kickerFontSize <= 14)) return false;
const minTrackedSpacing = Math.max(1, kickerFontSize * 0.08);
@@ -2459,37 +2475,64 @@ function isRepeatedKickerCandidate(opts) {
return true;
}
-function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpacing) {
+// Resolve a heading level for the anchor element: 1-4 for h1-h4, aria-level
+// (default 2) for role="heading" elements, 0 otherwise.
+function kickerHeadingLevel(heading) {
+ const tag = heading.tagName.toLowerCase();
+ const byTag = /^h([1-6])$/.exec(tag);
+ if (byTag) return parseInt(byTag[1], 10);
+ const role = heading.getAttribute?.('role') || '';
+ if (role.toLowerCase() !== 'heading') return 0;
+ const ariaLevel = parseInt(heading.getAttribute?.('aria-level') || '', 10);
+ return Number.isFinite(ariaLevel) && ariaLevel >= 1 ? ariaLevel : 2;
+}
+
+function collectKickerCandidates(doc, getStyle, resolveLetterSpacing) {
const candidates = [];
- for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ for (const heading of doc.querySelectorAll('h1, h2, h3, h4, [role="heading"]')) {
+ const headingLevel = kickerHeadingLevel(heading);
+ if (!headingLevel || headingLevel > 4) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ // Application contexts (tab panels, dialogs) use compact context labels
+ // above headings to describe state, not to decorate. Same carve-out the
+ // hero-eyebrow rule makes.
+ if (heading.closest?.('[role="tabpanel"], [role="dialog"], [role="application"], dialog')) continue;
const kicker = heading.previousElementSibling;
- if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
- if (isRepeatedKickerCardContext(heading, kicker)) continue;
+ if (!kicker || kicker.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ if (isKickerCardContext(heading, kicker)) continue;
const headingStyle = getStyle(heading);
const kickerStyle = getStyle(kicker);
+ const headingTag = heading.tagName.toLowerCase();
const headingText = (heading.textContent || '').replace(/\s+/g, ' ').trim();
const kickerText = cleanInlineText(kicker) || (kicker.textContent || '').replace(/\s+/g, ' ').trim();
const headingFontSize = resolveLetterSpacing(headingStyle.fontSize || '', 16) || parseFloat(headingStyle.fontSize) || 0;
const kickerFontSize = resolveLetterSpacing(kickerStyle.fontSize || '', 16) || parseFloat(kickerStyle.fontSize) || 0;
const kickerLetterSpacing = resolveLetterSpacing(kickerStyle.letterSpacing || '', kickerFontSize);
- if (!isRepeatedKickerCandidate({
- headingTag: heading.tagName.toLowerCase(),
+ if (!isKickerCandidate({
+ headingLevel,
headingText,
headingFontSize,
kickerTag: kicker.tagName.toLowerCase(),
kickerText,
kickerTextTransform: kickerStyle.textTransform || '',
+ kickerFontVariant: `${kickerStyle.fontVariant || ''} ${kickerStyle.fontVariantCaps || ''}`,
kickerFontSize,
kickerLetterSpacing,
})) {
continue;
}
+ // A tracked-caps eyebrow above a hero-scale h1 belongs to
+ // hero-eyebrow-chip (which also covers the accent-bold and dash-prefix
+ // stylings there). Stand down so one element gets one finding.
+ if (headingTag === 'h1' && headingFontSize >= 48 && kickerLetterSpacing >= 1.6) {
+ continue;
+ }
+
candidates.push({
- headingTag: heading.tagName.toLowerCase(),
+ headingTag,
headingText: headingText.replace(/^"|"$/g, '').slice(0, 60),
kickerText: kickerText.slice(0, 40),
});
@@ -2497,17 +2540,17 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac
return candidates;
}
-function checkRepeatedSectionKickersDOM() {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingDOM() {
+ const candidates = collectKickerCandidates(
document,
(el) => getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
// ── Numbered section labels ─────────────────────────────────────────────────
-// Sibling of the repeated-kicker rule: instead of a tracked uppercase word,
+// Sibling of the kicker-above-heading rule: instead of a tracked uppercase word,
// the section scaffold is a tiny numeric index riding beside each section
// heading — bare and zero-padded, or an index joined to a short micro-label
// by a separator glyph. The kicker rule deliberately excludes bare 1-2 digit
@@ -2561,7 +2604,7 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
const candidates = [];
const seenLabels = new Set();
for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
// The index sits either directly before the heading, or before the
// wrapper the heading leads (label | …
…).
let label = heading.previousElementSibling;
@@ -2571,9 +2614,9 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
if (firstChild === heading) label = parent.previousElementSibling;
}
if (!label || seenLabels.has(label)) continue;
- if (label.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (label.closest?.(KICKER_SKIP_SELECTOR)) continue;
if (HEADING_TAGS.has(label.tagName.toLowerCase())) continue;
- if (isRepeatedKickerCardContext(heading, label)) continue;
+ if (isKickerCardContext(heading, label)) continue;
const labelText = cleanInlineText(label) || (label.textContent || '').replace(/\s+/g, ' ').trim();
const parsed = parseNumberedLabelText(labelText);
@@ -3746,13 +3789,13 @@ function checkElementHeroEyebrow(el, style, tag, window, customPropMap) {
});
}
-function checkRepeatedSectionKickersFromDoc(doc, win) {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingFromDoc(doc, win) {
+ const candidates = collectKickerCandidates(
doc,
(el) => win.getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
function checkElementMotion(tag, style) {
@@ -5393,7 +5436,7 @@ export {
checkItalicSerif,
isAccentColor,
checkHeroEyebrow,
- checkRepeatedSectionKickers,
+ checkKickerAboveHeading,
checkMotion,
checkGlow,
scanCssTextForGlow,
@@ -5424,9 +5467,9 @@ export {
parseAnyColor,
parseColorResolved,
cleanInlineText,
- isRepeatedKickerCandidate,
- collectRepeatedSectionKickerCandidates,
- checkRepeatedSectionKickersDOM,
+ isKickerCandidate,
+ collectKickerCandidates,
+ checkKickerAboveHeadingDOM,
parseNumberedLabelText,
isNumberedSectionLabelCandidate,
collectNumberedSectionLabelCandidates,
@@ -5458,7 +5501,7 @@ export {
checkElementIconTile,
checkElementItalicSerif,
checkElementHeroEyebrow,
- checkRepeatedSectionKickersFromDoc,
+ checkKickerAboveHeadingFromDoc,
checkElementMotion,
checkElementGlow,
checkTypography,
diff --git a/.trae-cn/skills/impeccable/reference/craft-floor.md b/.trae-cn/skills/impeccable/reference/craft-floor.md
index 3d0c7a8fb..4ea9b8fa4 100644
--- a/.trae-cn/skills/impeccable/reference/craft-floor.md
+++ b/.trae-cn/skills/impeccable/reference/craft-floor.md
@@ -23,7 +23,7 @@ Page scaffolds:
- Same-size cards of icon plus heading plus text as the page structure. Cards are the lazy container; nested cards are always wrong.
- The hero-metric template: big number, small label, supporting stats, accent.
-- A tracked uppercase eyebrow over every section. One named kicker is a system; an eyebrow everywhere is grammar you did not choose.
+- A kicker or eyebrow above a heading. This one is a ban, not a default: no brief earns it back. The heading carries its own weight; delete the label and let the heading speak.
- Section numbers (01 / 02 / 03) unless the sequence itself carries information the reader needs.
- A modal for a task that needs neither interruption nor protected focus.
diff --git a/.trae-cn/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.trae-cn/skills/impeccable/scripts/detector/browser/injected/index.mjs
index e61c536ee..dfc725a8e 100644
--- a/.trae-cn/skills/impeccable/scripts/detector/browser/injected/index.mjs
+++ b/.trae-cn/skills/impeccable/scripts/detector/browser/injected/index.mjs
@@ -1529,7 +1529,7 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, typoFindings);
}
- const sectionKickerFindings = checkRepeatedSectionKickersDOM()
+ const sectionKickerFindings = checkKickerAboveHeadingDOM()
.map(f => ({ type: f.id, detail: f.snippet }))
.filter(f => _ruleOk(f.type));
if (sectionKickerFindings.length > 0) {
diff --git a/.trae-cn/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.trae-cn/skills/impeccable/scripts/detector/detect-antipatterns-browser.js
index a728d4f9e..3d948ad39 100644
--- a/.trae-cn/skills/impeccable/scripts/detector/detect-antipatterns-browser.js
+++ b/.trae-cn/skills/impeccable/scripts/detector/detect-antipatterns-browser.js
@@ -309,15 +309,14 @@ const ANTIPATTERNS = [
skillGuideline: 'tiny uppercase tracked label above the hero headline',
},
{
- id: 'repeated-section-kickers',
+ id: 'kicker-above-heading',
category: 'slop',
scopes: ['type'],
- severity: 'advisory',
- name: 'Repeated section kicker labels',
+ name: 'Kicker / eyebrow label above heading',
description:
- 'Repeating tiny uppercase tracked labels above section headings turns a brand page into AI editorial scaffolding. Replace them with stronger structure, artifacts, imagery, or a deliberate brand system.',
+ 'A tiny tracked uppercase or small-caps label sitting as its own block directly above a heading is banned outright, repeated or not. Generated kickers never earn their place: the heading carries its own weight. Delete the label and let the heading speak; if the words matter, work them into the heading or the body.',
skillSection: 'Typography',
- skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding',
+ skillGuideline: 'kicker or eyebrow labels above headings',
},
{
id: 'numbered-section-labels',
@@ -1258,12 +1257,15 @@ function checkHeroEyebrow(opts) {
}];
}
-function checkRepeatedSectionKickers(opts) {
- const { candidates, minCount = 3 } = opts;
- if (!Array.isArray(candidates) || candidates.length < minCount) return [];
+// Outright ban: one kicker is one too many, so every collected candidate is
+// a finding. The judgment lives in the candidate gate (isKickerCandidate) and
+// the collector's context skips, not in a repetition count.
+function checkKickerAboveHeading(opts) {
+ const { candidates } = opts;
+ if (!Array.isArray(candidates)) return [];
return candidates.map(candidate => ({
- id: 'repeated-section-kickers',
- snippet: `repeated section kicker "${candidate.kickerText}" before ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`,
+ id: 'kicker-above-heading',
+ snippet: `kicker "${candidate.kickerText}" above ${candidate.headingTag} "${candidate.headingText}"`,
}));
}
@@ -3190,7 +3192,7 @@ function parseColorResolved(str, customPropMap) {
return parseAnyColor(resolved);
}
-const REPEATED_KICKER_SKIP_SELECTOR = [
+const KICKER_SKIP_SELECTOR = [
'nav',
'form',
'table',
@@ -3209,7 +3211,7 @@ const REPEATED_KICKER_SKIP_SELECTOR = [
'[data-impeccable-allow-kickers]',
].join(',');
-const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [
+const KICKER_CARD_CONTEXT_SELECTOR = [
'article',
'button',
'a',
@@ -3227,23 +3229,32 @@ function cleanInlineText(el) {
.trim();
}
-function isRepeatedKickerCardContext(heading, kicker) {
- const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR);
+function isKickerCardContext(heading, kicker) {
+ const item = heading.closest?.(KICKER_CARD_CONTEXT_SELECTOR);
return Boolean(item && (!item.contains || item.contains(kicker)));
}
-function isRepeatedKickerCandidate(opts) {
+// Meta lines above headlines join category and date (or path crumbs) with
+// separator glyphs, or carry a year. A kicker is one short phrase; metadata
+// keeps its markers.
+const KICKER_META_TEXT_RE = /[·•|]|\s[\/›»>]\s|\b(19|20)\d{2}\b/;
+// Legal and document numbering: "Section 4.2", "Article IX", "§ 12.3",
+// dotted decimal outlines. The label identifies the clause, so it stays.
+const KICKER_DOC_NUMBERING_RE = /^(§|\d+(\.\d+)+\b|(section|article|clause|appendix|exhibit|schedule|chapter|part|rule|title)\s+([\divxlc]+\b|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)\b)/i;
+
+function isKickerCandidate(opts) {
const {
- headingTag,
+ headingLevel,
headingText,
headingFontSize,
kickerTag,
kickerText,
kickerTextTransform,
+ kickerFontVariant,
kickerFontSize,
kickerLetterSpacing,
} = opts;
- if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
+ if (!headingLevel || headingLevel > 4) return false;
if (!headingText || headingText.length < 3) return false;
if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false;
if (!(headingFontSize >= 20)) return false;
@@ -3251,9 +3262,13 @@ function isRepeatedKickerCandidate(opts) {
if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;
if (!kickerText || kickerText.length < 2 || kickerText.length > 34) return false;
if (/^step\s*\d+/i.test(kickerText) || /^\d{1,2}$/.test(kickerText)) return false;
+ if (KICKER_META_TEXT_RE.test(kickerText)) return false;
+ if (KICKER_DOC_NUMBERING_RE.test(kickerText)) return false;
+ const isSmallCaps = /small-caps/.test(kickerFontVariant || '');
const isUppercased = kickerTextTransform === 'uppercase'
- || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText));
+ || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText))
+ || isSmallCaps;
if (!isUppercased) return false;
if (!(kickerFontSize > 0 && kickerFontSize <= 14)) return false;
const minTrackedSpacing = Math.max(1, kickerFontSize * 0.08);
@@ -3261,37 +3276,64 @@ function isRepeatedKickerCandidate(opts) {
return true;
}
-function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpacing) {
+// Resolve a heading level for the anchor element: 1-4 for h1-h4, aria-level
+// (default 2) for role="heading" elements, 0 otherwise.
+function kickerHeadingLevel(heading) {
+ const tag = heading.tagName.toLowerCase();
+ const byTag = /^h([1-6])$/.exec(tag);
+ if (byTag) return parseInt(byTag[1], 10);
+ const role = heading.getAttribute?.('role') || '';
+ if (role.toLowerCase() !== 'heading') return 0;
+ const ariaLevel = parseInt(heading.getAttribute?.('aria-level') || '', 10);
+ return Number.isFinite(ariaLevel) && ariaLevel >= 1 ? ariaLevel : 2;
+}
+
+function collectKickerCandidates(doc, getStyle, resolveLetterSpacing) {
const candidates = [];
- for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ for (const heading of doc.querySelectorAll('h1, h2, h3, h4, [role="heading"]')) {
+ const headingLevel = kickerHeadingLevel(heading);
+ if (!headingLevel || headingLevel > 4) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ // Application contexts (tab panels, dialogs) use compact context labels
+ // above headings to describe state, not to decorate. Same carve-out the
+ // hero-eyebrow rule makes.
+ if (heading.closest?.('[role="tabpanel"], [role="dialog"], [role="application"], dialog')) continue;
const kicker = heading.previousElementSibling;
- if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
- if (isRepeatedKickerCardContext(heading, kicker)) continue;
+ if (!kicker || kicker.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ if (isKickerCardContext(heading, kicker)) continue;
const headingStyle = getStyle(heading);
const kickerStyle = getStyle(kicker);
+ const headingTag = heading.tagName.toLowerCase();
const headingText = (heading.textContent || '').replace(/\s+/g, ' ').trim();
const kickerText = cleanInlineText(kicker) || (kicker.textContent || '').replace(/\s+/g, ' ').trim();
const headingFontSize = resolveLetterSpacing(headingStyle.fontSize || '', 16) || parseFloat(headingStyle.fontSize) || 0;
const kickerFontSize = resolveLetterSpacing(kickerStyle.fontSize || '', 16) || parseFloat(kickerStyle.fontSize) || 0;
const kickerLetterSpacing = resolveLetterSpacing(kickerStyle.letterSpacing || '', kickerFontSize);
- if (!isRepeatedKickerCandidate({
- headingTag: heading.tagName.toLowerCase(),
+ if (!isKickerCandidate({
+ headingLevel,
headingText,
headingFontSize,
kickerTag: kicker.tagName.toLowerCase(),
kickerText,
kickerTextTransform: kickerStyle.textTransform || '',
+ kickerFontVariant: `${kickerStyle.fontVariant || ''} ${kickerStyle.fontVariantCaps || ''}`,
kickerFontSize,
kickerLetterSpacing,
})) {
continue;
}
+ // A tracked-caps eyebrow above a hero-scale h1 belongs to
+ // hero-eyebrow-chip (which also covers the accent-bold and dash-prefix
+ // stylings there). Stand down so one element gets one finding.
+ if (headingTag === 'h1' && headingFontSize >= 48 && kickerLetterSpacing >= 1.6) {
+ continue;
+ }
+
candidates.push({
- headingTag: heading.tagName.toLowerCase(),
+ headingTag,
headingText: headingText.replace(/^"|"$/g, '').slice(0, 60),
kickerText: kickerText.slice(0, 40),
});
@@ -3299,17 +3341,17 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac
return candidates;
}
-function checkRepeatedSectionKickersDOM() {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingDOM() {
+ const candidates = collectKickerCandidates(
document,
(el) => getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
// ── Numbered section labels ─────────────────────────────────────────────────
-// Sibling of the repeated-kicker rule: instead of a tracked uppercase word,
+// Sibling of the kicker-above-heading rule: instead of a tracked uppercase word,
// the section scaffold is a tiny numeric index riding beside each section
// heading — bare and zero-padded, or an index joined to a short micro-label
// by a separator glyph. The kicker rule deliberately excludes bare 1-2 digit
@@ -3363,7 +3405,7 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
const candidates = [];
const seenLabels = new Set();
for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
// The index sits either directly before the heading, or before the
// wrapper the heading leads (label | …
…).
let label = heading.previousElementSibling;
@@ -3373,9 +3415,9 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
if (firstChild === heading) label = parent.previousElementSibling;
}
if (!label || seenLabels.has(label)) continue;
- if (label.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (label.closest?.(KICKER_SKIP_SELECTOR)) continue;
if (HEADING_TAGS.has(label.tagName.toLowerCase())) continue;
- if (isRepeatedKickerCardContext(heading, label)) continue;
+ if (isKickerCardContext(heading, label)) continue;
const labelText = cleanInlineText(label) || (label.textContent || '').replace(/\s+/g, ' ').trim();
const parsed = parseNumberedLabelText(labelText);
@@ -4548,13 +4590,13 @@ function checkElementHeroEyebrow(el, style, tag, window, customPropMap) {
});
}
-function checkRepeatedSectionKickersFromDoc(doc, win) {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingFromDoc(doc, win) {
+ const candidates = collectKickerCandidates(
doc,
(el) => win.getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
function checkElementMotion(tag, style) {
@@ -7712,7 +7754,7 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, typoFindings);
}
- const sectionKickerFindings = checkRepeatedSectionKickersDOM()
+ const sectionKickerFindings = checkKickerAboveHeadingDOM()
.map(f => ({ type: f.id, detail: f.snippet }))
.filter(f => _ruleOk(f.type));
if (sectionKickerFindings.length > 0) {
diff --git a/.trae-cn/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.trae-cn/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs
index 53d7be48c..939eed779 100644
--- a/.trae-cn/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs
+++ b/.trae-cn/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs
@@ -223,7 +223,7 @@ function unwrapCssAtLayer(source) {
// ---------------------------------------------------------------------------
const STATIC_INHERITED_PROPS = new Set([
- 'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight',
+ 'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight', 'fontVariant',
'lineHeight', 'letterSpacing', 'textTransform', 'textAlign', 'hyphens',
'webkitHyphens',
]);
@@ -252,6 +252,7 @@ const STATIC_DEFAULT_STYLE = {
fontFamily: '',
fontSize: '16px',
fontStyle: 'normal',
+ fontVariant: 'normal',
fontWeight: '400',
lineHeight: 'normal',
letterSpacing: 'normal',
diff --git a/.trae-cn/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.trae-cn/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs
index 1ebb3e427..4e2074042 100644
--- a/.trae-cn/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs
+++ b/.trae-cn/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs
@@ -27,11 +27,11 @@ import {
checkElementRadialSpotlight,
checkCreamPalette,
checkHtmlPatterns,
+ checkKickerAboveHeadingFromDoc,
checkNumberedSectionLabelsFromDoc,
checkPageLayout,
checkPageQualityFromDoc,
checkRepeatedContainerTextFromDoc,
- checkRepeatedSectionKickersFromDoc,
resolveBackground,
resolveBorderRadiusPx,
} from '../../rules/checks.mjs';
@@ -202,7 +202,7 @@ async function detectHtml(filePath, options = {}) {
for (const f of runPageCheck('typography-rules', () => checkStaticPageTypography(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
- for (const f of runPageCheck('repeated-section-kickers', () => checkRepeatedSectionKickersFromDoc(document, window))) {
+ for (const f of runPageCheck('kicker-above-heading', () => checkKickerAboveHeadingFromDoc(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of runPageCheck('numbered-section-labels', () => checkNumberedSectionLabelsFromDoc(document, window))) {
diff --git a/.trae-cn/skills/impeccable/scripts/detector/registry/antipatterns.mjs b/.trae-cn/skills/impeccable/scripts/detector/registry/antipatterns.mjs
index c47d39524..38ce07390 100644
--- a/.trae-cn/skills/impeccable/scripts/detector/registry/antipatterns.mjs
+++ b/.trae-cn/skills/impeccable/scripts/detector/registry/antipatterns.mjs
@@ -198,15 +198,14 @@ const ANTIPATTERNS = [
skillGuideline: 'tiny uppercase tracked label above the hero headline',
},
{
- id: 'repeated-section-kickers',
+ id: 'kicker-above-heading',
category: 'slop',
scopes: ['type'],
- severity: 'advisory',
- name: 'Repeated section kicker labels',
+ name: 'Kicker / eyebrow label above heading',
description:
- 'Repeating tiny uppercase tracked labels above section headings turns a brand page into AI editorial scaffolding. Replace them with stronger structure, artifacts, imagery, or a deliberate brand system.',
+ 'A tiny tracked uppercase or small-caps label sitting as its own block directly above a heading is banned outright, repeated or not. Generated kickers never earn their place: the heading carries its own weight. Delete the label and let the heading speak; if the words matter, work them into the heading or the body.',
skillSection: 'Typography',
- skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding',
+ skillGuideline: 'kicker or eyebrow labels above headings',
},
{
id: 'numbered-section-labels',
diff --git a/.trae-cn/skills/impeccable/scripts/detector/rules/checks.mjs b/.trae-cn/skills/impeccable/scripts/detector/rules/checks.mjs
index 3e810076b..3bf26b9b5 100644
--- a/.trae-cn/skills/impeccable/scripts/detector/rules/checks.mjs
+++ b/.trae-cn/skills/impeccable/scripts/detector/rules/checks.mjs
@@ -456,12 +456,15 @@ function checkHeroEyebrow(opts) {
}];
}
-function checkRepeatedSectionKickers(opts) {
- const { candidates, minCount = 3 } = opts;
- if (!Array.isArray(candidates) || candidates.length < minCount) return [];
+// Outright ban: one kicker is one too many, so every collected candidate is
+// a finding. The judgment lives in the candidate gate (isKickerCandidate) and
+// the collector's context skips, not in a repetition count.
+function checkKickerAboveHeading(opts) {
+ const { candidates } = opts;
+ if (!Array.isArray(candidates)) return [];
return candidates.map(candidate => ({
- id: 'repeated-section-kickers',
- snippet: `repeated section kicker "${candidate.kickerText}" before ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`,
+ id: 'kicker-above-heading',
+ snippet: `kicker "${candidate.kickerText}" above ${candidate.headingTag} "${candidate.headingText}"`,
}));
}
@@ -2388,7 +2391,7 @@ function parseColorResolved(str, customPropMap) {
return parseAnyColor(resolved);
}
-const REPEATED_KICKER_SKIP_SELECTOR = [
+const KICKER_SKIP_SELECTOR = [
'nav',
'form',
'table',
@@ -2407,7 +2410,7 @@ const REPEATED_KICKER_SKIP_SELECTOR = [
'[data-impeccable-allow-kickers]',
].join(',');
-const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [
+const KICKER_CARD_CONTEXT_SELECTOR = [
'article',
'button',
'a',
@@ -2425,23 +2428,32 @@ function cleanInlineText(el) {
.trim();
}
-function isRepeatedKickerCardContext(heading, kicker) {
- const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR);
+function isKickerCardContext(heading, kicker) {
+ const item = heading.closest?.(KICKER_CARD_CONTEXT_SELECTOR);
return Boolean(item && (!item.contains || item.contains(kicker)));
}
-function isRepeatedKickerCandidate(opts) {
+// Meta lines above headlines join category and date (or path crumbs) with
+// separator glyphs, or carry a year. A kicker is one short phrase; metadata
+// keeps its markers.
+const KICKER_META_TEXT_RE = /[·•|]|\s[\/›»>]\s|\b(19|20)\d{2}\b/;
+// Legal and document numbering: "Section 4.2", "Article IX", "§ 12.3",
+// dotted decimal outlines. The label identifies the clause, so it stays.
+const KICKER_DOC_NUMBERING_RE = /^(§|\d+(\.\d+)+\b|(section|article|clause|appendix|exhibit|schedule|chapter|part|rule|title)\s+([\divxlc]+\b|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)\b)/i;
+
+function isKickerCandidate(opts) {
const {
- headingTag,
+ headingLevel,
headingText,
headingFontSize,
kickerTag,
kickerText,
kickerTextTransform,
+ kickerFontVariant,
kickerFontSize,
kickerLetterSpacing,
} = opts;
- if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
+ if (!headingLevel || headingLevel > 4) return false;
if (!headingText || headingText.length < 3) return false;
if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false;
if (!(headingFontSize >= 20)) return false;
@@ -2449,9 +2461,13 @@ function isRepeatedKickerCandidate(opts) {
if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;
if (!kickerText || kickerText.length < 2 || kickerText.length > 34) return false;
if (/^step\s*\d+/i.test(kickerText) || /^\d{1,2}$/.test(kickerText)) return false;
+ if (KICKER_META_TEXT_RE.test(kickerText)) return false;
+ if (KICKER_DOC_NUMBERING_RE.test(kickerText)) return false;
+ const isSmallCaps = /small-caps/.test(kickerFontVariant || '');
const isUppercased = kickerTextTransform === 'uppercase'
- || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText));
+ || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText))
+ || isSmallCaps;
if (!isUppercased) return false;
if (!(kickerFontSize > 0 && kickerFontSize <= 14)) return false;
const minTrackedSpacing = Math.max(1, kickerFontSize * 0.08);
@@ -2459,37 +2475,64 @@ function isRepeatedKickerCandidate(opts) {
return true;
}
-function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpacing) {
+// Resolve a heading level for the anchor element: 1-4 for h1-h4, aria-level
+// (default 2) for role="heading" elements, 0 otherwise.
+function kickerHeadingLevel(heading) {
+ const tag = heading.tagName.toLowerCase();
+ const byTag = /^h([1-6])$/.exec(tag);
+ if (byTag) return parseInt(byTag[1], 10);
+ const role = heading.getAttribute?.('role') || '';
+ if (role.toLowerCase() !== 'heading') return 0;
+ const ariaLevel = parseInt(heading.getAttribute?.('aria-level') || '', 10);
+ return Number.isFinite(ariaLevel) && ariaLevel >= 1 ? ariaLevel : 2;
+}
+
+function collectKickerCandidates(doc, getStyle, resolveLetterSpacing) {
const candidates = [];
- for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ for (const heading of doc.querySelectorAll('h1, h2, h3, h4, [role="heading"]')) {
+ const headingLevel = kickerHeadingLevel(heading);
+ if (!headingLevel || headingLevel > 4) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ // Application contexts (tab panels, dialogs) use compact context labels
+ // above headings to describe state, not to decorate. Same carve-out the
+ // hero-eyebrow rule makes.
+ if (heading.closest?.('[role="tabpanel"], [role="dialog"], [role="application"], dialog')) continue;
const kicker = heading.previousElementSibling;
- if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
- if (isRepeatedKickerCardContext(heading, kicker)) continue;
+ if (!kicker || kicker.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ if (isKickerCardContext(heading, kicker)) continue;
const headingStyle = getStyle(heading);
const kickerStyle = getStyle(kicker);
+ const headingTag = heading.tagName.toLowerCase();
const headingText = (heading.textContent || '').replace(/\s+/g, ' ').trim();
const kickerText = cleanInlineText(kicker) || (kicker.textContent || '').replace(/\s+/g, ' ').trim();
const headingFontSize = resolveLetterSpacing(headingStyle.fontSize || '', 16) || parseFloat(headingStyle.fontSize) || 0;
const kickerFontSize = resolveLetterSpacing(kickerStyle.fontSize || '', 16) || parseFloat(kickerStyle.fontSize) || 0;
const kickerLetterSpacing = resolveLetterSpacing(kickerStyle.letterSpacing || '', kickerFontSize);
- if (!isRepeatedKickerCandidate({
- headingTag: heading.tagName.toLowerCase(),
+ if (!isKickerCandidate({
+ headingLevel,
headingText,
headingFontSize,
kickerTag: kicker.tagName.toLowerCase(),
kickerText,
kickerTextTransform: kickerStyle.textTransform || '',
+ kickerFontVariant: `${kickerStyle.fontVariant || ''} ${kickerStyle.fontVariantCaps || ''}`,
kickerFontSize,
kickerLetterSpacing,
})) {
continue;
}
+ // A tracked-caps eyebrow above a hero-scale h1 belongs to
+ // hero-eyebrow-chip (which also covers the accent-bold and dash-prefix
+ // stylings there). Stand down so one element gets one finding.
+ if (headingTag === 'h1' && headingFontSize >= 48 && kickerLetterSpacing >= 1.6) {
+ continue;
+ }
+
candidates.push({
- headingTag: heading.tagName.toLowerCase(),
+ headingTag,
headingText: headingText.replace(/^"|"$/g, '').slice(0, 60),
kickerText: kickerText.slice(0, 40),
});
@@ -2497,17 +2540,17 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac
return candidates;
}
-function checkRepeatedSectionKickersDOM() {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingDOM() {
+ const candidates = collectKickerCandidates(
document,
(el) => getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
// ── Numbered section labels ─────────────────────────────────────────────────
-// Sibling of the repeated-kicker rule: instead of a tracked uppercase word,
+// Sibling of the kicker-above-heading rule: instead of a tracked uppercase word,
// the section scaffold is a tiny numeric index riding beside each section
// heading — bare and zero-padded, or an index joined to a short micro-label
// by a separator glyph. The kicker rule deliberately excludes bare 1-2 digit
@@ -2561,7 +2604,7 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
const candidates = [];
const seenLabels = new Set();
for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
// The index sits either directly before the heading, or before the
// wrapper the heading leads (label | …
…).
let label = heading.previousElementSibling;
@@ -2571,9 +2614,9 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
if (firstChild === heading) label = parent.previousElementSibling;
}
if (!label || seenLabels.has(label)) continue;
- if (label.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (label.closest?.(KICKER_SKIP_SELECTOR)) continue;
if (HEADING_TAGS.has(label.tagName.toLowerCase())) continue;
- if (isRepeatedKickerCardContext(heading, label)) continue;
+ if (isKickerCardContext(heading, label)) continue;
const labelText = cleanInlineText(label) || (label.textContent || '').replace(/\s+/g, ' ').trim();
const parsed = parseNumberedLabelText(labelText);
@@ -3746,13 +3789,13 @@ function checkElementHeroEyebrow(el, style, tag, window, customPropMap) {
});
}
-function checkRepeatedSectionKickersFromDoc(doc, win) {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingFromDoc(doc, win) {
+ const candidates = collectKickerCandidates(
doc,
(el) => win.getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
function checkElementMotion(tag, style) {
@@ -5393,7 +5436,7 @@ export {
checkItalicSerif,
isAccentColor,
checkHeroEyebrow,
- checkRepeatedSectionKickers,
+ checkKickerAboveHeading,
checkMotion,
checkGlow,
scanCssTextForGlow,
@@ -5424,9 +5467,9 @@ export {
parseAnyColor,
parseColorResolved,
cleanInlineText,
- isRepeatedKickerCandidate,
- collectRepeatedSectionKickerCandidates,
- checkRepeatedSectionKickersDOM,
+ isKickerCandidate,
+ collectKickerCandidates,
+ checkKickerAboveHeadingDOM,
parseNumberedLabelText,
isNumberedSectionLabelCandidate,
collectNumberedSectionLabelCandidates,
@@ -5458,7 +5501,7 @@ export {
checkElementIconTile,
checkElementItalicSerif,
checkElementHeroEyebrow,
- checkRepeatedSectionKickersFromDoc,
+ checkKickerAboveHeadingFromDoc,
checkElementMotion,
checkElementGlow,
checkTypography,
diff --git a/.trae/skills/impeccable/reference/craft-floor.md b/.trae/skills/impeccable/reference/craft-floor.md
index 3d0c7a8fb..4ea9b8fa4 100644
--- a/.trae/skills/impeccable/reference/craft-floor.md
+++ b/.trae/skills/impeccable/reference/craft-floor.md
@@ -23,7 +23,7 @@ Page scaffolds:
- Same-size cards of icon plus heading plus text as the page structure. Cards are the lazy container; nested cards are always wrong.
- The hero-metric template: big number, small label, supporting stats, accent.
-- A tracked uppercase eyebrow over every section. One named kicker is a system; an eyebrow everywhere is grammar you did not choose.
+- A kicker or eyebrow above a heading. This one is a ban, not a default: no brief earns it back. The heading carries its own weight; delete the label and let the heading speak.
- Section numbers (01 / 02 / 03) unless the sequence itself carries information the reader needs.
- A modal for a task that needs neither interruption nor protected focus.
diff --git a/.trae/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.trae/skills/impeccable/scripts/detector/browser/injected/index.mjs
index e61c536ee..dfc725a8e 100644
--- a/.trae/skills/impeccable/scripts/detector/browser/injected/index.mjs
+++ b/.trae/skills/impeccable/scripts/detector/browser/injected/index.mjs
@@ -1529,7 +1529,7 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, typoFindings);
}
- const sectionKickerFindings = checkRepeatedSectionKickersDOM()
+ const sectionKickerFindings = checkKickerAboveHeadingDOM()
.map(f => ({ type: f.id, detail: f.snippet }))
.filter(f => _ruleOk(f.type));
if (sectionKickerFindings.length > 0) {
diff --git a/.trae/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.trae/skills/impeccable/scripts/detector/detect-antipatterns-browser.js
index a728d4f9e..3d948ad39 100644
--- a/.trae/skills/impeccable/scripts/detector/detect-antipatterns-browser.js
+++ b/.trae/skills/impeccable/scripts/detector/detect-antipatterns-browser.js
@@ -309,15 +309,14 @@ const ANTIPATTERNS = [
skillGuideline: 'tiny uppercase tracked label above the hero headline',
},
{
- id: 'repeated-section-kickers',
+ id: 'kicker-above-heading',
category: 'slop',
scopes: ['type'],
- severity: 'advisory',
- name: 'Repeated section kicker labels',
+ name: 'Kicker / eyebrow label above heading',
description:
- 'Repeating tiny uppercase tracked labels above section headings turns a brand page into AI editorial scaffolding. Replace them with stronger structure, artifacts, imagery, or a deliberate brand system.',
+ 'A tiny tracked uppercase or small-caps label sitting as its own block directly above a heading is banned outright, repeated or not. Generated kickers never earn their place: the heading carries its own weight. Delete the label and let the heading speak; if the words matter, work them into the heading or the body.',
skillSection: 'Typography',
- skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding',
+ skillGuideline: 'kicker or eyebrow labels above headings',
},
{
id: 'numbered-section-labels',
@@ -1258,12 +1257,15 @@ function checkHeroEyebrow(opts) {
}];
}
-function checkRepeatedSectionKickers(opts) {
- const { candidates, minCount = 3 } = opts;
- if (!Array.isArray(candidates) || candidates.length < minCount) return [];
+// Outright ban: one kicker is one too many, so every collected candidate is
+// a finding. The judgment lives in the candidate gate (isKickerCandidate) and
+// the collector's context skips, not in a repetition count.
+function checkKickerAboveHeading(opts) {
+ const { candidates } = opts;
+ if (!Array.isArray(candidates)) return [];
return candidates.map(candidate => ({
- id: 'repeated-section-kickers',
- snippet: `repeated section kicker "${candidate.kickerText}" before ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`,
+ id: 'kicker-above-heading',
+ snippet: `kicker "${candidate.kickerText}" above ${candidate.headingTag} "${candidate.headingText}"`,
}));
}
@@ -3190,7 +3192,7 @@ function parseColorResolved(str, customPropMap) {
return parseAnyColor(resolved);
}
-const REPEATED_KICKER_SKIP_SELECTOR = [
+const KICKER_SKIP_SELECTOR = [
'nav',
'form',
'table',
@@ -3209,7 +3211,7 @@ const REPEATED_KICKER_SKIP_SELECTOR = [
'[data-impeccable-allow-kickers]',
].join(',');
-const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [
+const KICKER_CARD_CONTEXT_SELECTOR = [
'article',
'button',
'a',
@@ -3227,23 +3229,32 @@ function cleanInlineText(el) {
.trim();
}
-function isRepeatedKickerCardContext(heading, kicker) {
- const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR);
+function isKickerCardContext(heading, kicker) {
+ const item = heading.closest?.(KICKER_CARD_CONTEXT_SELECTOR);
return Boolean(item && (!item.contains || item.contains(kicker)));
}
-function isRepeatedKickerCandidate(opts) {
+// Meta lines above headlines join category and date (or path crumbs) with
+// separator glyphs, or carry a year. A kicker is one short phrase; metadata
+// keeps its markers.
+const KICKER_META_TEXT_RE = /[·•|]|\s[\/›»>]\s|\b(19|20)\d{2}\b/;
+// Legal and document numbering: "Section 4.2", "Article IX", "§ 12.3",
+// dotted decimal outlines. The label identifies the clause, so it stays.
+const KICKER_DOC_NUMBERING_RE = /^(§|\d+(\.\d+)+\b|(section|article|clause|appendix|exhibit|schedule|chapter|part|rule|title)\s+([\divxlc]+\b|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)\b)/i;
+
+function isKickerCandidate(opts) {
const {
- headingTag,
+ headingLevel,
headingText,
headingFontSize,
kickerTag,
kickerText,
kickerTextTransform,
+ kickerFontVariant,
kickerFontSize,
kickerLetterSpacing,
} = opts;
- if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
+ if (!headingLevel || headingLevel > 4) return false;
if (!headingText || headingText.length < 3) return false;
if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false;
if (!(headingFontSize >= 20)) return false;
@@ -3251,9 +3262,13 @@ function isRepeatedKickerCandidate(opts) {
if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;
if (!kickerText || kickerText.length < 2 || kickerText.length > 34) return false;
if (/^step\s*\d+/i.test(kickerText) || /^\d{1,2}$/.test(kickerText)) return false;
+ if (KICKER_META_TEXT_RE.test(kickerText)) return false;
+ if (KICKER_DOC_NUMBERING_RE.test(kickerText)) return false;
+ const isSmallCaps = /small-caps/.test(kickerFontVariant || '');
const isUppercased = kickerTextTransform === 'uppercase'
- || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText));
+ || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText))
+ || isSmallCaps;
if (!isUppercased) return false;
if (!(kickerFontSize > 0 && kickerFontSize <= 14)) return false;
const minTrackedSpacing = Math.max(1, kickerFontSize * 0.08);
@@ -3261,37 +3276,64 @@ function isRepeatedKickerCandidate(opts) {
return true;
}
-function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpacing) {
+// Resolve a heading level for the anchor element: 1-4 for h1-h4, aria-level
+// (default 2) for role="heading" elements, 0 otherwise.
+function kickerHeadingLevel(heading) {
+ const tag = heading.tagName.toLowerCase();
+ const byTag = /^h([1-6])$/.exec(tag);
+ if (byTag) return parseInt(byTag[1], 10);
+ const role = heading.getAttribute?.('role') || '';
+ if (role.toLowerCase() !== 'heading') return 0;
+ const ariaLevel = parseInt(heading.getAttribute?.('aria-level') || '', 10);
+ return Number.isFinite(ariaLevel) && ariaLevel >= 1 ? ariaLevel : 2;
+}
+
+function collectKickerCandidates(doc, getStyle, resolveLetterSpacing) {
const candidates = [];
- for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ for (const heading of doc.querySelectorAll('h1, h2, h3, h4, [role="heading"]')) {
+ const headingLevel = kickerHeadingLevel(heading);
+ if (!headingLevel || headingLevel > 4) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ // Application contexts (tab panels, dialogs) use compact context labels
+ // above headings to describe state, not to decorate. Same carve-out the
+ // hero-eyebrow rule makes.
+ if (heading.closest?.('[role="tabpanel"], [role="dialog"], [role="application"], dialog')) continue;
const kicker = heading.previousElementSibling;
- if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
- if (isRepeatedKickerCardContext(heading, kicker)) continue;
+ if (!kicker || kicker.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ if (isKickerCardContext(heading, kicker)) continue;
const headingStyle = getStyle(heading);
const kickerStyle = getStyle(kicker);
+ const headingTag = heading.tagName.toLowerCase();
const headingText = (heading.textContent || '').replace(/\s+/g, ' ').trim();
const kickerText = cleanInlineText(kicker) || (kicker.textContent || '').replace(/\s+/g, ' ').trim();
const headingFontSize = resolveLetterSpacing(headingStyle.fontSize || '', 16) || parseFloat(headingStyle.fontSize) || 0;
const kickerFontSize = resolveLetterSpacing(kickerStyle.fontSize || '', 16) || parseFloat(kickerStyle.fontSize) || 0;
const kickerLetterSpacing = resolveLetterSpacing(kickerStyle.letterSpacing || '', kickerFontSize);
- if (!isRepeatedKickerCandidate({
- headingTag: heading.tagName.toLowerCase(),
+ if (!isKickerCandidate({
+ headingLevel,
headingText,
headingFontSize,
kickerTag: kicker.tagName.toLowerCase(),
kickerText,
kickerTextTransform: kickerStyle.textTransform || '',
+ kickerFontVariant: `${kickerStyle.fontVariant || ''} ${kickerStyle.fontVariantCaps || ''}`,
kickerFontSize,
kickerLetterSpacing,
})) {
continue;
}
+ // A tracked-caps eyebrow above a hero-scale h1 belongs to
+ // hero-eyebrow-chip (which also covers the accent-bold and dash-prefix
+ // stylings there). Stand down so one element gets one finding.
+ if (headingTag === 'h1' && headingFontSize >= 48 && kickerLetterSpacing >= 1.6) {
+ continue;
+ }
+
candidates.push({
- headingTag: heading.tagName.toLowerCase(),
+ headingTag,
headingText: headingText.replace(/^"|"$/g, '').slice(0, 60),
kickerText: kickerText.slice(0, 40),
});
@@ -3299,17 +3341,17 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac
return candidates;
}
-function checkRepeatedSectionKickersDOM() {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingDOM() {
+ const candidates = collectKickerCandidates(
document,
(el) => getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
// ── Numbered section labels ─────────────────────────────────────────────────
-// Sibling of the repeated-kicker rule: instead of a tracked uppercase word,
+// Sibling of the kicker-above-heading rule: instead of a tracked uppercase word,
// the section scaffold is a tiny numeric index riding beside each section
// heading — bare and zero-padded, or an index joined to a short micro-label
// by a separator glyph. The kicker rule deliberately excludes bare 1-2 digit
@@ -3363,7 +3405,7 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
const candidates = [];
const seenLabels = new Set();
for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
// The index sits either directly before the heading, or before the
// wrapper the heading leads (label | …
…).
let label = heading.previousElementSibling;
@@ -3373,9 +3415,9 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
if (firstChild === heading) label = parent.previousElementSibling;
}
if (!label || seenLabels.has(label)) continue;
- if (label.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (label.closest?.(KICKER_SKIP_SELECTOR)) continue;
if (HEADING_TAGS.has(label.tagName.toLowerCase())) continue;
- if (isRepeatedKickerCardContext(heading, label)) continue;
+ if (isKickerCardContext(heading, label)) continue;
const labelText = cleanInlineText(label) || (label.textContent || '').replace(/\s+/g, ' ').trim();
const parsed = parseNumberedLabelText(labelText);
@@ -4548,13 +4590,13 @@ function checkElementHeroEyebrow(el, style, tag, window, customPropMap) {
});
}
-function checkRepeatedSectionKickersFromDoc(doc, win) {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingFromDoc(doc, win) {
+ const candidates = collectKickerCandidates(
doc,
(el) => win.getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
function checkElementMotion(tag, style) {
@@ -7712,7 +7754,7 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, typoFindings);
}
- const sectionKickerFindings = checkRepeatedSectionKickersDOM()
+ const sectionKickerFindings = checkKickerAboveHeadingDOM()
.map(f => ({ type: f.id, detail: f.snippet }))
.filter(f => _ruleOk(f.type));
if (sectionKickerFindings.length > 0) {
diff --git a/.trae/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.trae/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs
index 53d7be48c..939eed779 100644
--- a/.trae/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs
+++ b/.trae/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs
@@ -223,7 +223,7 @@ function unwrapCssAtLayer(source) {
// ---------------------------------------------------------------------------
const STATIC_INHERITED_PROPS = new Set([
- 'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight',
+ 'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight', 'fontVariant',
'lineHeight', 'letterSpacing', 'textTransform', 'textAlign', 'hyphens',
'webkitHyphens',
]);
@@ -252,6 +252,7 @@ const STATIC_DEFAULT_STYLE = {
fontFamily: '',
fontSize: '16px',
fontStyle: 'normal',
+ fontVariant: 'normal',
fontWeight: '400',
lineHeight: 'normal',
letterSpacing: 'normal',
diff --git a/.trae/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.trae/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs
index 1ebb3e427..4e2074042 100644
--- a/.trae/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs
+++ b/.trae/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs
@@ -27,11 +27,11 @@ import {
checkElementRadialSpotlight,
checkCreamPalette,
checkHtmlPatterns,
+ checkKickerAboveHeadingFromDoc,
checkNumberedSectionLabelsFromDoc,
checkPageLayout,
checkPageQualityFromDoc,
checkRepeatedContainerTextFromDoc,
- checkRepeatedSectionKickersFromDoc,
resolveBackground,
resolveBorderRadiusPx,
} from '../../rules/checks.mjs';
@@ -202,7 +202,7 @@ async function detectHtml(filePath, options = {}) {
for (const f of runPageCheck('typography-rules', () => checkStaticPageTypography(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
- for (const f of runPageCheck('repeated-section-kickers', () => checkRepeatedSectionKickersFromDoc(document, window))) {
+ for (const f of runPageCheck('kicker-above-heading', () => checkKickerAboveHeadingFromDoc(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of runPageCheck('numbered-section-labels', () => checkNumberedSectionLabelsFromDoc(document, window))) {
diff --git a/.trae/skills/impeccable/scripts/detector/registry/antipatterns.mjs b/.trae/skills/impeccable/scripts/detector/registry/antipatterns.mjs
index c47d39524..38ce07390 100644
--- a/.trae/skills/impeccable/scripts/detector/registry/antipatterns.mjs
+++ b/.trae/skills/impeccable/scripts/detector/registry/antipatterns.mjs
@@ -198,15 +198,14 @@ const ANTIPATTERNS = [
skillGuideline: 'tiny uppercase tracked label above the hero headline',
},
{
- id: 'repeated-section-kickers',
+ id: 'kicker-above-heading',
category: 'slop',
scopes: ['type'],
- severity: 'advisory',
- name: 'Repeated section kicker labels',
+ name: 'Kicker / eyebrow label above heading',
description:
- 'Repeating tiny uppercase tracked labels above section headings turns a brand page into AI editorial scaffolding. Replace them with stronger structure, artifacts, imagery, or a deliberate brand system.',
+ 'A tiny tracked uppercase or small-caps label sitting as its own block directly above a heading is banned outright, repeated or not. Generated kickers never earn their place: the heading carries its own weight. Delete the label and let the heading speak; if the words matter, work them into the heading or the body.',
skillSection: 'Typography',
- skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding',
+ skillGuideline: 'kicker or eyebrow labels above headings',
},
{
id: 'numbered-section-labels',
diff --git a/.trae/skills/impeccable/scripts/detector/rules/checks.mjs b/.trae/skills/impeccable/scripts/detector/rules/checks.mjs
index 3e810076b..3bf26b9b5 100644
--- a/.trae/skills/impeccable/scripts/detector/rules/checks.mjs
+++ b/.trae/skills/impeccable/scripts/detector/rules/checks.mjs
@@ -456,12 +456,15 @@ function checkHeroEyebrow(opts) {
}];
}
-function checkRepeatedSectionKickers(opts) {
- const { candidates, minCount = 3 } = opts;
- if (!Array.isArray(candidates) || candidates.length < minCount) return [];
+// Outright ban: one kicker is one too many, so every collected candidate is
+// a finding. The judgment lives in the candidate gate (isKickerCandidate) and
+// the collector's context skips, not in a repetition count.
+function checkKickerAboveHeading(opts) {
+ const { candidates } = opts;
+ if (!Array.isArray(candidates)) return [];
return candidates.map(candidate => ({
- id: 'repeated-section-kickers',
- snippet: `repeated section kicker "${candidate.kickerText}" before ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`,
+ id: 'kicker-above-heading',
+ snippet: `kicker "${candidate.kickerText}" above ${candidate.headingTag} "${candidate.headingText}"`,
}));
}
@@ -2388,7 +2391,7 @@ function parseColorResolved(str, customPropMap) {
return parseAnyColor(resolved);
}
-const REPEATED_KICKER_SKIP_SELECTOR = [
+const KICKER_SKIP_SELECTOR = [
'nav',
'form',
'table',
@@ -2407,7 +2410,7 @@ const REPEATED_KICKER_SKIP_SELECTOR = [
'[data-impeccable-allow-kickers]',
].join(',');
-const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [
+const KICKER_CARD_CONTEXT_SELECTOR = [
'article',
'button',
'a',
@@ -2425,23 +2428,32 @@ function cleanInlineText(el) {
.trim();
}
-function isRepeatedKickerCardContext(heading, kicker) {
- const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR);
+function isKickerCardContext(heading, kicker) {
+ const item = heading.closest?.(KICKER_CARD_CONTEXT_SELECTOR);
return Boolean(item && (!item.contains || item.contains(kicker)));
}
-function isRepeatedKickerCandidate(opts) {
+// Meta lines above headlines join category and date (or path crumbs) with
+// separator glyphs, or carry a year. A kicker is one short phrase; metadata
+// keeps its markers.
+const KICKER_META_TEXT_RE = /[·•|]|\s[\/›»>]\s|\b(19|20)\d{2}\b/;
+// Legal and document numbering: "Section 4.2", "Article IX", "§ 12.3",
+// dotted decimal outlines. The label identifies the clause, so it stays.
+const KICKER_DOC_NUMBERING_RE = /^(§|\d+(\.\d+)+\b|(section|article|clause|appendix|exhibit|schedule|chapter|part|rule|title)\s+([\divxlc]+\b|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)\b)/i;
+
+function isKickerCandidate(opts) {
const {
- headingTag,
+ headingLevel,
headingText,
headingFontSize,
kickerTag,
kickerText,
kickerTextTransform,
+ kickerFontVariant,
kickerFontSize,
kickerLetterSpacing,
} = opts;
- if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
+ if (!headingLevel || headingLevel > 4) return false;
if (!headingText || headingText.length < 3) return false;
if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false;
if (!(headingFontSize >= 20)) return false;
@@ -2449,9 +2461,13 @@ function isRepeatedKickerCandidate(opts) {
if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;
if (!kickerText || kickerText.length < 2 || kickerText.length > 34) return false;
if (/^step\s*\d+/i.test(kickerText) || /^\d{1,2}$/.test(kickerText)) return false;
+ if (KICKER_META_TEXT_RE.test(kickerText)) return false;
+ if (KICKER_DOC_NUMBERING_RE.test(kickerText)) return false;
+ const isSmallCaps = /small-caps/.test(kickerFontVariant || '');
const isUppercased = kickerTextTransform === 'uppercase'
- || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText));
+ || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText))
+ || isSmallCaps;
if (!isUppercased) return false;
if (!(kickerFontSize > 0 && kickerFontSize <= 14)) return false;
const minTrackedSpacing = Math.max(1, kickerFontSize * 0.08);
@@ -2459,37 +2475,64 @@ function isRepeatedKickerCandidate(opts) {
return true;
}
-function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpacing) {
+// Resolve a heading level for the anchor element: 1-4 for h1-h4, aria-level
+// (default 2) for role="heading" elements, 0 otherwise.
+function kickerHeadingLevel(heading) {
+ const tag = heading.tagName.toLowerCase();
+ const byTag = /^h([1-6])$/.exec(tag);
+ if (byTag) return parseInt(byTag[1], 10);
+ const role = heading.getAttribute?.('role') || '';
+ if (role.toLowerCase() !== 'heading') return 0;
+ const ariaLevel = parseInt(heading.getAttribute?.('aria-level') || '', 10);
+ return Number.isFinite(ariaLevel) && ariaLevel >= 1 ? ariaLevel : 2;
+}
+
+function collectKickerCandidates(doc, getStyle, resolveLetterSpacing) {
const candidates = [];
- for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ for (const heading of doc.querySelectorAll('h1, h2, h3, h4, [role="heading"]')) {
+ const headingLevel = kickerHeadingLevel(heading);
+ if (!headingLevel || headingLevel > 4) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ // Application contexts (tab panels, dialogs) use compact context labels
+ // above headings to describe state, not to decorate. Same carve-out the
+ // hero-eyebrow rule makes.
+ if (heading.closest?.('[role="tabpanel"], [role="dialog"], [role="application"], dialog')) continue;
const kicker = heading.previousElementSibling;
- if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
- if (isRepeatedKickerCardContext(heading, kicker)) continue;
+ if (!kicker || kicker.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ if (isKickerCardContext(heading, kicker)) continue;
const headingStyle = getStyle(heading);
const kickerStyle = getStyle(kicker);
+ const headingTag = heading.tagName.toLowerCase();
const headingText = (heading.textContent || '').replace(/\s+/g, ' ').trim();
const kickerText = cleanInlineText(kicker) || (kicker.textContent || '').replace(/\s+/g, ' ').trim();
const headingFontSize = resolveLetterSpacing(headingStyle.fontSize || '', 16) || parseFloat(headingStyle.fontSize) || 0;
const kickerFontSize = resolveLetterSpacing(kickerStyle.fontSize || '', 16) || parseFloat(kickerStyle.fontSize) || 0;
const kickerLetterSpacing = resolveLetterSpacing(kickerStyle.letterSpacing || '', kickerFontSize);
- if (!isRepeatedKickerCandidate({
- headingTag: heading.tagName.toLowerCase(),
+ if (!isKickerCandidate({
+ headingLevel,
headingText,
headingFontSize,
kickerTag: kicker.tagName.toLowerCase(),
kickerText,
kickerTextTransform: kickerStyle.textTransform || '',
+ kickerFontVariant: `${kickerStyle.fontVariant || ''} ${kickerStyle.fontVariantCaps || ''}`,
kickerFontSize,
kickerLetterSpacing,
})) {
continue;
}
+ // A tracked-caps eyebrow above a hero-scale h1 belongs to
+ // hero-eyebrow-chip (which also covers the accent-bold and dash-prefix
+ // stylings there). Stand down so one element gets one finding.
+ if (headingTag === 'h1' && headingFontSize >= 48 && kickerLetterSpacing >= 1.6) {
+ continue;
+ }
+
candidates.push({
- headingTag: heading.tagName.toLowerCase(),
+ headingTag,
headingText: headingText.replace(/^"|"$/g, '').slice(0, 60),
kickerText: kickerText.slice(0, 40),
});
@@ -2497,17 +2540,17 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac
return candidates;
}
-function checkRepeatedSectionKickersDOM() {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingDOM() {
+ const candidates = collectKickerCandidates(
document,
(el) => getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
// ── Numbered section labels ─────────────────────────────────────────────────
-// Sibling of the repeated-kicker rule: instead of a tracked uppercase word,
+// Sibling of the kicker-above-heading rule: instead of a tracked uppercase word,
// the section scaffold is a tiny numeric index riding beside each section
// heading — bare and zero-padded, or an index joined to a short micro-label
// by a separator glyph. The kicker rule deliberately excludes bare 1-2 digit
@@ -2561,7 +2604,7 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
const candidates = [];
const seenLabels = new Set();
for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
// The index sits either directly before the heading, or before the
// wrapper the heading leads (label | …
…).
let label = heading.previousElementSibling;
@@ -2571,9 +2614,9 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
if (firstChild === heading) label = parent.previousElementSibling;
}
if (!label || seenLabels.has(label)) continue;
- if (label.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (label.closest?.(KICKER_SKIP_SELECTOR)) continue;
if (HEADING_TAGS.has(label.tagName.toLowerCase())) continue;
- if (isRepeatedKickerCardContext(heading, label)) continue;
+ if (isKickerCardContext(heading, label)) continue;
const labelText = cleanInlineText(label) || (label.textContent || '').replace(/\s+/g, ' ').trim();
const parsed = parseNumberedLabelText(labelText);
@@ -3746,13 +3789,13 @@ function checkElementHeroEyebrow(el, style, tag, window, customPropMap) {
});
}
-function checkRepeatedSectionKickersFromDoc(doc, win) {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingFromDoc(doc, win) {
+ const candidates = collectKickerCandidates(
doc,
(el) => win.getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
function checkElementMotion(tag, style) {
@@ -5393,7 +5436,7 @@ export {
checkItalicSerif,
isAccentColor,
checkHeroEyebrow,
- checkRepeatedSectionKickers,
+ checkKickerAboveHeading,
checkMotion,
checkGlow,
scanCssTextForGlow,
@@ -5424,9 +5467,9 @@ export {
parseAnyColor,
parseColorResolved,
cleanInlineText,
- isRepeatedKickerCandidate,
- collectRepeatedSectionKickerCandidates,
- checkRepeatedSectionKickersDOM,
+ isKickerCandidate,
+ collectKickerCandidates,
+ checkKickerAboveHeadingDOM,
parseNumberedLabelText,
isNumberedSectionLabelCandidate,
collectNumberedSectionLabelCandidates,
@@ -5458,7 +5501,7 @@ export {
checkElementIconTile,
checkElementItalicSerif,
checkElementHeroEyebrow,
- checkRepeatedSectionKickersFromDoc,
+ checkKickerAboveHeadingFromDoc,
checkElementMotion,
checkElementGlow,
checkTypography,
diff --git a/.vibe/skills/impeccable/reference/craft-floor.md b/.vibe/skills/impeccable/reference/craft-floor.md
index 3d0c7a8fb..4ea9b8fa4 100644
--- a/.vibe/skills/impeccable/reference/craft-floor.md
+++ b/.vibe/skills/impeccable/reference/craft-floor.md
@@ -23,7 +23,7 @@ Page scaffolds:
- Same-size cards of icon plus heading plus text as the page structure. Cards are the lazy container; nested cards are always wrong.
- The hero-metric template: big number, small label, supporting stats, accent.
-- A tracked uppercase eyebrow over every section. One named kicker is a system; an eyebrow everywhere is grammar you did not choose.
+- A kicker or eyebrow above a heading. This one is a ban, not a default: no brief earns it back. The heading carries its own weight; delete the label and let the heading speak.
- Section numbers (01 / 02 / 03) unless the sequence itself carries information the reader needs.
- A modal for a task that needs neither interruption nor protected focus.
diff --git a/.vibe/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.vibe/skills/impeccable/scripts/detector/browser/injected/index.mjs
index e61c536ee..dfc725a8e 100644
--- a/.vibe/skills/impeccable/scripts/detector/browser/injected/index.mjs
+++ b/.vibe/skills/impeccable/scripts/detector/browser/injected/index.mjs
@@ -1529,7 +1529,7 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, typoFindings);
}
- const sectionKickerFindings = checkRepeatedSectionKickersDOM()
+ const sectionKickerFindings = checkKickerAboveHeadingDOM()
.map(f => ({ type: f.id, detail: f.snippet }))
.filter(f => _ruleOk(f.type));
if (sectionKickerFindings.length > 0) {
diff --git a/.vibe/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.vibe/skills/impeccable/scripts/detector/detect-antipatterns-browser.js
index a728d4f9e..3d948ad39 100644
--- a/.vibe/skills/impeccable/scripts/detector/detect-antipatterns-browser.js
+++ b/.vibe/skills/impeccable/scripts/detector/detect-antipatterns-browser.js
@@ -309,15 +309,14 @@ const ANTIPATTERNS = [
skillGuideline: 'tiny uppercase tracked label above the hero headline',
},
{
- id: 'repeated-section-kickers',
+ id: 'kicker-above-heading',
category: 'slop',
scopes: ['type'],
- severity: 'advisory',
- name: 'Repeated section kicker labels',
+ name: 'Kicker / eyebrow label above heading',
description:
- 'Repeating tiny uppercase tracked labels above section headings turns a brand page into AI editorial scaffolding. Replace them with stronger structure, artifacts, imagery, or a deliberate brand system.',
+ 'A tiny tracked uppercase or small-caps label sitting as its own block directly above a heading is banned outright, repeated or not. Generated kickers never earn their place: the heading carries its own weight. Delete the label and let the heading speak; if the words matter, work them into the heading or the body.',
skillSection: 'Typography',
- skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding',
+ skillGuideline: 'kicker or eyebrow labels above headings',
},
{
id: 'numbered-section-labels',
@@ -1258,12 +1257,15 @@ function checkHeroEyebrow(opts) {
}];
}
-function checkRepeatedSectionKickers(opts) {
- const { candidates, minCount = 3 } = opts;
- if (!Array.isArray(candidates) || candidates.length < minCount) return [];
+// Outright ban: one kicker is one too many, so every collected candidate is
+// a finding. The judgment lives in the candidate gate (isKickerCandidate) and
+// the collector's context skips, not in a repetition count.
+function checkKickerAboveHeading(opts) {
+ const { candidates } = opts;
+ if (!Array.isArray(candidates)) return [];
return candidates.map(candidate => ({
- id: 'repeated-section-kickers',
- snippet: `repeated section kicker "${candidate.kickerText}" before ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`,
+ id: 'kicker-above-heading',
+ snippet: `kicker "${candidate.kickerText}" above ${candidate.headingTag} "${candidate.headingText}"`,
}));
}
@@ -3190,7 +3192,7 @@ function parseColorResolved(str, customPropMap) {
return parseAnyColor(resolved);
}
-const REPEATED_KICKER_SKIP_SELECTOR = [
+const KICKER_SKIP_SELECTOR = [
'nav',
'form',
'table',
@@ -3209,7 +3211,7 @@ const REPEATED_KICKER_SKIP_SELECTOR = [
'[data-impeccable-allow-kickers]',
].join(',');
-const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [
+const KICKER_CARD_CONTEXT_SELECTOR = [
'article',
'button',
'a',
@@ -3227,23 +3229,32 @@ function cleanInlineText(el) {
.trim();
}
-function isRepeatedKickerCardContext(heading, kicker) {
- const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR);
+function isKickerCardContext(heading, kicker) {
+ const item = heading.closest?.(KICKER_CARD_CONTEXT_SELECTOR);
return Boolean(item && (!item.contains || item.contains(kicker)));
}
-function isRepeatedKickerCandidate(opts) {
+// Meta lines above headlines join category and date (or path crumbs) with
+// separator glyphs, or carry a year. A kicker is one short phrase; metadata
+// keeps its markers.
+const KICKER_META_TEXT_RE = /[·•|]|\s[\/›»>]\s|\b(19|20)\d{2}\b/;
+// Legal and document numbering: "Section 4.2", "Article IX", "§ 12.3",
+// dotted decimal outlines. The label identifies the clause, so it stays.
+const KICKER_DOC_NUMBERING_RE = /^(§|\d+(\.\d+)+\b|(section|article|clause|appendix|exhibit|schedule|chapter|part|rule|title)\s+([\divxlc]+\b|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)\b)/i;
+
+function isKickerCandidate(opts) {
const {
- headingTag,
+ headingLevel,
headingText,
headingFontSize,
kickerTag,
kickerText,
kickerTextTransform,
+ kickerFontVariant,
kickerFontSize,
kickerLetterSpacing,
} = opts;
- if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
+ if (!headingLevel || headingLevel > 4) return false;
if (!headingText || headingText.length < 3) return false;
if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false;
if (!(headingFontSize >= 20)) return false;
@@ -3251,9 +3262,13 @@ function isRepeatedKickerCandidate(opts) {
if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;
if (!kickerText || kickerText.length < 2 || kickerText.length > 34) return false;
if (/^step\s*\d+/i.test(kickerText) || /^\d{1,2}$/.test(kickerText)) return false;
+ if (KICKER_META_TEXT_RE.test(kickerText)) return false;
+ if (KICKER_DOC_NUMBERING_RE.test(kickerText)) return false;
+ const isSmallCaps = /small-caps/.test(kickerFontVariant || '');
const isUppercased = kickerTextTransform === 'uppercase'
- || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText));
+ || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText))
+ || isSmallCaps;
if (!isUppercased) return false;
if (!(kickerFontSize > 0 && kickerFontSize <= 14)) return false;
const minTrackedSpacing = Math.max(1, kickerFontSize * 0.08);
@@ -3261,37 +3276,64 @@ function isRepeatedKickerCandidate(opts) {
return true;
}
-function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpacing) {
+// Resolve a heading level for the anchor element: 1-4 for h1-h4, aria-level
+// (default 2) for role="heading" elements, 0 otherwise.
+function kickerHeadingLevel(heading) {
+ const tag = heading.tagName.toLowerCase();
+ const byTag = /^h([1-6])$/.exec(tag);
+ if (byTag) return parseInt(byTag[1], 10);
+ const role = heading.getAttribute?.('role') || '';
+ if (role.toLowerCase() !== 'heading') return 0;
+ const ariaLevel = parseInt(heading.getAttribute?.('aria-level') || '', 10);
+ return Number.isFinite(ariaLevel) && ariaLevel >= 1 ? ariaLevel : 2;
+}
+
+function collectKickerCandidates(doc, getStyle, resolveLetterSpacing) {
const candidates = [];
- for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ for (const heading of doc.querySelectorAll('h1, h2, h3, h4, [role="heading"]')) {
+ const headingLevel = kickerHeadingLevel(heading);
+ if (!headingLevel || headingLevel > 4) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ // Application contexts (tab panels, dialogs) use compact context labels
+ // above headings to describe state, not to decorate. Same carve-out the
+ // hero-eyebrow rule makes.
+ if (heading.closest?.('[role="tabpanel"], [role="dialog"], [role="application"], dialog')) continue;
const kicker = heading.previousElementSibling;
- if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
- if (isRepeatedKickerCardContext(heading, kicker)) continue;
+ if (!kicker || kicker.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ if (isKickerCardContext(heading, kicker)) continue;
const headingStyle = getStyle(heading);
const kickerStyle = getStyle(kicker);
+ const headingTag = heading.tagName.toLowerCase();
const headingText = (heading.textContent || '').replace(/\s+/g, ' ').trim();
const kickerText = cleanInlineText(kicker) || (kicker.textContent || '').replace(/\s+/g, ' ').trim();
const headingFontSize = resolveLetterSpacing(headingStyle.fontSize || '', 16) || parseFloat(headingStyle.fontSize) || 0;
const kickerFontSize = resolveLetterSpacing(kickerStyle.fontSize || '', 16) || parseFloat(kickerStyle.fontSize) || 0;
const kickerLetterSpacing = resolveLetterSpacing(kickerStyle.letterSpacing || '', kickerFontSize);
- if (!isRepeatedKickerCandidate({
- headingTag: heading.tagName.toLowerCase(),
+ if (!isKickerCandidate({
+ headingLevel,
headingText,
headingFontSize,
kickerTag: kicker.tagName.toLowerCase(),
kickerText,
kickerTextTransform: kickerStyle.textTransform || '',
+ kickerFontVariant: `${kickerStyle.fontVariant || ''} ${kickerStyle.fontVariantCaps || ''}`,
kickerFontSize,
kickerLetterSpacing,
})) {
continue;
}
+ // A tracked-caps eyebrow above a hero-scale h1 belongs to
+ // hero-eyebrow-chip (which also covers the accent-bold and dash-prefix
+ // stylings there). Stand down so one element gets one finding.
+ if (headingTag === 'h1' && headingFontSize >= 48 && kickerLetterSpacing >= 1.6) {
+ continue;
+ }
+
candidates.push({
- headingTag: heading.tagName.toLowerCase(),
+ headingTag,
headingText: headingText.replace(/^"|"$/g, '').slice(0, 60),
kickerText: kickerText.slice(0, 40),
});
@@ -3299,17 +3341,17 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac
return candidates;
}
-function checkRepeatedSectionKickersDOM() {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingDOM() {
+ const candidates = collectKickerCandidates(
document,
(el) => getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
// ── Numbered section labels ─────────────────────────────────────────────────
-// Sibling of the repeated-kicker rule: instead of a tracked uppercase word,
+// Sibling of the kicker-above-heading rule: instead of a tracked uppercase word,
// the section scaffold is a tiny numeric index riding beside each section
// heading — bare and zero-padded, or an index joined to a short micro-label
// by a separator glyph. The kicker rule deliberately excludes bare 1-2 digit
@@ -3363,7 +3405,7 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
const candidates = [];
const seenLabels = new Set();
for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
// The index sits either directly before the heading, or before the
// wrapper the heading leads (label | …
…).
let label = heading.previousElementSibling;
@@ -3373,9 +3415,9 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
if (firstChild === heading) label = parent.previousElementSibling;
}
if (!label || seenLabels.has(label)) continue;
- if (label.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (label.closest?.(KICKER_SKIP_SELECTOR)) continue;
if (HEADING_TAGS.has(label.tagName.toLowerCase())) continue;
- if (isRepeatedKickerCardContext(heading, label)) continue;
+ if (isKickerCardContext(heading, label)) continue;
const labelText = cleanInlineText(label) || (label.textContent || '').replace(/\s+/g, ' ').trim();
const parsed = parseNumberedLabelText(labelText);
@@ -4548,13 +4590,13 @@ function checkElementHeroEyebrow(el, style, tag, window, customPropMap) {
});
}
-function checkRepeatedSectionKickersFromDoc(doc, win) {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingFromDoc(doc, win) {
+ const candidates = collectKickerCandidates(
doc,
(el) => win.getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
function checkElementMotion(tag, style) {
@@ -7712,7 +7754,7 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, typoFindings);
}
- const sectionKickerFindings = checkRepeatedSectionKickersDOM()
+ const sectionKickerFindings = checkKickerAboveHeadingDOM()
.map(f => ({ type: f.id, detail: f.snippet }))
.filter(f => _ruleOk(f.type));
if (sectionKickerFindings.length > 0) {
diff --git a/.vibe/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/.vibe/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs
index 53d7be48c..939eed779 100644
--- a/.vibe/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs
+++ b/.vibe/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs
@@ -223,7 +223,7 @@ function unwrapCssAtLayer(source) {
// ---------------------------------------------------------------------------
const STATIC_INHERITED_PROPS = new Set([
- 'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight',
+ 'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight', 'fontVariant',
'lineHeight', 'letterSpacing', 'textTransform', 'textAlign', 'hyphens',
'webkitHyphens',
]);
@@ -252,6 +252,7 @@ const STATIC_DEFAULT_STYLE = {
fontFamily: '',
fontSize: '16px',
fontStyle: 'normal',
+ fontVariant: 'normal',
fontWeight: '400',
lineHeight: 'normal',
letterSpacing: 'normal',
diff --git a/.vibe/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/.vibe/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs
index 1ebb3e427..4e2074042 100644
--- a/.vibe/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs
+++ b/.vibe/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs
@@ -27,11 +27,11 @@ import {
checkElementRadialSpotlight,
checkCreamPalette,
checkHtmlPatterns,
+ checkKickerAboveHeadingFromDoc,
checkNumberedSectionLabelsFromDoc,
checkPageLayout,
checkPageQualityFromDoc,
checkRepeatedContainerTextFromDoc,
- checkRepeatedSectionKickersFromDoc,
resolveBackground,
resolveBorderRadiusPx,
} from '../../rules/checks.mjs';
@@ -202,7 +202,7 @@ async function detectHtml(filePath, options = {}) {
for (const f of runPageCheck('typography-rules', () => checkStaticPageTypography(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
- for (const f of runPageCheck('repeated-section-kickers', () => checkRepeatedSectionKickersFromDoc(document, window))) {
+ for (const f of runPageCheck('kicker-above-heading', () => checkKickerAboveHeadingFromDoc(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of runPageCheck('numbered-section-labels', () => checkNumberedSectionLabelsFromDoc(document, window))) {
diff --git a/.vibe/skills/impeccable/scripts/detector/registry/antipatterns.mjs b/.vibe/skills/impeccable/scripts/detector/registry/antipatterns.mjs
index c47d39524..38ce07390 100644
--- a/.vibe/skills/impeccable/scripts/detector/registry/antipatterns.mjs
+++ b/.vibe/skills/impeccable/scripts/detector/registry/antipatterns.mjs
@@ -198,15 +198,14 @@ const ANTIPATTERNS = [
skillGuideline: 'tiny uppercase tracked label above the hero headline',
},
{
- id: 'repeated-section-kickers',
+ id: 'kicker-above-heading',
category: 'slop',
scopes: ['type'],
- severity: 'advisory',
- name: 'Repeated section kicker labels',
+ name: 'Kicker / eyebrow label above heading',
description:
- 'Repeating tiny uppercase tracked labels above section headings turns a brand page into AI editorial scaffolding. Replace them with stronger structure, artifacts, imagery, or a deliberate brand system.',
+ 'A tiny tracked uppercase or small-caps label sitting as its own block directly above a heading is banned outright, repeated or not. Generated kickers never earn their place: the heading carries its own weight. Delete the label and let the heading speak; if the words matter, work them into the heading or the body.',
skillSection: 'Typography',
- skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding',
+ skillGuideline: 'kicker or eyebrow labels above headings',
},
{
id: 'numbered-section-labels',
diff --git a/.vibe/skills/impeccable/scripts/detector/rules/checks.mjs b/.vibe/skills/impeccable/scripts/detector/rules/checks.mjs
index 3e810076b..3bf26b9b5 100644
--- a/.vibe/skills/impeccable/scripts/detector/rules/checks.mjs
+++ b/.vibe/skills/impeccable/scripts/detector/rules/checks.mjs
@@ -456,12 +456,15 @@ function checkHeroEyebrow(opts) {
}];
}
-function checkRepeatedSectionKickers(opts) {
- const { candidates, minCount = 3 } = opts;
- if (!Array.isArray(candidates) || candidates.length < minCount) return [];
+// Outright ban: one kicker is one too many, so every collected candidate is
+// a finding. The judgment lives in the candidate gate (isKickerCandidate) and
+// the collector's context skips, not in a repetition count.
+function checkKickerAboveHeading(opts) {
+ const { candidates } = opts;
+ if (!Array.isArray(candidates)) return [];
return candidates.map(candidate => ({
- id: 'repeated-section-kickers',
- snippet: `repeated section kicker "${candidate.kickerText}" before ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`,
+ id: 'kicker-above-heading',
+ snippet: `kicker "${candidate.kickerText}" above ${candidate.headingTag} "${candidate.headingText}"`,
}));
}
@@ -2388,7 +2391,7 @@ function parseColorResolved(str, customPropMap) {
return parseAnyColor(resolved);
}
-const REPEATED_KICKER_SKIP_SELECTOR = [
+const KICKER_SKIP_SELECTOR = [
'nav',
'form',
'table',
@@ -2407,7 +2410,7 @@ const REPEATED_KICKER_SKIP_SELECTOR = [
'[data-impeccable-allow-kickers]',
].join(',');
-const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [
+const KICKER_CARD_CONTEXT_SELECTOR = [
'article',
'button',
'a',
@@ -2425,23 +2428,32 @@ function cleanInlineText(el) {
.trim();
}
-function isRepeatedKickerCardContext(heading, kicker) {
- const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR);
+function isKickerCardContext(heading, kicker) {
+ const item = heading.closest?.(KICKER_CARD_CONTEXT_SELECTOR);
return Boolean(item && (!item.contains || item.contains(kicker)));
}
-function isRepeatedKickerCandidate(opts) {
+// Meta lines above headlines join category and date (or path crumbs) with
+// separator glyphs, or carry a year. A kicker is one short phrase; metadata
+// keeps its markers.
+const KICKER_META_TEXT_RE = /[·•|]|\s[\/›»>]\s|\b(19|20)\d{2}\b/;
+// Legal and document numbering: "Section 4.2", "Article IX", "§ 12.3",
+// dotted decimal outlines. The label identifies the clause, so it stays.
+const KICKER_DOC_NUMBERING_RE = /^(§|\d+(\.\d+)+\b|(section|article|clause|appendix|exhibit|schedule|chapter|part|rule|title)\s+([\divxlc]+\b|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)\b)/i;
+
+function isKickerCandidate(opts) {
const {
- headingTag,
+ headingLevel,
headingText,
headingFontSize,
kickerTag,
kickerText,
kickerTextTransform,
+ kickerFontVariant,
kickerFontSize,
kickerLetterSpacing,
} = opts;
- if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
+ if (!headingLevel || headingLevel > 4) return false;
if (!headingText || headingText.length < 3) return false;
if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false;
if (!(headingFontSize >= 20)) return false;
@@ -2449,9 +2461,13 @@ function isRepeatedKickerCandidate(opts) {
if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;
if (!kickerText || kickerText.length < 2 || kickerText.length > 34) return false;
if (/^step\s*\d+/i.test(kickerText) || /^\d{1,2}$/.test(kickerText)) return false;
+ if (KICKER_META_TEXT_RE.test(kickerText)) return false;
+ if (KICKER_DOC_NUMBERING_RE.test(kickerText)) return false;
+ const isSmallCaps = /small-caps/.test(kickerFontVariant || '');
const isUppercased = kickerTextTransform === 'uppercase'
- || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText));
+ || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText))
+ || isSmallCaps;
if (!isUppercased) return false;
if (!(kickerFontSize > 0 && kickerFontSize <= 14)) return false;
const minTrackedSpacing = Math.max(1, kickerFontSize * 0.08);
@@ -2459,37 +2475,64 @@ function isRepeatedKickerCandidate(opts) {
return true;
}
-function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpacing) {
+// Resolve a heading level for the anchor element: 1-4 for h1-h4, aria-level
+// (default 2) for role="heading" elements, 0 otherwise.
+function kickerHeadingLevel(heading) {
+ const tag = heading.tagName.toLowerCase();
+ const byTag = /^h([1-6])$/.exec(tag);
+ if (byTag) return parseInt(byTag[1], 10);
+ const role = heading.getAttribute?.('role') || '';
+ if (role.toLowerCase() !== 'heading') return 0;
+ const ariaLevel = parseInt(heading.getAttribute?.('aria-level') || '', 10);
+ return Number.isFinite(ariaLevel) && ariaLevel >= 1 ? ariaLevel : 2;
+}
+
+function collectKickerCandidates(doc, getStyle, resolveLetterSpacing) {
const candidates = [];
- for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ for (const heading of doc.querySelectorAll('h1, h2, h3, h4, [role="heading"]')) {
+ const headingLevel = kickerHeadingLevel(heading);
+ if (!headingLevel || headingLevel > 4) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ // Application contexts (tab panels, dialogs) use compact context labels
+ // above headings to describe state, not to decorate. Same carve-out the
+ // hero-eyebrow rule makes.
+ if (heading.closest?.('[role="tabpanel"], [role="dialog"], [role="application"], dialog')) continue;
const kicker = heading.previousElementSibling;
- if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
- if (isRepeatedKickerCardContext(heading, kicker)) continue;
+ if (!kicker || kicker.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ if (isKickerCardContext(heading, kicker)) continue;
const headingStyle = getStyle(heading);
const kickerStyle = getStyle(kicker);
+ const headingTag = heading.tagName.toLowerCase();
const headingText = (heading.textContent || '').replace(/\s+/g, ' ').trim();
const kickerText = cleanInlineText(kicker) || (kicker.textContent || '').replace(/\s+/g, ' ').trim();
const headingFontSize = resolveLetterSpacing(headingStyle.fontSize || '', 16) || parseFloat(headingStyle.fontSize) || 0;
const kickerFontSize = resolveLetterSpacing(kickerStyle.fontSize || '', 16) || parseFloat(kickerStyle.fontSize) || 0;
const kickerLetterSpacing = resolveLetterSpacing(kickerStyle.letterSpacing || '', kickerFontSize);
- if (!isRepeatedKickerCandidate({
- headingTag: heading.tagName.toLowerCase(),
+ if (!isKickerCandidate({
+ headingLevel,
headingText,
headingFontSize,
kickerTag: kicker.tagName.toLowerCase(),
kickerText,
kickerTextTransform: kickerStyle.textTransform || '',
+ kickerFontVariant: `${kickerStyle.fontVariant || ''} ${kickerStyle.fontVariantCaps || ''}`,
kickerFontSize,
kickerLetterSpacing,
})) {
continue;
}
+ // A tracked-caps eyebrow above a hero-scale h1 belongs to
+ // hero-eyebrow-chip (which also covers the accent-bold and dash-prefix
+ // stylings there). Stand down so one element gets one finding.
+ if (headingTag === 'h1' && headingFontSize >= 48 && kickerLetterSpacing >= 1.6) {
+ continue;
+ }
+
candidates.push({
- headingTag: heading.tagName.toLowerCase(),
+ headingTag,
headingText: headingText.replace(/^"|"$/g, '').slice(0, 60),
kickerText: kickerText.slice(0, 40),
});
@@ -2497,17 +2540,17 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac
return candidates;
}
-function checkRepeatedSectionKickersDOM() {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingDOM() {
+ const candidates = collectKickerCandidates(
document,
(el) => getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
// ── Numbered section labels ─────────────────────────────────────────────────
-// Sibling of the repeated-kicker rule: instead of a tracked uppercase word,
+// Sibling of the kicker-above-heading rule: instead of a tracked uppercase word,
// the section scaffold is a tiny numeric index riding beside each section
// heading — bare and zero-padded, or an index joined to a short micro-label
// by a separator glyph. The kicker rule deliberately excludes bare 1-2 digit
@@ -2561,7 +2604,7 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
const candidates = [];
const seenLabels = new Set();
for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
// The index sits either directly before the heading, or before the
// wrapper the heading leads (label | …
…).
let label = heading.previousElementSibling;
@@ -2571,9 +2614,9 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
if (firstChild === heading) label = parent.previousElementSibling;
}
if (!label || seenLabels.has(label)) continue;
- if (label.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (label.closest?.(KICKER_SKIP_SELECTOR)) continue;
if (HEADING_TAGS.has(label.tagName.toLowerCase())) continue;
- if (isRepeatedKickerCardContext(heading, label)) continue;
+ if (isKickerCardContext(heading, label)) continue;
const labelText = cleanInlineText(label) || (label.textContent || '').replace(/\s+/g, ' ').trim();
const parsed = parseNumberedLabelText(labelText);
@@ -3746,13 +3789,13 @@ function checkElementHeroEyebrow(el, style, tag, window, customPropMap) {
});
}
-function checkRepeatedSectionKickersFromDoc(doc, win) {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingFromDoc(doc, win) {
+ const candidates = collectKickerCandidates(
doc,
(el) => win.getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
function checkElementMotion(tag, style) {
@@ -5393,7 +5436,7 @@ export {
checkItalicSerif,
isAccentColor,
checkHeroEyebrow,
- checkRepeatedSectionKickers,
+ checkKickerAboveHeading,
checkMotion,
checkGlow,
scanCssTextForGlow,
@@ -5424,9 +5467,9 @@ export {
parseAnyColor,
parseColorResolved,
cleanInlineText,
- isRepeatedKickerCandidate,
- collectRepeatedSectionKickerCandidates,
- checkRepeatedSectionKickersDOM,
+ isKickerCandidate,
+ collectKickerCandidates,
+ checkKickerAboveHeadingDOM,
parseNumberedLabelText,
isNumberedSectionLabelCandidate,
collectNumberedSectionLabelCandidates,
@@ -5458,7 +5501,7 @@ export {
checkElementIconTile,
checkElementItalicSerif,
checkElementHeroEyebrow,
- checkRepeatedSectionKickersFromDoc,
+ checkKickerAboveHeadingFromDoc,
checkElementMotion,
checkElementGlow,
checkTypography,
diff --git a/plugin/skills/impeccable/reference/craft-floor.md b/plugin/skills/impeccable/reference/craft-floor.md
index 3d0c7a8fb..4ea9b8fa4 100644
--- a/plugin/skills/impeccable/reference/craft-floor.md
+++ b/plugin/skills/impeccable/reference/craft-floor.md
@@ -23,7 +23,7 @@ Page scaffolds:
- Same-size cards of icon plus heading plus text as the page structure. Cards are the lazy container; nested cards are always wrong.
- The hero-metric template: big number, small label, supporting stats, accent.
-- A tracked uppercase eyebrow over every section. One named kicker is a system; an eyebrow everywhere is grammar you did not choose.
+- A kicker or eyebrow above a heading. This one is a ban, not a default: no brief earns it back. The heading carries its own weight; delete the label and let the heading speak.
- Section numbers (01 / 02 / 03) unless the sequence itself carries information the reader needs.
- A modal for a task that needs neither interruption nor protected focus.
diff --git a/plugin/skills/impeccable/scripts/detector/browser/injected/index.mjs b/plugin/skills/impeccable/scripts/detector/browser/injected/index.mjs
index e61c536ee..dfc725a8e 100644
--- a/plugin/skills/impeccable/scripts/detector/browser/injected/index.mjs
+++ b/plugin/skills/impeccable/scripts/detector/browser/injected/index.mjs
@@ -1529,7 +1529,7 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, typoFindings);
}
- const sectionKickerFindings = checkRepeatedSectionKickersDOM()
+ const sectionKickerFindings = checkKickerAboveHeadingDOM()
.map(f => ({ type: f.id, detail: f.snippet }))
.filter(f => _ruleOk(f.type));
if (sectionKickerFindings.length > 0) {
diff --git a/plugin/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/plugin/skills/impeccable/scripts/detector/detect-antipatterns-browser.js
index a728d4f9e..3d948ad39 100644
--- a/plugin/skills/impeccable/scripts/detector/detect-antipatterns-browser.js
+++ b/plugin/skills/impeccable/scripts/detector/detect-antipatterns-browser.js
@@ -309,15 +309,14 @@ const ANTIPATTERNS = [
skillGuideline: 'tiny uppercase tracked label above the hero headline',
},
{
- id: 'repeated-section-kickers',
+ id: 'kicker-above-heading',
category: 'slop',
scopes: ['type'],
- severity: 'advisory',
- name: 'Repeated section kicker labels',
+ name: 'Kicker / eyebrow label above heading',
description:
- 'Repeating tiny uppercase tracked labels above section headings turns a brand page into AI editorial scaffolding. Replace them with stronger structure, artifacts, imagery, or a deliberate brand system.',
+ 'A tiny tracked uppercase or small-caps label sitting as its own block directly above a heading is banned outright, repeated or not. Generated kickers never earn their place: the heading carries its own weight. Delete the label and let the heading speak; if the words matter, work them into the heading or the body.',
skillSection: 'Typography',
- skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding',
+ skillGuideline: 'kicker or eyebrow labels above headings',
},
{
id: 'numbered-section-labels',
@@ -1258,12 +1257,15 @@ function checkHeroEyebrow(opts) {
}];
}
-function checkRepeatedSectionKickers(opts) {
- const { candidates, minCount = 3 } = opts;
- if (!Array.isArray(candidates) || candidates.length < minCount) return [];
+// Outright ban: one kicker is one too many, so every collected candidate is
+// a finding. The judgment lives in the candidate gate (isKickerCandidate) and
+// the collector's context skips, not in a repetition count.
+function checkKickerAboveHeading(opts) {
+ const { candidates } = opts;
+ if (!Array.isArray(candidates)) return [];
return candidates.map(candidate => ({
- id: 'repeated-section-kickers',
- snippet: `repeated section kicker "${candidate.kickerText}" before ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`,
+ id: 'kicker-above-heading',
+ snippet: `kicker "${candidate.kickerText}" above ${candidate.headingTag} "${candidate.headingText}"`,
}));
}
@@ -3190,7 +3192,7 @@ function parseColorResolved(str, customPropMap) {
return parseAnyColor(resolved);
}
-const REPEATED_KICKER_SKIP_SELECTOR = [
+const KICKER_SKIP_SELECTOR = [
'nav',
'form',
'table',
@@ -3209,7 +3211,7 @@ const REPEATED_KICKER_SKIP_SELECTOR = [
'[data-impeccable-allow-kickers]',
].join(',');
-const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [
+const KICKER_CARD_CONTEXT_SELECTOR = [
'article',
'button',
'a',
@@ -3227,23 +3229,32 @@ function cleanInlineText(el) {
.trim();
}
-function isRepeatedKickerCardContext(heading, kicker) {
- const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR);
+function isKickerCardContext(heading, kicker) {
+ const item = heading.closest?.(KICKER_CARD_CONTEXT_SELECTOR);
return Boolean(item && (!item.contains || item.contains(kicker)));
}
-function isRepeatedKickerCandidate(opts) {
+// Meta lines above headlines join category and date (or path crumbs) with
+// separator glyphs, or carry a year. A kicker is one short phrase; metadata
+// keeps its markers.
+const KICKER_META_TEXT_RE = /[·•|]|\s[\/›»>]\s|\b(19|20)\d{2}\b/;
+// Legal and document numbering: "Section 4.2", "Article IX", "§ 12.3",
+// dotted decimal outlines. The label identifies the clause, so it stays.
+const KICKER_DOC_NUMBERING_RE = /^(§|\d+(\.\d+)+\b|(section|article|clause|appendix|exhibit|schedule|chapter|part|rule|title)\s+([\divxlc]+\b|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)\b)/i;
+
+function isKickerCandidate(opts) {
const {
- headingTag,
+ headingLevel,
headingText,
headingFontSize,
kickerTag,
kickerText,
kickerTextTransform,
+ kickerFontVariant,
kickerFontSize,
kickerLetterSpacing,
} = opts;
- if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
+ if (!headingLevel || headingLevel > 4) return false;
if (!headingText || headingText.length < 3) return false;
if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false;
if (!(headingFontSize >= 20)) return false;
@@ -3251,9 +3262,13 @@ function isRepeatedKickerCandidate(opts) {
if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;
if (!kickerText || kickerText.length < 2 || kickerText.length > 34) return false;
if (/^step\s*\d+/i.test(kickerText) || /^\d{1,2}$/.test(kickerText)) return false;
+ if (KICKER_META_TEXT_RE.test(kickerText)) return false;
+ if (KICKER_DOC_NUMBERING_RE.test(kickerText)) return false;
+ const isSmallCaps = /small-caps/.test(kickerFontVariant || '');
const isUppercased = kickerTextTransform === 'uppercase'
- || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText));
+ || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText))
+ || isSmallCaps;
if (!isUppercased) return false;
if (!(kickerFontSize > 0 && kickerFontSize <= 14)) return false;
const minTrackedSpacing = Math.max(1, kickerFontSize * 0.08);
@@ -3261,37 +3276,64 @@ function isRepeatedKickerCandidate(opts) {
return true;
}
-function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpacing) {
+// Resolve a heading level for the anchor element: 1-4 for h1-h4, aria-level
+// (default 2) for role="heading" elements, 0 otherwise.
+function kickerHeadingLevel(heading) {
+ const tag = heading.tagName.toLowerCase();
+ const byTag = /^h([1-6])$/.exec(tag);
+ if (byTag) return parseInt(byTag[1], 10);
+ const role = heading.getAttribute?.('role') || '';
+ if (role.toLowerCase() !== 'heading') return 0;
+ const ariaLevel = parseInt(heading.getAttribute?.('aria-level') || '', 10);
+ return Number.isFinite(ariaLevel) && ariaLevel >= 1 ? ariaLevel : 2;
+}
+
+function collectKickerCandidates(doc, getStyle, resolveLetterSpacing) {
const candidates = [];
- for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ for (const heading of doc.querySelectorAll('h1, h2, h3, h4, [role="heading"]')) {
+ const headingLevel = kickerHeadingLevel(heading);
+ if (!headingLevel || headingLevel > 4) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ // Application contexts (tab panels, dialogs) use compact context labels
+ // above headings to describe state, not to decorate. Same carve-out the
+ // hero-eyebrow rule makes.
+ if (heading.closest?.('[role="tabpanel"], [role="dialog"], [role="application"], dialog')) continue;
const kicker = heading.previousElementSibling;
- if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
- if (isRepeatedKickerCardContext(heading, kicker)) continue;
+ if (!kicker || kicker.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ if (isKickerCardContext(heading, kicker)) continue;
const headingStyle = getStyle(heading);
const kickerStyle = getStyle(kicker);
+ const headingTag = heading.tagName.toLowerCase();
const headingText = (heading.textContent || '').replace(/\s+/g, ' ').trim();
const kickerText = cleanInlineText(kicker) || (kicker.textContent || '').replace(/\s+/g, ' ').trim();
const headingFontSize = resolveLetterSpacing(headingStyle.fontSize || '', 16) || parseFloat(headingStyle.fontSize) || 0;
const kickerFontSize = resolveLetterSpacing(kickerStyle.fontSize || '', 16) || parseFloat(kickerStyle.fontSize) || 0;
const kickerLetterSpacing = resolveLetterSpacing(kickerStyle.letterSpacing || '', kickerFontSize);
- if (!isRepeatedKickerCandidate({
- headingTag: heading.tagName.toLowerCase(),
+ if (!isKickerCandidate({
+ headingLevel,
headingText,
headingFontSize,
kickerTag: kicker.tagName.toLowerCase(),
kickerText,
kickerTextTransform: kickerStyle.textTransform || '',
+ kickerFontVariant: `${kickerStyle.fontVariant || ''} ${kickerStyle.fontVariantCaps || ''}`,
kickerFontSize,
kickerLetterSpacing,
})) {
continue;
}
+ // A tracked-caps eyebrow above a hero-scale h1 belongs to
+ // hero-eyebrow-chip (which also covers the accent-bold and dash-prefix
+ // stylings there). Stand down so one element gets one finding.
+ if (headingTag === 'h1' && headingFontSize >= 48 && kickerLetterSpacing >= 1.6) {
+ continue;
+ }
+
candidates.push({
- headingTag: heading.tagName.toLowerCase(),
+ headingTag,
headingText: headingText.replace(/^"|"$/g, '').slice(0, 60),
kickerText: kickerText.slice(0, 40),
});
@@ -3299,17 +3341,17 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac
return candidates;
}
-function checkRepeatedSectionKickersDOM() {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingDOM() {
+ const candidates = collectKickerCandidates(
document,
(el) => getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
// ── Numbered section labels ─────────────────────────────────────────────────
-// Sibling of the repeated-kicker rule: instead of a tracked uppercase word,
+// Sibling of the kicker-above-heading rule: instead of a tracked uppercase word,
// the section scaffold is a tiny numeric index riding beside each section
// heading — bare and zero-padded, or an index joined to a short micro-label
// by a separator glyph. The kicker rule deliberately excludes bare 1-2 digit
@@ -3363,7 +3405,7 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
const candidates = [];
const seenLabels = new Set();
for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
// The index sits either directly before the heading, or before the
// wrapper the heading leads (label | …
…).
let label = heading.previousElementSibling;
@@ -3373,9 +3415,9 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
if (firstChild === heading) label = parent.previousElementSibling;
}
if (!label || seenLabels.has(label)) continue;
- if (label.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (label.closest?.(KICKER_SKIP_SELECTOR)) continue;
if (HEADING_TAGS.has(label.tagName.toLowerCase())) continue;
- if (isRepeatedKickerCardContext(heading, label)) continue;
+ if (isKickerCardContext(heading, label)) continue;
const labelText = cleanInlineText(label) || (label.textContent || '').replace(/\s+/g, ' ').trim();
const parsed = parseNumberedLabelText(labelText);
@@ -4548,13 +4590,13 @@ function checkElementHeroEyebrow(el, style, tag, window, customPropMap) {
});
}
-function checkRepeatedSectionKickersFromDoc(doc, win) {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingFromDoc(doc, win) {
+ const candidates = collectKickerCandidates(
doc,
(el) => win.getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
function checkElementMotion(tag, style) {
@@ -7712,7 +7754,7 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, typoFindings);
}
- const sectionKickerFindings = checkRepeatedSectionKickersDOM()
+ const sectionKickerFindings = checkKickerAboveHeadingDOM()
.map(f => ({ type: f.id, detail: f.snippet }))
.filter(f => _ruleOk(f.type));
if (sectionKickerFindings.length > 0) {
diff --git a/plugin/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs b/plugin/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs
index 53d7be48c..939eed779 100644
--- a/plugin/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs
+++ b/plugin/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs
@@ -223,7 +223,7 @@ function unwrapCssAtLayer(source) {
// ---------------------------------------------------------------------------
const STATIC_INHERITED_PROPS = new Set([
- 'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight',
+ 'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight', 'fontVariant',
'lineHeight', 'letterSpacing', 'textTransform', 'textAlign', 'hyphens',
'webkitHyphens',
]);
@@ -252,6 +252,7 @@ const STATIC_DEFAULT_STYLE = {
fontFamily: '',
fontSize: '16px',
fontStyle: 'normal',
+ fontVariant: 'normal',
fontWeight: '400',
lineHeight: 'normal',
letterSpacing: 'normal',
diff --git a/plugin/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs b/plugin/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs
index 1ebb3e427..4e2074042 100644
--- a/plugin/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs
+++ b/plugin/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs
@@ -27,11 +27,11 @@ import {
checkElementRadialSpotlight,
checkCreamPalette,
checkHtmlPatterns,
+ checkKickerAboveHeadingFromDoc,
checkNumberedSectionLabelsFromDoc,
checkPageLayout,
checkPageQualityFromDoc,
checkRepeatedContainerTextFromDoc,
- checkRepeatedSectionKickersFromDoc,
resolveBackground,
resolveBorderRadiusPx,
} from '../../rules/checks.mjs';
@@ -202,7 +202,7 @@ async function detectHtml(filePath, options = {}) {
for (const f of runPageCheck('typography-rules', () => checkStaticPageTypography(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
- for (const f of runPageCheck('repeated-section-kickers', () => checkRepeatedSectionKickersFromDoc(document, window))) {
+ for (const f of runPageCheck('kicker-above-heading', () => checkKickerAboveHeadingFromDoc(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of runPageCheck('numbered-section-labels', () => checkNumberedSectionLabelsFromDoc(document, window))) {
diff --git a/plugin/skills/impeccable/scripts/detector/registry/antipatterns.mjs b/plugin/skills/impeccable/scripts/detector/registry/antipatterns.mjs
index c47d39524..38ce07390 100644
--- a/plugin/skills/impeccable/scripts/detector/registry/antipatterns.mjs
+++ b/plugin/skills/impeccable/scripts/detector/registry/antipatterns.mjs
@@ -198,15 +198,14 @@ const ANTIPATTERNS = [
skillGuideline: 'tiny uppercase tracked label above the hero headline',
},
{
- id: 'repeated-section-kickers',
+ id: 'kicker-above-heading',
category: 'slop',
scopes: ['type'],
- severity: 'advisory',
- name: 'Repeated section kicker labels',
+ name: 'Kicker / eyebrow label above heading',
description:
- 'Repeating tiny uppercase tracked labels above section headings turns a brand page into AI editorial scaffolding. Replace them with stronger structure, artifacts, imagery, or a deliberate brand system.',
+ 'A tiny tracked uppercase or small-caps label sitting as its own block directly above a heading is banned outright, repeated or not. Generated kickers never earn their place: the heading carries its own weight. Delete the label and let the heading speak; if the words matter, work them into the heading or the body.',
skillSection: 'Typography',
- skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding',
+ skillGuideline: 'kicker or eyebrow labels above headings',
},
{
id: 'numbered-section-labels',
diff --git a/plugin/skills/impeccable/scripts/detector/rules/checks.mjs b/plugin/skills/impeccable/scripts/detector/rules/checks.mjs
index 3e810076b..3bf26b9b5 100644
--- a/plugin/skills/impeccable/scripts/detector/rules/checks.mjs
+++ b/plugin/skills/impeccable/scripts/detector/rules/checks.mjs
@@ -456,12 +456,15 @@ function checkHeroEyebrow(opts) {
}];
}
-function checkRepeatedSectionKickers(opts) {
- const { candidates, minCount = 3 } = opts;
- if (!Array.isArray(candidates) || candidates.length < minCount) return [];
+// Outright ban: one kicker is one too many, so every collected candidate is
+// a finding. The judgment lives in the candidate gate (isKickerCandidate) and
+// the collector's context skips, not in a repetition count.
+function checkKickerAboveHeading(opts) {
+ const { candidates } = opts;
+ if (!Array.isArray(candidates)) return [];
return candidates.map(candidate => ({
- id: 'repeated-section-kickers',
- snippet: `repeated section kicker "${candidate.kickerText}" before ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`,
+ id: 'kicker-above-heading',
+ snippet: `kicker "${candidate.kickerText}" above ${candidate.headingTag} "${candidate.headingText}"`,
}));
}
@@ -2388,7 +2391,7 @@ function parseColorResolved(str, customPropMap) {
return parseAnyColor(resolved);
}
-const REPEATED_KICKER_SKIP_SELECTOR = [
+const KICKER_SKIP_SELECTOR = [
'nav',
'form',
'table',
@@ -2407,7 +2410,7 @@ const REPEATED_KICKER_SKIP_SELECTOR = [
'[data-impeccable-allow-kickers]',
].join(',');
-const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [
+const KICKER_CARD_CONTEXT_SELECTOR = [
'article',
'button',
'a',
@@ -2425,23 +2428,32 @@ function cleanInlineText(el) {
.trim();
}
-function isRepeatedKickerCardContext(heading, kicker) {
- const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR);
+function isKickerCardContext(heading, kicker) {
+ const item = heading.closest?.(KICKER_CARD_CONTEXT_SELECTOR);
return Boolean(item && (!item.contains || item.contains(kicker)));
}
-function isRepeatedKickerCandidate(opts) {
+// Meta lines above headlines join category and date (or path crumbs) with
+// separator glyphs, or carry a year. A kicker is one short phrase; metadata
+// keeps its markers.
+const KICKER_META_TEXT_RE = /[·•|]|\s[\/›»>]\s|\b(19|20)\d{2}\b/;
+// Legal and document numbering: "Section 4.2", "Article IX", "§ 12.3",
+// dotted decimal outlines. The label identifies the clause, so it stays.
+const KICKER_DOC_NUMBERING_RE = /^(§|\d+(\.\d+)+\b|(section|article|clause|appendix|exhibit|schedule|chapter|part|rule|title)\s+([\divxlc]+\b|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)\b)/i;
+
+function isKickerCandidate(opts) {
const {
- headingTag,
+ headingLevel,
headingText,
headingFontSize,
kickerTag,
kickerText,
kickerTextTransform,
+ kickerFontVariant,
kickerFontSize,
kickerLetterSpacing,
} = opts;
- if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
+ if (!headingLevel || headingLevel > 4) return false;
if (!headingText || headingText.length < 3) return false;
if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false;
if (!(headingFontSize >= 20)) return false;
@@ -2449,9 +2461,13 @@ function isRepeatedKickerCandidate(opts) {
if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;
if (!kickerText || kickerText.length < 2 || kickerText.length > 34) return false;
if (/^step\s*\d+/i.test(kickerText) || /^\d{1,2}$/.test(kickerText)) return false;
+ if (KICKER_META_TEXT_RE.test(kickerText)) return false;
+ if (KICKER_DOC_NUMBERING_RE.test(kickerText)) return false;
+ const isSmallCaps = /small-caps/.test(kickerFontVariant || '');
const isUppercased = kickerTextTransform === 'uppercase'
- || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText));
+ || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText))
+ || isSmallCaps;
if (!isUppercased) return false;
if (!(kickerFontSize > 0 && kickerFontSize <= 14)) return false;
const minTrackedSpacing = Math.max(1, kickerFontSize * 0.08);
@@ -2459,37 +2475,64 @@ function isRepeatedKickerCandidate(opts) {
return true;
}
-function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpacing) {
+// Resolve a heading level for the anchor element: 1-4 for h1-h4, aria-level
+// (default 2) for role="heading" elements, 0 otherwise.
+function kickerHeadingLevel(heading) {
+ const tag = heading.tagName.toLowerCase();
+ const byTag = /^h([1-6])$/.exec(tag);
+ if (byTag) return parseInt(byTag[1], 10);
+ const role = heading.getAttribute?.('role') || '';
+ if (role.toLowerCase() !== 'heading') return 0;
+ const ariaLevel = parseInt(heading.getAttribute?.('aria-level') || '', 10);
+ return Number.isFinite(ariaLevel) && ariaLevel >= 1 ? ariaLevel : 2;
+}
+
+function collectKickerCandidates(doc, getStyle, resolveLetterSpacing) {
const candidates = [];
- for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ for (const heading of doc.querySelectorAll('h1, h2, h3, h4, [role="heading"]')) {
+ const headingLevel = kickerHeadingLevel(heading);
+ if (!headingLevel || headingLevel > 4) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ // Application contexts (tab panels, dialogs) use compact context labels
+ // above headings to describe state, not to decorate. Same carve-out the
+ // hero-eyebrow rule makes.
+ if (heading.closest?.('[role="tabpanel"], [role="dialog"], [role="application"], dialog')) continue;
const kicker = heading.previousElementSibling;
- if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
- if (isRepeatedKickerCardContext(heading, kicker)) continue;
+ if (!kicker || kicker.closest?.(KICKER_SKIP_SELECTOR)) continue;
+ if (isKickerCardContext(heading, kicker)) continue;
const headingStyle = getStyle(heading);
const kickerStyle = getStyle(kicker);
+ const headingTag = heading.tagName.toLowerCase();
const headingText = (heading.textContent || '').replace(/\s+/g, ' ').trim();
const kickerText = cleanInlineText(kicker) || (kicker.textContent || '').replace(/\s+/g, ' ').trim();
const headingFontSize = resolveLetterSpacing(headingStyle.fontSize || '', 16) || parseFloat(headingStyle.fontSize) || 0;
const kickerFontSize = resolveLetterSpacing(kickerStyle.fontSize || '', 16) || parseFloat(kickerStyle.fontSize) || 0;
const kickerLetterSpacing = resolveLetterSpacing(kickerStyle.letterSpacing || '', kickerFontSize);
- if (!isRepeatedKickerCandidate({
- headingTag: heading.tagName.toLowerCase(),
+ if (!isKickerCandidate({
+ headingLevel,
headingText,
headingFontSize,
kickerTag: kicker.tagName.toLowerCase(),
kickerText,
kickerTextTransform: kickerStyle.textTransform || '',
+ kickerFontVariant: `${kickerStyle.fontVariant || ''} ${kickerStyle.fontVariantCaps || ''}`,
kickerFontSize,
kickerLetterSpacing,
})) {
continue;
}
+ // A tracked-caps eyebrow above a hero-scale h1 belongs to
+ // hero-eyebrow-chip (which also covers the accent-bold and dash-prefix
+ // stylings there). Stand down so one element gets one finding.
+ if (headingTag === 'h1' && headingFontSize >= 48 && kickerLetterSpacing >= 1.6) {
+ continue;
+ }
+
candidates.push({
- headingTag: heading.tagName.toLowerCase(),
+ headingTag,
headingText: headingText.replace(/^"|"$/g, '').slice(0, 60),
kickerText: kickerText.slice(0, 40),
});
@@ -2497,17 +2540,17 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac
return candidates;
}
-function checkRepeatedSectionKickersDOM() {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingDOM() {
+ const candidates = collectKickerCandidates(
document,
(el) => getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
// ── Numbered section labels ─────────────────────────────────────────────────
-// Sibling of the repeated-kicker rule: instead of a tracked uppercase word,
+// Sibling of the kicker-above-heading rule: instead of a tracked uppercase word,
// the section scaffold is a tiny numeric index riding beside each section
// heading — bare and zero-padded, or an index joined to a short micro-label
// by a separator glyph. The kicker rule deliberately excludes bare 1-2 digit
@@ -2561,7 +2604,7 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
const candidates = [];
const seenLabels = new Set();
for (const heading of doc.querySelectorAll('h2, h3, h4')) {
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
// The index sits either directly before the heading, or before the
// wrapper the heading leads (label | …
…).
let label = heading.previousElementSibling;
@@ -2571,9 +2614,9 @@ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpaci
if (firstChild === heading) label = parent.previousElementSibling;
}
if (!label || seenLabels.has(label)) continue;
- if (label.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
+ if (label.closest?.(KICKER_SKIP_SELECTOR)) continue;
if (HEADING_TAGS.has(label.tagName.toLowerCase())) continue;
- if (isRepeatedKickerCardContext(heading, label)) continue;
+ if (isKickerCardContext(heading, label)) continue;
const labelText = cleanInlineText(label) || (label.textContent || '').replace(/\s+/g, ' ').trim();
const parsed = parseNumberedLabelText(labelText);
@@ -3746,13 +3789,13 @@ function checkElementHeroEyebrow(el, style, tag, window, customPropMap) {
});
}
-function checkRepeatedSectionKickersFromDoc(doc, win) {
- const candidates = collectRepeatedSectionKickerCandidates(
+function checkKickerAboveHeadingFromDoc(doc, win) {
+ const candidates = collectKickerCandidates(
doc,
(el) => win.getComputedStyle(el),
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
);
- return checkRepeatedSectionKickers({ candidates });
+ return checkKickerAboveHeading({ candidates });
}
function checkElementMotion(tag, style) {
@@ -5393,7 +5436,7 @@ export {
checkItalicSerif,
isAccentColor,
checkHeroEyebrow,
- checkRepeatedSectionKickers,
+ checkKickerAboveHeading,
checkMotion,
checkGlow,
scanCssTextForGlow,
@@ -5424,9 +5467,9 @@ export {
parseAnyColor,
parseColorResolved,
cleanInlineText,
- isRepeatedKickerCandidate,
- collectRepeatedSectionKickerCandidates,
- checkRepeatedSectionKickersDOM,
+ isKickerCandidate,
+ collectKickerCandidates,
+ checkKickerAboveHeadingDOM,
parseNumberedLabelText,
isNumberedSectionLabelCandidate,
collectNumberedSectionLabelCandidates,
@@ -5458,7 +5501,7 @@ export {
checkElementIconTile,
checkElementItalicSerif,
checkElementHeroEyebrow,
- checkRepeatedSectionKickersFromDoc,
+ checkKickerAboveHeadingFromDoc,
checkElementMotion,
checkElementGlow,
checkTypography,