mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-20 01:56:37 +03:00
* feat(detector): flag italic-serif display heroes and uppercase eyebrow chips (#127) Two new rules covering the structural tells of late-2025/early-2026 AI-generated marketing pages. - italic-serif-display: oversized italic serif (Fraunces, Recoleta, Newsreader, Playfair, Cormorant, Tiempos, ...) as the primary hero headline. Anchored on h1 (or h2 at >= 48px) with font-style: italic and a serif primary face. - hero-eyebrow-chip: uppercase letter-spaced label sitting as the previousElementSibling of a hero h1 (font-size >= 48px). Bounded text length 2-30 chars, letter-spacing >= 1.6px, font-size <= 14px. The pill-chip variant (background + border-radius: 999px) falls out of the same gates for free. Both follow the existing icon-tile-stack pattern: pure check function + browser DOM adapter + jsdom adapter, wired into both element loops. Two-column fixtures (4 flag / 6 pass each) drive the jsdom tests. Skill copy in source/skills/impeccable/reference/typography.md and critique.md calls out the patterns by name. The italic-serif rule's description acknowledges that editorial/magazine register may legitimately want the pattern -- judge by context. Closes #127 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Add sandbox gotchas for Codex * Trim verbose detector skill copy --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Paul Bakaus <paulbakaus@pauls-mbp-3.lan>
This commit is contained in:
co-authored by
Claude Opus 4.7
Paul Bakaus
parent
b8f09c8142
commit
444e4acad3
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -20,6 +20,15 @@ If you load the Impeccable skill from `.agents/skills/impeccable/SKILL.md` (or a
|
||||
|
||||
Run `bun run build` after changing anything in `source/`, transformer code, or user-facing counts.
|
||||
|
||||
## Sandbox gotchas for Codex agents
|
||||
|
||||
Some repo workflows need to run outside the sandbox in the desktop app:
|
||||
|
||||
- GitHub SSH operations that depend on the 1Password SSH agent, such as `gh pr checkout`, may fail in the sandbox with `sign_and_send_pubkey` or no 1Password approval prompt. Rerun them outside the sandbox instead of falling back to unrelated workarounds.
|
||||
- `bun run build` rewrites committed harness directories such as `.agents/skills/`. In the sandbox, Bun can hit filesystem errors while removing/recreating those trees (for example `EFAULT` on `.agents/skills`). Rerun the build outside the sandbox before treating it as a real build failure.
|
||||
- Puppeteer/headless-Chrome tests, especially `node --test tests/detect-antipatterns-browser.test.mjs` and the browser portion of `bun run test`, can hang in the sandbox while launching Chrome. Run them outside the sandbox for authoritative results.
|
||||
- The jsdom fixture suite is intentionally run with Node, not Bun: use `node --test tests/detect-antipatterns-fixtures.test.mjs` or the `bun run test` script. A direct `bun test tests/detect-antipatterns-fixtures.test.mjs` can time out and is not the supported signal.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
|
||||
Use ESM, semicolons, and the existing two-space indentation style in JS, HTML, and CSS. Prefer small, single-purpose modules over large abstractions. Keep filenames descriptive and lowercase with hyphens where needed; skill entrypoints stay as `SKILL.md`, helper scripts use `.js` or `.mjs`. In source frontmatter, use clear kebab-case names and concise descriptions. There is no dedicated formatter or linter configured here, so match surrounding code closely.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Impeccable CLI
|
||||
|
||||
Detect UI anti-patterns and design quality issues from the command line. Scans HTML, CSS, JSX, TSX, Vue, and Svelte files for 25 specific patterns including AI-generated UI tells, accessibility violations, and general design quality problems.
|
||||
Detect UI anti-patterns and design quality issues from the command line. Scans HTML, CSS, JSX, TSX, Vue, and Svelte files for 27 specific patterns including AI-generated UI tells, accessibility violations, and general design quality problems.
|
||||
|
||||
## Quick Start
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -489,7 +489,7 @@ import '../styles/sub-pages.css';
|
||||
|
||||
<article class="why-panel" id="why-panel-6" role="tabpanel" data-index="6" hidden>
|
||||
<h3 class="why-panel-title">Block AI slop before it ships.</h3>
|
||||
<p class="why-panel-body">Run <code>npx impeccable detect src/</code> on a PR branch and get JSON output with every purple gradient, nested card, low-contrast label, and gradient-text heading caught. Fail the build on severity. 25 deterministic checks, no LLM required, no API key, no flaky scoring. Gate your PRs the same way you gate lint and types.</p>
|
||||
<p class="why-panel-body">Run <code>npx impeccable detect src/</code> on a PR branch and get JSON output with every purple gradient, nested card, low-contrast label, and gradient-text heading caught. Fail the build on severity. 27 deterministic checks, no LLM required, no API key, no flaky scoring. Gate your PRs the same way you gate lint and types.</p>
|
||||
<div class="why-visual why-visual--ci">
|
||||
<div class="why-ci-window">
|
||||
<div class="why-ci-header">
|
||||
@@ -505,7 +505,7 @@ import '../styles/sub-pages.css';
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="why-panel-meta">25 rules · <code>--json</code> · <code>--fast</code> · exit codes for CI.</p>
|
||||
<p class="why-panel-meta">27 rules · <code>--json</code> · <code>--fast</code> · exit codes for CI.</p>
|
||||
</article>
|
||||
|
||||
<article class="why-panel" id="why-panel-7" role="tabpanel" data-index="7" hidden>
|
||||
@@ -832,7 +832,7 @@ import '../styles/sub-pages.css';
|
||||
<svg class="install-step-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>
|
||||
</summary>
|
||||
<div class="install-step-body">
|
||||
<p class="install-path-desc">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.</p>
|
||||
<p class="install-path-desc">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.</p>
|
||||
|
||||
<div class="install-cmd-block">
|
||||
<div class="install-cmd-line">
|
||||
@@ -1047,7 +1047,7 @@ import '../styles/sub-pages.css';
|
||||
<ul class="changelog-items">
|
||||
<li><strong>Renamed <code>frontend-design</code> to <code>impeccable</code>.</strong> The core skill now shares its name with the project, and the teach subcommand moved from <code>/teach-impeccable</code> to <code>/impeccable teach</code>. One skill, one namespace.</li>
|
||||
<li><strong>Data-driven skill rewrite.</strong> 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.</li>
|
||||
<li><strong>Anti-pattern detection engine.</strong> 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.</li>
|
||||
<li><strong>Anti-pattern detection engine.</strong> 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.</li>
|
||||
<li><strong>CLI: <code>npx impeccable detect</code>.</strong> 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 <code>--fast</code> regex mode for huge codebases.</li>
|
||||
<li><strong>Chrome DevTools extension.</strong> 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.</li>
|
||||
<li><strong><code>/critique</code> got teeth.</strong> 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.</li>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 <em> 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
|
||||
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Hero-Eyebrow-Chip — Should Flag vs Should Pass</title>
|
||||
<style>
|
||||
/* ─── Layout convention: two-column fixture ─────────────────────────
|
||||
Left column = patterns the rule SHOULD flag.
|
||||
Right column = legitimate patterns the rule should NOT flag.
|
||||
Each test case is annotated with a unique <h1> text so the test
|
||||
can match snippets back to expectations.
|
||||
──────────────────────────────────────────────────────────────── */
|
||||
body { font-family: system-ui, sans-serif; margin: 0; padding: 24px; }
|
||||
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; max-width: 1200px; margin: 0 auto; }
|
||||
.col h2.col-label { font-size: 14px; text-transform: uppercase; letter-spacing: 0.05em; margin: 0 0 16px; color: #475569; }
|
||||
.case { margin-bottom: 32px; padding: 24px; border: 1px solid #e2e8f0; border-radius: 12px; background: #fff; }
|
||||
.case p { font-size: 14px; margin: 12px 0 0; color: #64748b; }
|
||||
.case h1 { margin: 0; font-family: 'Inter', sans-serif; line-height: 1.05; }
|
||||
|
||||
/* Hero size used by most cases */
|
||||
.hero { font-size: 88px; font-weight: 600; }
|
||||
|
||||
/* ── FLAG cases ── */
|
||||
|
||||
.eyebrow-classic {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.15em;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.eyebrow-span {
|
||||
display: inline-block;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
font-size: 11px;
|
||||
color: #475569;
|
||||
font-weight: 600;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.eyebrow-pill {
|
||||
display: inline-block;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
font-size: 12px;
|
||||
background: #f1f5f9;
|
||||
color: #334155;
|
||||
border-radius: 999px;
|
||||
padding: 4px 12px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.eyebrow-already-uppercase {
|
||||
letter-spacing: 0.18em;
|
||||
font-size: 11px;
|
||||
color: #b45309;
|
||||
font-weight: 700;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
/* ── PASS cases ── */
|
||||
|
||||
.pass-eyebrow-no-tracking {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: normal;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.pass-body-heading {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
font-family: 'Inter', sans-serif;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.pass-card-caption {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.pass-bare-hero {
|
||||
font-size: 64px;
|
||||
}
|
||||
|
||||
.pass-h2-above-h1 {
|
||||
font-size: 14px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
font-weight: 700;
|
||||
color: #475569;
|
||||
margin: 0 0 16px;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.pass-long-uppercase {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
font-weight: 600;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="grid">
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════════════
|
||||
LEFT COLUMN: should flag
|
||||
═══════════════════════════════════════════════════════════════ -->
|
||||
<div class="col" data-col="flag">
|
||||
<h2 class="col-label">Should flag</h2>
|
||||
|
||||
<div class="case">
|
||||
<div class="eyebrow-classic">AI-NATIVE WORKFLOWS</div>
|
||||
<h1 class="hero">Eyebrow Above Hero</h1>
|
||||
<p>Classic uppercase tracked div eyebrow above an 88px hero.</p>
|
||||
</div>
|
||||
|
||||
<div class="case">
|
||||
<span class="eyebrow-span">NEW IN 2026</span>
|
||||
<h1 class="hero">Span Eyebrow Above Hero</h1>
|
||||
<p>Span variant with the same uppercase + tracking + small-size shape.</p>
|
||||
</div>
|
||||
|
||||
<div class="case">
|
||||
<span class="eyebrow-pill">FEATURED</span>
|
||||
<h1 class="hero">Pill Chip Above Hero</h1>
|
||||
<p>Pill-shaped chip with background, border-radius, padding.</p>
|
||||
</div>
|
||||
|
||||
<div class="case">
|
||||
<span class="eyebrow-already-uppercase">NEW</span>
|
||||
<h1 class="hero">Already Uppercase Text</h1>
|
||||
<p>Text typed uppercase, no text-transform, but matching tracking and size.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════════════
|
||||
RIGHT COLUMN: should pass
|
||||
═══════════════════════════════════════════════════════════════ -->
|
||||
<div class="col" data-col="pass">
|
||||
<h2 class="col-label">Should pass</h2>
|
||||
|
||||
<div class="case">
|
||||
<div class="pass-eyebrow-no-tracking">UPPERCASE LABEL</div>
|
||||
<h1 class="hero">Eyebrow With Normal Tracking</h1>
|
||||
<p>Uppercase label above a hero but with letter-spacing: normal.</p>
|
||||
</div>
|
||||
|
||||
<div class="case">
|
||||
<div class="eyebrow-classic">SECTION KICKER</div>
|
||||
<h1 class="pass-body-heading">Body-Sized Heading Below Eyebrow</h1>
|
||||
<p>Eyebrow above a heading, but the heading is only 24px — not a hero.</p>
|
||||
</div>
|
||||
|
||||
<div class="case">
|
||||
<div class="pass-card-caption">CARD CAPTION</div>
|
||||
<p>Plain body text below the caption.</p>
|
||||
<h1 class="hero" style="margin-top:32px">Uppercase Caption Far From Hero</h1>
|
||||
<p>Hero exists, but its preceding sibling is a paragraph, not the eyebrow.</p>
|
||||
</div>
|
||||
|
||||
<div class="case">
|
||||
<h1 class="pass-bare-hero">Hero With No Eyebrow</h1>
|
||||
<p>Bare h1 at 64px, nothing above it.</p>
|
||||
</div>
|
||||
|
||||
<div class="case">
|
||||
<h2 class="pass-h2-above-h1">SECTION HEADING</h2>
|
||||
<h1 class="hero">Heading Above Heading</h1>
|
||||
<p>An h2 styled like an eyebrow above an h1 — heading-tag exclusion must skip this.</p>
|
||||
</div>
|
||||
|
||||
<div class="case">
|
||||
<div class="pass-long-uppercase">A VERY LONG UPPERCASE TABLE OF CONTENTS HEADER</div>
|
||||
<h1 class="hero">Long Uppercase Sentence Above Hero</h1>
|
||||
<p>Uppercase tracked label, but text length exceeds the 30-char eyebrow ceiling.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,190 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Italic-Serif-Display — Should Flag vs Should Pass</title>
|
||||
<style>
|
||||
/* ─── Layout convention: two-column fixture ─────────────────────────
|
||||
Left column = patterns the rule SHOULD flag.
|
||||
Right column = legitimate patterns the rule should NOT flag.
|
||||
Each test case is annotated with a unique <h1>/<h2>/<blockquote>
|
||||
text so the test can match snippets back to expectations.
|
||||
──────────────────────────────────────────────────────────────── */
|
||||
body { font-family: system-ui, sans-serif; margin: 0; padding: 24px; }
|
||||
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; max-width: 1200px; margin: 0 auto; }
|
||||
.col h2.col-label { font-size: 14px; text-transform: uppercase; letter-spacing: 0.05em; margin: 0 0 16px; color: #475569; }
|
||||
.case { margin-bottom: 32px; padding: 24px; border: 1px solid #e2e8f0; border-radius: 12px; background: #fff; }
|
||||
.case p { font-size: 14px; margin: 12px 0 0; color: #64748b; }
|
||||
|
||||
/* ── FLAG cases ── */
|
||||
|
||||
/* 1: Fraunces 88px italic — the Lumina hero pattern */
|
||||
.hero-fraunces {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-style: italic;
|
||||
font-size: 88px;
|
||||
font-weight: 400;
|
||||
line-height: 1.05;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 2: Recoleta 64px italic */
|
||||
.hero-recoleta {
|
||||
font-family: 'Recoleta', serif;
|
||||
font-style: italic;
|
||||
font-size: 64px;
|
||||
font-weight: 400;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 3: Playfair Display 72px italic */
|
||||
.hero-playfair {
|
||||
font-family: 'Playfair Display', serif;
|
||||
font-style: italic;
|
||||
font-size: 72px;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 4: Unknown serif primary, but generic `serif` fallback in stack */
|
||||
.hero-unknown-serif {
|
||||
font-family: 'CustomSerifFace', serif;
|
||||
font-style: italic;
|
||||
font-size: 56px;
|
||||
font-weight: 400;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── PASS cases ── */
|
||||
|
||||
/* a: italic, but sans-serif primary (Karla) — italic display in a sans is rare and usually intentional */
|
||||
.pass-sans-italic {
|
||||
font-family: 'Karla', sans-serif;
|
||||
font-style: italic;
|
||||
font-size: 64px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* b: serif at 88px, but font-style: normal */
|
||||
.pass-roman-serif {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-style: normal;
|
||||
font-size: 88px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* c: italic serif blockquote at 24px (genuine pull quote, not a hero) */
|
||||
.pass-pull-quote {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-style: italic;
|
||||
font-size: 24px;
|
||||
font-weight: 400;
|
||||
margin: 0;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
/* d: roman h1 with an italic <em> inside — v1 explicitly does NOT flag this */
|
||||
.pass-roman-h1-em-italic {
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-style: normal;
|
||||
font-size: 72px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
.pass-roman-h1-em-italic em {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* e: italic serif h2 at 32px — under the 48px hero threshold */
|
||||
.pass-italic-serif-32 {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-style: italic;
|
||||
font-size: 32px;
|
||||
font-weight: 400;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* f: baseline-clean h1 — sans-serif roman */
|
||||
.pass-h1-sans-roman {
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-style: normal;
|
||||
font-size: 64px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="grid">
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════════════
|
||||
LEFT COLUMN: should flag
|
||||
═══════════════════════════════════════════════════════════════ -->
|
||||
<div class="col" data-col="flag">
|
||||
<h2 class="col-label">Should flag</h2>
|
||||
|
||||
<div class="case">
|
||||
<h1 class="hero-fraunces">Fraunces 88px italic</h1>
|
||||
<p>The Lumina hero pattern: oversized Fraunces italic display.</p>
|
||||
</div>
|
||||
|
||||
<div class="case">
|
||||
<h1 class="hero-recoleta">Recoleta 64px italic</h1>
|
||||
<p>Recoleta italic at 64px, generic serif fallback.</p>
|
||||
</div>
|
||||
|
||||
<div class="case">
|
||||
<h1 class="hero-playfair">Playfair 72px italic</h1>
|
||||
<p>Playfair Display italic at hero scale.</p>
|
||||
</div>
|
||||
|
||||
<div class="case">
|
||||
<h1 class="hero-unknown-serif">Unknown Serif Generic Fallback</h1>
|
||||
<p>Primary font unknown to the detector, but the stack ends in generic serif.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════════════
|
||||
RIGHT COLUMN: should pass
|
||||
═══════════════════════════════════════════════════════════════ -->
|
||||
<div class="col" data-col="pass">
|
||||
<h2 class="col-label">Should pass</h2>
|
||||
|
||||
<div class="case">
|
||||
<h1 class="pass-sans-italic">Sans Italic Display</h1>
|
||||
<p>Italic at hero scale, but in a sans-serif face.</p>
|
||||
</div>
|
||||
|
||||
<div class="case">
|
||||
<h1 class="pass-roman-serif">Roman Serif Display</h1>
|
||||
<p>Fraunces at 88px, but font-style is normal (not italic).</p>
|
||||
</div>
|
||||
|
||||
<div class="case">
|
||||
<blockquote class="pass-pull-quote">Italic Serif Pull Quote</blockquote>
|
||||
<p>Italic serif at 24px is a legitimate pull-quote pattern, not a hero.</p>
|
||||
</div>
|
||||
|
||||
<div class="case">
|
||||
<h1 class="pass-roman-h1-em-italic">Inline Em Inside <em>Roman</em></h1>
|
||||
<p>Roman h1 with an inline italic em — v1 deliberately does not flag this.</p>
|
||||
</div>
|
||||
|
||||
<div class="case">
|
||||
<h2 class="pass-italic-serif-32">Italic Serif at 32px</h2>
|
||||
<p>Italic serif h2 below the 48px hero threshold.</p>
|
||||
</div>
|
||||
|
||||
<div class="case">
|
||||
<h1 class="pass-h1-sans-roman">h1 Sans-Serif Roman</h1>
|
||||
<p>Baseline-clean control: sans-serif, roman, hero scale.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user