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