-
Run anti-pattern scans outside the skill: in CI, in a PR check, or against a whole directory. 25 deterministic rules, no LLM required, JSON output ready for build gates.
+
Run anti-pattern scans outside the skill: in CI, in a PR check, or against a whole directory. 27 deterministic rules, no LLM required, JSON output ready for build gates.
@@ -1047,7 +1047,7 @@ import '../styles/sub-pages.css';
- Renamed
frontend-design to impeccable. The core skill now shares its name with the project, and the teach subcommand moved from /teach-impeccable to /impeccable teach. One skill, one namespace.
- Data-driven skill rewrite. The core skill was rebuilt against an internal eval framework that runs the same brief through frontier models with and without the skill loaded, then measures how much the output collapses into monoculture. The result: dramatically more font and color diversity, sharper overall design quality, and much stronger Codex support. The biggest unlock was an anti-attractor procedure that forces the model to enumerate and reject its reflex defaults before picking. Validated on gpt-5.4 and Qwen 3.6 Plus across 15 niches.
- - Anti-pattern detection engine. 25 deterministic rules across typography, color, layout, motion, and quality. Handles oklch, oklab, lch, and lab color formats, CSS variables inside border shorthands, gradient-backed text, and emoji-only nodes.
+ - Anti-pattern detection engine. 27 deterministic rules across typography, color, layout, motion, and quality. Handles oklch, oklab, lch, and lab color formats, CSS variables inside border shorthands, gradient-backed text, and emoji-only nodes.
- CLI:
npx impeccable detect. Scans HTML, CSS, JSX/TSX, Vue, Svelte, and CSS-in-JS. Framework detection, multi-file import tracking, Puppeteer-backed live URL scanning, CI-ready JSON output, and a --fast regex mode for huge codebases.
- Chrome DevTools extension. One-click detection on any page: yours, staging, production, or someone else's. Reads live computed styles, surfaces findings in an interactive panel, and highlights elements on the page. In Chrome Web Store review.
/critique got teeth. Persona sub-agents review in parallel, score against Nielsen's heuristics, run the detector automatically, and open a live browser overlay so you can walk each finding in place.
diff --git a/site/scripts/generated/counts.js b/site/scripts/generated/counts.js
index e2d1a00d8..88acc117b 100644
--- a/site/scripts/generated/counts.js
+++ b/site/scripts/generated/counts.js
@@ -1,3 +1,3 @@
// GENERATED by build.js — do not edit
export const COMMAND_COUNT = 23;
-export const DETECTION_COUNT = 25;
+export const DETECTION_COUNT = 27;
diff --git a/source/skills/impeccable/reference/critique.md b/source/skills/impeccable/reference/critique.md
index 2f5321e30..cfd87d656 100644
--- a/source/skills/impeccable/reference/critique.md
+++ b/source/skills/impeccable/reference/critique.md
@@ -39,7 +39,7 @@ Return structured findings covering: AI slop verdict, heuristic scores, cognitiv
#### Assessment B: Automated Detection
-Run the bundled deterministic detector, which flags 25 specific patterns (AI slop tells + general design quality).
+Run the bundled deterministic detector, which flags 27 specific patterns (AI slop tells + general design quality).
**CLI scan**:
```bash
diff --git a/src/detect-antipatterns-browser.js b/src/detect-antipatterns-browser.js
index 212583a20..00c608176 100644
--- a/src/detect-antipatterns-browser.js
+++ b/src/detect-antipatterns-browser.js
@@ -103,6 +103,23 @@ const GENERIC_FONTS = new Set([
'inherit', 'initial', 'unset', 'revert',
]);
+// Serif faces that show up in italic-display heroes. The rule also fires when
+// the primary face is unknown but the stack ends in the generic `serif` token,
+// which catches custom/private faces with a serif fallback.
+const KNOWN_SERIF_FONTS = new Set([
+ 'fraunces', 'recoleta', 'newsreader', 'playfair display', 'playfair',
+ 'cormorant', 'cormorant garamond', 'garamond', 'eb garamond',
+ 'tiempos', 'tiempos headline', 'tiempos text',
+ 'lora', 'vollkorn', 'spectral',
+ 'source serif pro', 'source serif 4', 'source serif',
+ 'ibm plex serif', 'merriweather',
+ 'libre caslon', 'libre baskerville', 'baskerville',
+ 'georgia', 'times new roman', 'times',
+ 'dm serif display', 'dm serif text',
+ 'instrument serif', 'gt sectra', 'ogg', 'canela',
+ 'freight display', 'freight text',
+]);
+
const ANTIPATTERNS = [
// ── AI slop: tells that something was AI-generated ──
{
@@ -222,6 +239,24 @@ const ANTIPATTERNS = [
skillSection: 'Typography',
skillGuideline: 'large icons with rounded corners above every heading',
},
+ {
+ id: 'italic-serif-display',
+ category: 'slop',
+ name: 'Italic serif display headline',
+ description:
+ 'Oversized italic serif (Fraunces, Recoleta, Playfair, Newsreader-italic) as the primary hero headline reads as taste in isolation but has become the universal AI-startup landing page hero. Set roman, or move to a non-serif display face. Editorial / magazine register may legitimately want this — judge by context.',
+ skillSection: 'Typography',
+ skillGuideline: 'oversized italic serif as the hero headline',
+ },
+ {
+ id: 'hero-eyebrow-chip',
+ category: 'slop',
+ name: 'Hero eyebrow / pill chip',
+ description:
+ 'A tiny uppercase letter-spaced label sitting immediately above an oversized hero headline — or the same shape rendered as a pill chip — is now the default AI SaaS hero. Drop the eyebrow, integrate the kicker into the headline, or run it as a navigation breadcrumb instead.',
+ skillSection: 'Typography',
+ skillGuideline: 'tiny uppercase tracked label above the hero headline',
+ },
// ── Quality: general design and accessibility issues ──
{
@@ -627,6 +662,77 @@ function checkIconTile(opts) {
}];
}
+// Resolve the primary (non-generic) face from a font-family string and return
+// whether the resolved primary is serif. Two paths:
+// 1. Primary face is in KNOWN_SERIF_FONTS → serif.
+// 2. Primary face is unknown but the stack ends in the generic `serif`
+// token → treat as serif. Authors who declare `font-family: 'X', serif`
+// almost always have a serif primary; a sans declared with a serif
+// fallback is a code smell, not the common case.
+// Returns { primary, isSerif } so the snippet can name the face.
+function resolveSerif(fontFamily) {
+ if (!fontFamily) return { primary: null, isSerif: false };
+ const tokens = fontFamily.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
+ const primary = tokens.find(f => f && !GENERIC_FONTS.has(f)) || null;
+ if (!primary) return { primary: null, isSerif: false };
+ if (KNOWN_SERIF_FONTS.has(primary)) return { primary, isSerif: true };
+ if (tokens.includes('serif')) return { primary, isSerif: true };
+ return { primary, isSerif: false };
+}
+
+function checkItalicSerif(opts) {
+ const { tag, fontStyle, fontFamily, fontSize, headingText } = opts;
+ if (fontStyle !== 'italic') return [];
+ // Anchor the rule on hero-scale text. h1 is the canonical hero element;
+ // h2 ≥ 48px catches the cases where the design demotes the visual hero
+ // to an h2 but keeps the size.
+ if (tag !== 'h1' && !(tag === 'h2' && fontSize >= 48)) return [];
+ if (fontSize < 48) return [];
+ const { primary, isSerif } = resolveSerif(fontFamily);
+ if (!isSerif) return [];
+
+ const text = (headingText || '').trim().slice(0, 60);
+ return [{
+ id: 'italic-serif-display',
+ snippet: `italic serif ${tag} (${primary || 'serif'}) at ${Math.round(fontSize)}px "${text}"`,
+ }];
+}
+
+// Sibling-relationship rule. Anchor on a hero-scale h1, look at the
+// previousElementSibling, and gate on uppercase + tracked + small.
+function checkHeroEyebrow(opts) {
+ const {
+ headingTag, headingText, headingFontSize,
+ siblingTag, siblingText, siblingTextTransform,
+ siblingFontSize, siblingLetterSpacing,
+ } = opts;
+ if (headingTag !== 'h1') return [];
+ if (!headingFontSize || headingFontSize < 48) return [];
+ if (!siblingTag) return [];
+ // An h2 above an h1 is a different anti-pattern (heading hierarchy / dual
+ // headings) — never an eyebrow.
+ if (HEADING_TAGS.has(siblingTag)) return [];
+
+ const text = (siblingText || '').trim();
+ if (text.length < 2 || text.length > 30) return [];
+
+ // Uppercase: either via text-transform, or the content is already typed
+ // uppercase (no lowercase letters, at least one uppercase letter).
+ const isUppercased = siblingTextTransform === 'uppercase'
+ || (/[A-Z]/.test(text) && !/[a-z]/.test(text));
+ if (!isUppercased) return [];
+
+ if (!(siblingLetterSpacing >= 1.6)) return [];
+ if (!(siblingFontSize > 0 && siblingFontSize <= 14)) return [];
+
+ const headingTextSnippet = (headingText || '').trim().slice(0, 60);
+ const eyebrowSnippet = text.slice(0, 40);
+ return [{
+ id: 'hero-eyebrow-chip',
+ snippet: `eyebrow chip "${eyebrowSnippet}" above ${headingTag} "${headingTextSnippet}"`,
+ }];
+}
+
const LAYOUT_TRANSITION_PROPS = new Set([
'width', 'height', 'padding', 'margin',
'max-height', 'max-width', 'min-height', 'min-width',
@@ -1093,6 +1199,38 @@ function checkElementIconTileDOM(el) {
});
}
+function checkElementItalicSerifDOM(el) {
+ const tag = el.tagName.toLowerCase();
+ if (tag !== 'h1' && tag !== 'h2') return [];
+ const style = getComputedStyle(el);
+ return checkItalicSerif({
+ tag,
+ fontStyle: style.fontStyle || '',
+ fontFamily: style.fontFamily || '',
+ fontSize: parseFloat(style.fontSize) || 0,
+ headingText: el.textContent || '',
+ });
+}
+
+function checkElementHeroEyebrowDOM(el) {
+ const tag = el.tagName.toLowerCase();
+ if (tag !== 'h1') return [];
+ const sibling = el.previousElementSibling;
+ if (!sibling) return [];
+ const headStyle = getComputedStyle(el);
+ const sibStyle = getComputedStyle(sibling);
+ return checkHeroEyebrow({
+ headingTag: tag,
+ headingText: el.textContent || '',
+ headingFontSize: parseFloat(headStyle.fontSize) || 0,
+ siblingTag: sibling.tagName.toLowerCase(),
+ siblingText: sibling.textContent || '',
+ siblingTextTransform: sibStyle.textTransform || '',
+ siblingFontSize: parseFloat(sibStyle.fontSize) || 0,
+ siblingLetterSpacing: parseFloat(sibStyle.letterSpacing) || 0,
+ });
+}
+
function checkElementMotionDOM(el) {
const tag = el.tagName.toLowerCase();
if (SAFE_TAGS.has(tag)) return [];
@@ -1488,6 +1626,38 @@ function checkElementIconTile(el, tag, window) {
});
}
+function checkElementItalicSerif(el, style, tag) {
+ if (tag !== 'h1' && tag !== 'h2') return [];
+ return checkItalicSerif({
+ tag,
+ fontStyle: style.fontStyle || '',
+ fontFamily: style.fontFamily || '',
+ fontSize: parseFloat(style.fontSize) || 0,
+ headingText: el.textContent || '',
+ });
+}
+
+function checkElementHeroEyebrow(el, style, tag, window) {
+ if (tag !== 'h1') return [];
+ const sibling = el.previousElementSibling;
+ if (!sibling) return [];
+ const sibStyle = window.getComputedStyle(sibling);
+ const siblingFontSize = parseFloat(sibStyle.fontSize) || 0;
+ // resolveLengthPx returns null for 'normal' / 'auto'; coerce to 0 so the
+ // gate falls through cleanly. jsdom returns letter-spacing verbatim
+ // (e.g. '0.15em'), unlike real browsers, so this conversion is required.
+ return checkHeroEyebrow({
+ headingTag: tag,
+ headingText: el.textContent || '',
+ headingFontSize: parseFloat(style.fontSize) || 0,
+ siblingTag: sibling.tagName.toLowerCase(),
+ siblingText: sibling.textContent || '',
+ siblingTextTransform: sibStyle.textTransform || '',
+ siblingFontSize,
+ siblingLetterSpacing: resolveLengthPx(sibStyle.letterSpacing, siblingFontSize) || 0,
+ });
+}
+
function checkElementMotion(tag, style) {
return checkMotion({
tag,
@@ -2396,6 +2566,8 @@ if (IS_BROWSER) {
...checkElementGlowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
...checkElementAIPaletteDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
...checkElementIconTileDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
+ ...checkElementItalicSerifDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
+ ...checkElementHeroEyebrowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
...checkElementQualityDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
].filter(f => _ruleOk(f.type));
diff --git a/src/detect-antipatterns.mjs b/src/detect-antipatterns.mjs
index 625240683..7dd20c21f 100644
--- a/src/detect-antipatterns.mjs
+++ b/src/detect-antipatterns.mjs
@@ -99,6 +99,23 @@ const GENERIC_FONTS = new Set([
'inherit', 'initial', 'unset', 'revert',
]);
+// Serif faces that show up in italic-display heroes. The rule also fires when
+// the primary face is unknown but the stack ends in the generic `serif` token,
+// which catches custom/private faces with a serif fallback.
+const KNOWN_SERIF_FONTS = new Set([
+ 'fraunces', 'recoleta', 'newsreader', 'playfair display', 'playfair',
+ 'cormorant', 'cormorant garamond', 'garamond', 'eb garamond',
+ 'tiempos', 'tiempos headline', 'tiempos text',
+ 'lora', 'vollkorn', 'spectral',
+ 'source serif pro', 'source serif 4', 'source serif',
+ 'ibm plex serif', 'merriweather',
+ 'libre caslon', 'libre baskerville', 'baskerville',
+ 'georgia', 'times new roman', 'times',
+ 'dm serif display', 'dm serif text',
+ 'instrument serif', 'gt sectra', 'ogg', 'canela',
+ 'freight display', 'freight text',
+]);
+
const ANTIPATTERNS = [
// ── AI slop: tells that something was AI-generated ──
{
@@ -218,6 +235,24 @@ const ANTIPATTERNS = [
skillSection: 'Typography',
skillGuideline: 'large icons with rounded corners above every heading',
},
+ {
+ id: 'italic-serif-display',
+ category: 'slop',
+ name: 'Italic serif display headline',
+ description:
+ 'Oversized italic serif (Fraunces, Recoleta, Playfair, Newsreader-italic) as the primary hero headline reads as taste in isolation but has become the universal AI-startup landing page hero. Set roman, or move to a non-serif display face. Editorial / magazine register may legitimately want this — judge by context.',
+ skillSection: 'Typography',
+ skillGuideline: 'oversized italic serif as the hero headline',
+ },
+ {
+ id: 'hero-eyebrow-chip',
+ category: 'slop',
+ name: 'Hero eyebrow / pill chip',
+ description:
+ 'A tiny uppercase letter-spaced label sitting immediately above an oversized hero headline — or the same shape rendered as a pill chip — is now the default AI SaaS hero. Drop the eyebrow, integrate the kicker into the headline, or run it as a navigation breadcrumb instead.',
+ skillSection: 'Typography',
+ skillGuideline: 'tiny uppercase tracked label above the hero headline',
+ },
// ── Quality: general design and accessibility issues ──
{
@@ -623,6 +658,77 @@ function checkIconTile(opts) {
}];
}
+// Resolve the primary (non-generic) face from a font-family string and return
+// whether the resolved primary is serif. Two paths:
+// 1. Primary face is in KNOWN_SERIF_FONTS → serif.
+// 2. Primary face is unknown but the stack ends in the generic `serif`
+// token → treat as serif. Authors who declare `font-family: 'X', serif`
+// almost always have a serif primary; a sans declared with a serif
+// fallback is a code smell, not the common case.
+// Returns { primary, isSerif } so the snippet can name the face.
+function resolveSerif(fontFamily) {
+ if (!fontFamily) return { primary: null, isSerif: false };
+ const tokens = fontFamily.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
+ const primary = tokens.find(f => f && !GENERIC_FONTS.has(f)) || null;
+ if (!primary) return { primary: null, isSerif: false };
+ if (KNOWN_SERIF_FONTS.has(primary)) return { primary, isSerif: true };
+ if (tokens.includes('serif')) return { primary, isSerif: true };
+ return { primary, isSerif: false };
+}
+
+function checkItalicSerif(opts) {
+ const { tag, fontStyle, fontFamily, fontSize, headingText } = opts;
+ if (fontStyle !== 'italic') return [];
+ // Anchor the rule on hero-scale text. h1 is the canonical hero element;
+ // h2 ≥ 48px catches the cases where the design demotes the visual hero
+ // to an h2 but keeps the size.
+ if (tag !== 'h1' && !(tag === 'h2' && fontSize >= 48)) return [];
+ if (fontSize < 48) return [];
+ const { primary, isSerif } = resolveSerif(fontFamily);
+ if (!isSerif) return [];
+
+ const text = (headingText || '').trim().slice(0, 60);
+ return [{
+ id: 'italic-serif-display',
+ snippet: `italic serif ${tag} (${primary || 'serif'}) at ${Math.round(fontSize)}px "${text}"`,
+ }];
+}
+
+// Sibling-relationship rule. Anchor on a hero-scale h1, look at the
+// previousElementSibling, and gate on uppercase + tracked + small.
+function checkHeroEyebrow(opts) {
+ const {
+ headingTag, headingText, headingFontSize,
+ siblingTag, siblingText, siblingTextTransform,
+ siblingFontSize, siblingLetterSpacing,
+ } = opts;
+ if (headingTag !== 'h1') return [];
+ if (!headingFontSize || headingFontSize < 48) return [];
+ if (!siblingTag) return [];
+ // An h2 above an h1 is a different anti-pattern (heading hierarchy / dual
+ // headings) — never an eyebrow.
+ if (HEADING_TAGS.has(siblingTag)) return [];
+
+ const text = (siblingText || '').trim();
+ if (text.length < 2 || text.length > 30) return [];
+
+ // Uppercase: either via text-transform, or the content is already typed
+ // uppercase (no lowercase letters, at least one uppercase letter).
+ const isUppercased = siblingTextTransform === 'uppercase'
+ || (/[A-Z]/.test(text) && !/[a-z]/.test(text));
+ if (!isUppercased) return [];
+
+ if (!(siblingLetterSpacing >= 1.6)) return [];
+ if (!(siblingFontSize > 0 && siblingFontSize <= 14)) return [];
+
+ const headingTextSnippet = (headingText || '').trim().slice(0, 60);
+ const eyebrowSnippet = text.slice(0, 40);
+ return [{
+ id: 'hero-eyebrow-chip',
+ snippet: `eyebrow chip "${eyebrowSnippet}" above ${headingTag} "${headingTextSnippet}"`,
+ }];
+}
+
const LAYOUT_TRANSITION_PROPS = new Set([
'width', 'height', 'padding', 'margin',
'max-height', 'max-width', 'min-height', 'min-width',
@@ -1089,6 +1195,38 @@ function checkElementIconTileDOM(el) {
});
}
+function checkElementItalicSerifDOM(el) {
+ const tag = el.tagName.toLowerCase();
+ if (tag !== 'h1' && tag !== 'h2') return [];
+ const style = getComputedStyle(el);
+ return checkItalicSerif({
+ tag,
+ fontStyle: style.fontStyle || '',
+ fontFamily: style.fontFamily || '',
+ fontSize: parseFloat(style.fontSize) || 0,
+ headingText: el.textContent || '',
+ });
+}
+
+function checkElementHeroEyebrowDOM(el) {
+ const tag = el.tagName.toLowerCase();
+ if (tag !== 'h1') return [];
+ const sibling = el.previousElementSibling;
+ if (!sibling) return [];
+ const headStyle = getComputedStyle(el);
+ const sibStyle = getComputedStyle(sibling);
+ return checkHeroEyebrow({
+ headingTag: tag,
+ headingText: el.textContent || '',
+ headingFontSize: parseFloat(headStyle.fontSize) || 0,
+ siblingTag: sibling.tagName.toLowerCase(),
+ siblingText: sibling.textContent || '',
+ siblingTextTransform: sibStyle.textTransform || '',
+ siblingFontSize: parseFloat(sibStyle.fontSize) || 0,
+ siblingLetterSpacing: parseFloat(sibStyle.letterSpacing) || 0,
+ });
+}
+
function checkElementMotionDOM(el) {
const tag = el.tagName.toLowerCase();
if (SAFE_TAGS.has(tag)) return [];
@@ -1484,6 +1622,38 @@ function checkElementIconTile(el, tag, window) {
});
}
+function checkElementItalicSerif(el, style, tag) {
+ if (tag !== 'h1' && tag !== 'h2') return [];
+ return checkItalicSerif({
+ tag,
+ fontStyle: style.fontStyle || '',
+ fontFamily: style.fontFamily || '',
+ fontSize: parseFloat(style.fontSize) || 0,
+ headingText: el.textContent || '',
+ });
+}
+
+function checkElementHeroEyebrow(el, style, tag, window) {
+ if (tag !== 'h1') return [];
+ const sibling = el.previousElementSibling;
+ if (!sibling) return [];
+ const sibStyle = window.getComputedStyle(sibling);
+ const siblingFontSize = parseFloat(sibStyle.fontSize) || 0;
+ // resolveLengthPx returns null for 'normal' / 'auto'; coerce to 0 so the
+ // gate falls through cleanly. jsdom returns letter-spacing verbatim
+ // (e.g. '0.15em'), unlike real browsers, so this conversion is required.
+ return checkHeroEyebrow({
+ headingTag: tag,
+ headingText: el.textContent || '',
+ headingFontSize: parseFloat(style.fontSize) || 0,
+ siblingTag: sibling.tagName.toLowerCase(),
+ siblingText: sibling.textContent || '',
+ siblingTextTransform: sibStyle.textTransform || '',
+ siblingFontSize,
+ siblingLetterSpacing: resolveLengthPx(sibStyle.letterSpacing, siblingFontSize) || 0,
+ });
+}
+
function checkElementMotion(tag, style) {
return checkMotion({
tag,
@@ -2392,6 +2562,8 @@ if (IS_BROWSER) {
...checkElementGlowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
...checkElementAIPaletteDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
...checkElementIconTileDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
+ ...checkElementItalicSerifDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
+ ...checkElementHeroEyebrowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
...checkElementQualityDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
].filter(f => _ruleOk(f.type));
@@ -2797,6 +2969,12 @@ async function detectHtml(filePath) {
for (const f of checkElementIconTile(el, tag, window)) {
findings.push(finding(f.id, filePath, f.snippet));
}
+ for (const f of checkElementItalicSerif(el, style, tag)) {
+ findings.push(finding(f.id, filePath, f.snippet));
+ }
+ for (const f of checkElementHeroEyebrow(el, style, tag, window)) {
+ findings.push(finding(f.id, filePath, f.snippet));
+ }
for (const f of checkElementQuality(el, style, tag, window)) {
findings.push(finding(f.id, filePath, f.snippet));
}
diff --git a/tests/detect-antipatterns-fixtures.test.mjs b/tests/detect-antipatterns-fixtures.test.mjs
index 143a07152..1a64e6334 100644
--- a/tests/detect-antipatterns-fixtures.test.mjs
+++ b/tests/detect-antipatterns-fixtures.test.mjs
@@ -297,6 +297,80 @@ describe('detectHtml — layout', () => {
});
});
+describe('detectHtml — italic-serif-display', () => {
+ // Two-column fixture: left col flag, right col pass. Snippet embeds the
+ // heading text in quotes so the test can extract it via /"([^"]+)"/.
+ const SHOULD_FLAG = [
+ 'Fraunces 88px italic',
+ 'Recoleta 64px italic',
+ 'Playfair 72px italic',
+ 'Unknown Serif Generic Fallback',
+ ];
+ const SHOULD_PASS = [
+ 'Sans Italic Display',
+ 'Roman Serif Display',
+ 'Italic Serif Pull Quote',
+ // The italic inside the roman h1 is intentionally not detected in v1.
+ // The h1's own text "Inline Em Inside Roman" must not appear flagged.
+ 'Inline Em Inside Roman',
+ 'Italic Serif at 32px',
+ 'h1 Sans-Serif Roman',
+ ];
+
+ it('italic-serif-display: flags only the should-flag column', async () => {
+ const f = await detectHtml(path.join(FIXTURES, 'italic-serif-display.html'));
+ const flagged = new Set();
+ for (const r of f) {
+ if (r.antipattern !== 'italic-serif-display') continue;
+ const m = (r.snippet || '').match(/"([^"]+)"/);
+ if (m) flagged.add(m[1]);
+ }
+
+ for (const text of SHOULD_FLAG) {
+ assert.ok(flagged.has(text), `expected "${text}" to be flagged as italic-serif-display`);
+ }
+ for (const text of SHOULD_PASS) {
+ assert.ok(!flagged.has(text), `"${text}" should NOT be flagged as italic-serif-display`);
+ }
+ });
+});
+
+describe('detectHtml — hero-eyebrow-chip', () => {
+ const SHOULD_FLAG = [
+ 'Eyebrow Above Hero',
+ 'Span Eyebrow Above Hero',
+ 'Pill Chip Above Hero',
+ 'Already Uppercase Text',
+ ];
+ const SHOULD_PASS = [
+ 'Eyebrow With Normal Tracking',
+ 'Body-Sized Heading Below Eyebrow',
+ 'Uppercase Caption Far From Hero',
+ 'Hero With No Eyebrow',
+ 'Heading Above Heading',
+ 'Long Uppercase Sentence Above Hero',
+ ];
+
+ it('hero-eyebrow-chip: flags only the should-flag column', async () => {
+ const f = await detectHtml(path.join(FIXTURES, 'hero-eyebrow-chip.html'));
+ const flagged = new Set();
+ for (const r of f) {
+ if (r.antipattern !== 'hero-eyebrow-chip') continue;
+ // Snippet shape: ... above h1 "Heading Text"
+ const matches = [...(r.snippet || '').matchAll(/"([^"]+)"/g)];
+ // Last quoted token is the heading text
+ 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 hero-eyebrow-chip`);
+ }
+ for (const text of SHOULD_PASS) {
+ assert.ok(!flagged.has(text), `"${text}" should NOT be flagged as hero-eyebrow-chip`);
+ }
+ });
+});
+
describe('detectHtml — motion', () => {
// jsdom doesn't fully apply class-based styles, so the absolute finding counts
// are lower than what a real browser would see. The hardcoded counts below are
diff --git a/tests/fixtures/antipatterns/hero-eyebrow-chip.html b/tests/fixtures/antipatterns/hero-eyebrow-chip.html
new file mode 100644
index 000000000..53d74d75a
--- /dev/null
+++ b/tests/fixtures/antipatterns/hero-eyebrow-chip.html
@@ -0,0 +1,195 @@
+
+
+
+
+ Hero-Eyebrow-Chip — Should Flag vs Should Pass
+
+
+
+
+
+
+
+
Should flag
+
+
+
AI-NATIVE WORKFLOWS
+
Eyebrow Above Hero
+
Classic uppercase tracked div eyebrow above an 88px hero.
+
+
+
+
NEW IN 2026
+
Span Eyebrow Above Hero
+
Span variant with the same uppercase + tracking + small-size shape.
+
+
+
+
FEATURED
+
Pill Chip Above Hero
+
Pill-shaped chip with background, border-radius, padding.
+
+
+
+
NEW
+
Already Uppercase Text
+
Text typed uppercase, no text-transform, but matching tracking and size.
+
+
+
+
+
+
Should pass
+
+
+
UPPERCASE LABEL
+
Eyebrow With Normal Tracking
+
Uppercase label above a hero but with letter-spacing: normal.
+
+
+
+
SECTION KICKER
+
Body-Sized Heading Below Eyebrow
+
Eyebrow above a heading, but the heading is only 24px — not a hero.
+
+
+
+
CARD CAPTION
+
Plain body text below the caption.
+
Uppercase Caption Far From Hero
+
Hero exists, but its preceding sibling is a paragraph, not the eyebrow.
+
+
+
+
Hero With No Eyebrow
+
Bare h1 at 64px, nothing above it.
+
+
+
+
SECTION HEADING
+
Heading Above Heading
+
An h2 styled like an eyebrow above an h1 — heading-tag exclusion must skip this.
+
+
+
+
A VERY LONG UPPERCASE TABLE OF CONTENTS HEADER
+
Long Uppercase Sentence Above Hero
+
Uppercase tracked label, but text length exceeds the 30-char eyebrow ceiling.
+
+
+
+
+
+
diff --git a/tests/fixtures/antipatterns/italic-serif-display.html b/tests/fixtures/antipatterns/italic-serif-display.html
new file mode 100644
index 000000000..47cb6c735
--- /dev/null
+++ b/tests/fixtures/antipatterns/italic-serif-display.html
@@ -0,0 +1,190 @@
+
+
+
+
+ Italic-Serif-Display — Should Flag vs Should Pass
+
+
+
+
+
+
+
+
Should flag
+
+
+
Fraunces 88px italic
+
The Lumina hero pattern: oversized Fraunces italic display.
+
+
+
+
Recoleta 64px italic
+
Recoleta italic at 64px, generic serif fallback.
+
+
+
+
Playfair 72px italic
+
Playfair Display italic at hero scale.
+
+
+
+
Unknown Serif Generic Fallback
+
Primary font unknown to the detector, but the stack ends in generic serif.
+
+
+
+
+
+
Should pass
+
+
+
Sans Italic Display
+
Italic at hero scale, but in a sans-serif face.
+
+
+
+
Roman Serif Display
+
Fraunces at 88px, but font-style is normal (not italic).
+
+
+
+
Italic Serif Pull Quote
+
Italic serif at 24px is a legitimate pull-quote pattern, not a hero.
+
+
+
+
Inline Em Inside Roman
+
Roman h1 with an inline italic em — v1 deliberately does not flag this.
+
+
+
+
Italic Serif at 32px
+
Italic serif h2 below the 48px hero threshold.
+
+
+
+
h1 Sans-Serif Roman
+
Baseline-clean control: sans-serif, roman, hero scale.
+
+
+
+
+
+