Add typography anti-pattern detection: overused fonts, single font, flat hierarchy

Three new detections:
- overused-font: flags Inter, Roboto, Open Sans, Lato, Montserrat, Arial
  as primary font-family or via Google Fonts imports
- single-font: file-level analyzer flags pages using only one non-generic
  font family (needs pairing for typographic hierarchy)
- flat-type-hierarchy: file-level analyzer collects all font-size values
  (px, rem, Tailwind text-* classes, clamp min/max) and flags when the
  max/min ratio is below 2.0

Detection engine extended to support file-level analyzers alongside
line-level matchers. Typography fixtures added for both should-flag
and should-pass cases.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-03-17 11:00:58 -07:00
co-authored by Claude Opus 4.6
parent f9bfe18d26
commit 1fb896a3ff
5 changed files with 628 additions and 34 deletions
@@ -150,6 +150,161 @@ const ANTIPATTERNS = [
},
],
},
// -------------------------------------------------------------------------
// Typography anti-patterns
// -------------------------------------------------------------------------
{
id: 'overused-font',
name: 'Overused font',
description:
'Inter, Roboto, Open Sans, Lato, Montserrat, and Arial are used on millions of sites. Choose a distinctive font that gives your interface personality.',
matchers: [
// CSS font-family: 'Inter' as primary (first) font
{
regex: /font-family\s*:\s*['"]?(Inter|Roboto|Open Sans|Lato|Montserrat|Arial|Helvetica)\b/gi,
test: () => true,
format: (match) => match[0],
},
// Google Fonts import/link
{
regex: /fonts\.googleapis\.com\/css2?\?family=(Inter|Roboto|Open\+Sans|Lato|Montserrat)\b/gi,
test: () => true,
format: (match) => `Google Fonts: ${match[1].replace(/\+/g, ' ')}`,
},
],
},
{
id: 'single-font',
name: 'Single font for everything',
description:
'Only one font family is used for the entire page. Pair a distinctive display font with a refined body font to create typographic hierarchy.',
analyzers: [
(content, filePath) => {
// Extract all font names from font-family declarations
const fontFamilyRe = /font-family\s*:\s*([^;}]+)/gi;
const GENERIC = new Set([
'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',
'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
'-apple-system', 'blinkmacsystemfont', 'segoe ui', 'inherit', 'initial', 'unset',
]);
const fonts = new Set();
let m;
while ((m = fontFamilyRe.exec(content)) !== null) {
// Extract individual font names from the stack
const stack = m[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
for (const f of stack) {
if (f && !GENERIC.has(f)) fonts.add(f);
}
}
// Also extract from Google Fonts imports
const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
while ((m = gfRe.exec(content)) !== null) {
const families = m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase());
for (const f of families) fonts.add(f);
}
// Only flag if the file has meaningful content and exactly 1 font
if (fonts.size !== 1) return [];
// Don't flag tiny files (likely components)
const lineCount = content.split('\n').length;
if (lineCount < 20) return [];
const fontName = [...fonts][0];
// Find the first line where this font appears for reporting
const lines = content.split('\n');
let reportLine = 1;
for (let i = 0; i < lines.length; i++) {
if (lines[i].toLowerCase().includes(fontName)) {
reportLine = i + 1;
break;
}
}
return [{
antipattern: 'single-font',
name: 'Single font for everything',
description: 'Only one font family is used for the entire page. Pair a distinctive display font with a refined body font to create typographic hierarchy.',
file: filePath,
line: reportLine,
snippet: `Only font: ${fontName}`,
}];
},
],
},
{
id: 'flat-type-hierarchy',
name: 'Flat type hierarchy',
description:
'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).',
analyzers: [
(content, filePath) => {
// Collect all font-size values and convert to px
const sizes = new Set();
const REM_BASE = 16;
const lines = content.split('\n');
// CSS font-size declarations
const sizeRe = /font-size\s*:\s*([\d.]+)(px|rem|em)\b/gi;
let m;
while ((m = sizeRe.exec(content)) !== null) {
const val = parseFloat(m[1]);
const unit = m[2].toLowerCase();
const px = unit === 'px' ? val : val * REM_BASE;
if (px > 0 && px < 200) sizes.add(Math.round(px * 10) / 10);
}
// clamp() — extract min and max
const clampRe = /font-size\s*:\s*clamp\(\s*([\d.]+)(px|rem|em)\s*,\s*[^,]+,\s*([\d.]+)(px|rem|em)\s*\)/gi;
while ((m = clampRe.exec(content)) !== null) {
const minVal = parseFloat(m[1]);
const minUnit = m[2].toLowerCase();
const maxVal = parseFloat(m[3]);
const maxUnit = m[4].toLowerCase();
sizes.add(Math.round((minUnit === 'px' ? minVal : minVal * REM_BASE) * 10) / 10);
sizes.add(Math.round((maxUnit === 'px' ? maxVal : maxVal * REM_BASE) * 10) / 10);
}
// Tailwind text-* classes → approximate px values
const TW_SIZES = {
'text-xs': 12, 'text-sm': 14, 'text-base': 16, 'text-lg': 18,
'text-xl': 20, 'text-2xl': 24, 'text-3xl': 30, 'text-4xl': 36,
'text-5xl': 48, 'text-6xl': 60, 'text-7xl': 72, 'text-8xl': 96, 'text-9xl': 128,
};
for (const [cls, px] of Object.entries(TW_SIZES)) {
const twRe = new RegExp(`\\b${cls}\\b`);
if (twRe.test(content)) sizes.add(px);
}
// Need at least 3 distinct sizes to evaluate hierarchy
if (sizes.size < 3) return [];
const sorted = [...sizes].sort((a, b) => a - b);
const ratio = sorted[sorted.length - 1] / sorted[0];
// A healthy hierarchy has at least 2x range (e.g., 14px body to 36px heading)
if (ratio >= 2.0) return [];
// Find line to report on (first font-size declaration)
let reportLine = 1;
for (let i = 0; i < lines.length; i++) {
if (/font-size/i.test(lines[i]) || /\btext-(?:xs|sm|base|lg|xl|\d)/i.test(lines[i])) {
reportLine = i + 1;
break;
}
}
return [{
antipattern: 'flat-type-hierarchy',
name: 'Flat type hierarchy',
description: 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).',
file: filePath,
line: reportLine,
snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)`,
}];
},
],
},
];
// ---------------------------------------------------------------------------
@@ -167,26 +322,36 @@ function detectAntiPatterns(content, filePath) {
const lines = content.split('\n');
for (const ap of ANTIPATTERNS) {
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
for (const matcher of ap.matchers) {
// Reset regex state for each line
matcher.regex.lastIndex = 0;
let m;
while ((m = matcher.regex.exec(line)) !== null) {
if (matcher.test(m, line)) {
findings.push({
antipattern: ap.id,
name: ap.name,
description: ap.description,
file: filePath,
line: i + 1,
snippet: matcher.format(m),
});
// Line-level matchers
if (ap.matchers) {
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
for (const matcher of ap.matchers) {
// Reset regex state for each line
matcher.regex.lastIndex = 0;
let m;
while ((m = matcher.regex.exec(line)) !== null) {
if (matcher.test(m, line)) {
findings.push({
antipattern: ap.id,
name: ap.name,
description: ap.description,
file: filePath,
line: i + 1,
snippet: matcher.format(m),
});
}
}
}
}
}
// File-level analyzers
if (ap.analyzers) {
for (const analyzer of ap.analyzers) {
findings.push(...analyzer(content, filePath));
}
}
}
return findings;
@@ -150,6 +150,161 @@ const ANTIPATTERNS = [
},
],
},
// -------------------------------------------------------------------------
// Typography anti-patterns
// -------------------------------------------------------------------------
{
id: 'overused-font',
name: 'Overused font',
description:
'Inter, Roboto, Open Sans, Lato, Montserrat, and Arial are used on millions of sites. Choose a distinctive font that gives your interface personality.',
matchers: [
// CSS font-family: 'Inter' as primary (first) font
{
regex: /font-family\s*:\s*['"]?(Inter|Roboto|Open Sans|Lato|Montserrat|Arial|Helvetica)\b/gi,
test: () => true,
format: (match) => match[0],
},
// Google Fonts import/link
{
regex: /fonts\.googleapis\.com\/css2?\?family=(Inter|Roboto|Open\+Sans|Lato|Montserrat)\b/gi,
test: () => true,
format: (match) => `Google Fonts: ${match[1].replace(/\+/g, ' ')}`,
},
],
},
{
id: 'single-font',
name: 'Single font for everything',
description:
'Only one font family is used for the entire page. Pair a distinctive display font with a refined body font to create typographic hierarchy.',
analyzers: [
(content, filePath) => {
// Extract all font names from font-family declarations
const fontFamilyRe = /font-family\s*:\s*([^;}]+)/gi;
const GENERIC = new Set([
'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',
'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
'-apple-system', 'blinkmacsystemfont', 'segoe ui', 'inherit', 'initial', 'unset',
]);
const fonts = new Set();
let m;
while ((m = fontFamilyRe.exec(content)) !== null) {
// Extract individual font names from the stack
const stack = m[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
for (const f of stack) {
if (f && !GENERIC.has(f)) fonts.add(f);
}
}
// Also extract from Google Fonts imports
const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
while ((m = gfRe.exec(content)) !== null) {
const families = m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase());
for (const f of families) fonts.add(f);
}
// Only flag if the file has meaningful content and exactly 1 font
if (fonts.size !== 1) return [];
// Don't flag tiny files (likely components)
const lineCount = content.split('\n').length;
if (lineCount < 20) return [];
const fontName = [...fonts][0];
// Find the first line where this font appears for reporting
const lines = content.split('\n');
let reportLine = 1;
for (let i = 0; i < lines.length; i++) {
if (lines[i].toLowerCase().includes(fontName)) {
reportLine = i + 1;
break;
}
}
return [{
antipattern: 'single-font',
name: 'Single font for everything',
description: 'Only one font family is used for the entire page. Pair a distinctive display font with a refined body font to create typographic hierarchy.',
file: filePath,
line: reportLine,
snippet: `Only font: ${fontName}`,
}];
},
],
},
{
id: 'flat-type-hierarchy',
name: 'Flat type hierarchy',
description:
'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).',
analyzers: [
(content, filePath) => {
// Collect all font-size values and convert to px
const sizes = new Set();
const REM_BASE = 16;
const lines = content.split('\n');
// CSS font-size declarations
const sizeRe = /font-size\s*:\s*([\d.]+)(px|rem|em)\b/gi;
let m;
while ((m = sizeRe.exec(content)) !== null) {
const val = parseFloat(m[1]);
const unit = m[2].toLowerCase();
const px = unit === 'px' ? val : val * REM_BASE;
if (px > 0 && px < 200) sizes.add(Math.round(px * 10) / 10);
}
// clamp() — extract min and max
const clampRe = /font-size\s*:\s*clamp\(\s*([\d.]+)(px|rem|em)\s*,\s*[^,]+,\s*([\d.]+)(px|rem|em)\s*\)/gi;
while ((m = clampRe.exec(content)) !== null) {
const minVal = parseFloat(m[1]);
const minUnit = m[2].toLowerCase();
const maxVal = parseFloat(m[3]);
const maxUnit = m[4].toLowerCase();
sizes.add(Math.round((minUnit === 'px' ? minVal : minVal * REM_BASE) * 10) / 10);
sizes.add(Math.round((maxUnit === 'px' ? maxVal : maxVal * REM_BASE) * 10) / 10);
}
// Tailwind text-* classes → approximate px values
const TW_SIZES = {
'text-xs': 12, 'text-sm': 14, 'text-base': 16, 'text-lg': 18,
'text-xl': 20, 'text-2xl': 24, 'text-3xl': 30, 'text-4xl': 36,
'text-5xl': 48, 'text-6xl': 60, 'text-7xl': 72, 'text-8xl': 96, 'text-9xl': 128,
};
for (const [cls, px] of Object.entries(TW_SIZES)) {
const twRe = new RegExp(`\\b${cls}\\b`);
if (twRe.test(content)) sizes.add(px);
}
// Need at least 3 distinct sizes to evaluate hierarchy
if (sizes.size < 3) return [];
const sorted = [...sizes].sort((a, b) => a - b);
const ratio = sorted[sorted.length - 1] / sorted[0];
// A healthy hierarchy has at least 2x range (e.g., 14px body to 36px heading)
if (ratio >= 2.0) return [];
// Find line to report on (first font-size declaration)
let reportLine = 1;
for (let i = 0; i < lines.length; i++) {
if (/font-size/i.test(lines[i]) || /\btext-(?:xs|sm|base|lg|xl|\d)/i.test(lines[i])) {
reportLine = i + 1;
break;
}
}
return [{
antipattern: 'flat-type-hierarchy',
name: 'Flat type hierarchy',
description: 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).',
file: filePath,
line: reportLine,
snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)`,
}];
},
],
},
];
// ---------------------------------------------------------------------------
@@ -167,26 +322,36 @@ function detectAntiPatterns(content, filePath) {
const lines = content.split('\n');
for (const ap of ANTIPATTERNS) {
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
for (const matcher of ap.matchers) {
// Reset regex state for each line
matcher.regex.lastIndex = 0;
let m;
while ((m = matcher.regex.exec(line)) !== null) {
if (matcher.test(m, line)) {
findings.push({
antipattern: ap.id,
name: ap.name,
description: ap.description,
file: filePath,
line: i + 1,
snippet: matcher.format(m),
});
// Line-level matchers
if (ap.matchers) {
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
for (const matcher of ap.matchers) {
// Reset regex state for each line
matcher.regex.lastIndex = 0;
let m;
while ((m = matcher.regex.exec(line)) !== null) {
if (matcher.test(m, line)) {
findings.push({
antipattern: ap.id,
name: ap.name,
description: ap.description,
file: filePath,
line: i + 1,
snippet: matcher.format(m),
});
}
}
}
}
}
// File-level analyzers
if (ap.analyzers) {
for (const analyzer of ap.analyzers) {
findings.push(...analyzer(content, filePath));
}
}
}
return findings;
+182 -2
View File
@@ -272,6 +272,171 @@ describe('detectAntiPatterns — border accent on rounded', () => {
});
});
// ---------------------------------------------------------------------------
// Typography: overused fonts
// ---------------------------------------------------------------------------
describe('detectAntiPatterns — overused fonts', () => {
test('detects Inter as primary font', () => {
const findings = detectAntiPatterns("body { font-family: 'Inter', sans-serif; }", 'test.css');
expect(findings).toHaveLength(1);
expect(findings[0].antipattern).toBe('overused-font');
});
test('detects Roboto as primary font', () => {
const findings = detectAntiPatterns('body { font-family: Roboto, sans-serif; }', 'test.css');
expect(findings).toHaveLength(1);
});
test('detects Open Sans', () => {
const findings = detectAntiPatterns("body { font-family: 'Open Sans', sans-serif; }", 'test.css');
expect(findings).toHaveLength(1);
});
test('detects Google Fonts import for Inter', () => {
const findings = detectAntiPatterns('<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700" rel="stylesheet">', 'test.html');
expect(findings).toHaveLength(1);
expect(findings[0].snippet).toContain('Inter');
});
test('does not flag distinctive fonts', () => {
const findings = detectAntiPatterns("body { font-family: 'Instrument Sans', sans-serif; }", 'test.css');
expect(findings).toHaveLength(0);
});
test('does not flag Inter as fallback (not primary)', () => {
const findings = detectAntiPatterns("body { font-family: 'Fraunces', 'Inter', sans-serif; }", 'test.css');
expect(findings).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// Typography: single font
// ---------------------------------------------------------------------------
describe('detectAntiPatterns — single font', () => {
test('flags file with only one font', () => {
const content = `<html><head><style>
body { font-family: 'Poppins', sans-serif; }
h1 { font-size: 2rem; }
h2 { font-size: 1.5rem; }
p { font-size: 1rem; }
.card { padding: 1rem; }
.hero { padding: 2rem; }
.footer { padding: 1rem; }
.nav { display: flex; }
.sidebar { width: 200px; }
.main { flex: 1; }
.btn { padding: 0.5rem 1rem; }
.input { border: 1px solid #ccc; }
.label { font-weight: 500; }
.icon { width: 24px; }
.grid { display: grid; }
.flex { display: flex; }
.hidden { display: none; }
.visible { display: block; }
.text { color: #333; }
</style></head><body></body></html>`;
const findings = content.split('\n').length >= 20 ?
detectAntiPatterns(content, 'test.html').filter(f => f.antipattern === 'single-font') : [];
expect(findings).toHaveLength(1);
expect(findings[0].snippet).toContain('poppins');
});
test('does not flag file with two fonts', () => {
const content = `<html><head><style>
body { font-family: 'Instrument Sans', sans-serif; }
h1 { font-family: 'Fraunces', serif; font-size: 2rem; }
h2 { font-size: 1.5rem; }
p { font-size: 1rem; }
.card { padding: 1rem; }
.hero { padding: 2rem; }
.footer { padding: 1rem; }
.nav { display: flex; }
.sidebar { width: 200px; }
.main { flex: 1; }
.btn { padding: 0.5rem 1rem; }
.input { border: 1px solid #ccc; }
.label { font-weight: 500; }
.icon { width: 24px; }
.grid { display: grid; }
.flex { display: flex; }
.hidden { display: none; }
.visible { display: block; }
.text { color: #333; }
</style></head><body></body></html>`;
const findings = detectAntiPatterns(content, 'test.html').filter(f => f.antipattern === 'single-font');
expect(findings).toHaveLength(0);
});
test('does not flag small files', () => {
const findings = detectAntiPatterns("body { font-family: 'Poppins', sans-serif; }", 'test.css');
const singleFont = findings.filter(f => f.antipattern === 'single-font');
expect(singleFont).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// Typography: flat type hierarchy
// ---------------------------------------------------------------------------
describe('detectAntiPatterns — flat type hierarchy', () => {
test('flags sizes that are too close together', () => {
const content = `<style>
h1 { font-size: 18px; }
h2 { font-size: 16px; }
h3 { font-size: 15px; }
p { font-size: 14px; }
.small { font-size: 13px; }
</style>`;
const findings = detectAntiPatterns(content, 'test.html').filter(f => f.antipattern === 'flat-type-hierarchy');
expect(findings).toHaveLength(1);
expect(findings[0].snippet).toContain('ratio');
});
test('passes good hierarchy', () => {
const content = `<style>
h1 { font-size: 48px; }
h2 { font-size: 32px; }
h3 { font-size: 24px; }
p { font-size: 16px; }
.small { font-size: 12px; }
</style>`;
const findings = detectAntiPatterns(content, 'test.html').filter(f => f.antipattern === 'flat-type-hierarchy');
expect(findings).toHaveLength(0);
});
test('handles rem units', () => {
const content = `<style>
h1 { font-size: 1.125rem; }
h2 { font-size: 1rem; }
p { font-size: 0.875rem; }
</style>`;
const findings = detectAntiPatterns(content, 'test.html').filter(f => f.antipattern === 'flat-type-hierarchy');
expect(findings).toHaveLength(1);
});
test('handles Tailwind text-* classes', () => {
const content = '<div class="text-sm">small</div>\n<div class="text-base">base</div>\n<div class="text-lg">large</div>';
const findings = detectAntiPatterns(content, 'test.html').filter(f => f.antipattern === 'flat-type-hierarchy');
// 14px, 16px, 18px → ratio 1.3:1 → should flag
expect(findings).toHaveLength(1);
});
test('passes Tailwind with wide range', () => {
const content = '<div class="text-sm">small</div>\n<div class="text-base">base</div>\n<div class="text-4xl">heading</div>';
const findings = detectAntiPatterns(content, 'test.html').filter(f => f.antipattern === 'flat-type-hierarchy');
// 14px, 16px, 36px → ratio 2.6:1 → should pass
expect(findings).toHaveLength(0);
});
test('ignores files with fewer than 3 sizes', () => {
const content = '<style>\nh1 { font-size: 18px; }\np { font-size: 16px; }\n</style>';
const findings = detectAntiPatterns(content, 'test.html').filter(f => f.antipattern === 'flat-type-hierarchy');
expect(findings).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// Fixture files
// ---------------------------------------------------------------------------
@@ -292,6 +457,20 @@ describe('fixture file scanning', () => {
expect(findings).toHaveLength(0);
});
test('typography-should-flag.html detects all three font issues', () => {
const content = fs.readFileSync(path.join(FIXTURES, 'typography-should-flag.html'), 'utf-8');
const findings = detectAntiPatterns(content, 'typography-should-flag.html');
expect(findings.some(f => f.antipattern === 'overused-font')).toBe(true);
expect(findings.some(f => f.antipattern === 'single-font')).toBe(true);
expect(findings.some(f => f.antipattern === 'flat-type-hierarchy')).toBe(true);
});
test('typography-should-pass.html has zero findings', () => {
const content = fs.readFileSync(path.join(FIXTURES, 'typography-should-pass.html'), 'utf-8');
const findings = detectAntiPatterns(content, 'typography-should-pass.html');
expect(findings).toHaveLength(0);
});
test('legitimate-borders.html has minimal false positives', () => {
const content = fs.readFileSync(path.join(FIXTURES, 'legitimate-borders.html'), 'utf-8');
const findings = detectAntiPatterns(content, 'legitimate-borders.html');
@@ -340,8 +519,9 @@ describe('ANTIPATTERNS registry', () => {
expect(ap.id).toBeTypeOf('string');
expect(ap.name).toBeTypeOf('string');
expect(ap.description).toBeTypeOf('string');
expect(ap.matchers).toBeArray();
expect(ap.matchers.length).toBeGreaterThan(0);
const hasMatchers = ap.matchers && ap.matchers.length > 0;
const hasAnalyzers = ap.analyzers && ap.analyzers.length > 0;
expect(hasMatchers || hasAnalyzers).toBe(true);
}
});
});
+39
View File
@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Typography Anti-Patterns — Should Flag</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
body {
font-family: 'Inter', sans-serif;
background: #f9fafb;
padding: 2rem;
}
h1 { font-size: 18px; font-weight: 700; margin-bottom: 0.5rem; }
h2 { font-size: 16px; font-weight: 600; margin: 2rem 0 0.75rem; color: #6b7280; }
h3 { font-size: 15px; font-weight: 600; }
p { font-size: 14px; color: #6b7280; margin-top: 0.25rem; }
.caption { font-size: 13px; color: #9ca3af; }
</style>
</head>
<body>
<h1>Typography Anti-Patterns</h1>
<p>This page triggers three typography detections:</p>
<h2>1. Overused Font</h2>
<p>Inter is loaded from Google Fonts and set as the only font-family. It's the most common AI default.</p>
<h2>2. Single Font</h2>
<p>There's no second font for headings or display text. Everything uses Inter — no typographic variety.</p>
<h2>3. Flat Type Hierarchy</h2>
<p>The font sizes are 13px, 14px, 15px, 16px, 18px — all crammed into a 5px range. No visual contrast between heading and body.</p>
<p class="caption">This caption is barely distinguishable from body text.</p>
<h3>A Subheading</h3>
<p>Can you tell this is a subheading? Exactly.</p>
<script src="../../../public/js/detect-antipatterns-browser.js"></script>
</body>
</html>
+45
View File
@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Typography — Clean Patterns</title>
<link href="https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400;500;600&family=Fraunces:opsz,wght@9..144,400;9..144,700&display=swap" rel="stylesheet">
<style>
body {
font-family: 'Instrument Sans', system-ui, sans-serif;
background: #f9fafb;
padding: 2rem;
font-size: 16px;
color: #111827;
}
h1 {
font-family: 'Fraunces', Georgia, serif;
font-size: 48px;
font-weight: 700;
margin-bottom: 0.5rem;
}
h2 {
font-family: 'Fraunces', Georgia, serif;
font-size: 32px;
font-weight: 700;
margin: 2rem 0 0.75rem;
}
h3 { font-size: 24px; font-weight: 600; }
p { font-size: 16px; color: #6b7280; margin-top: 0.25rem; }
.caption { font-size: 12px; color: #9ca3af; }
</style>
</head>
<body>
<h1>Good Typography</h1>
<p>This page uses distinctive fonts, proper pairing, and strong hierarchy.</p>
<h2>Two Font Families</h2>
<p>Fraunces (serif) for headings, Instrument Sans for body. Clear contrast in both structure and personality.</p>
<h3>Strong Size Hierarchy</h3>
<p>Sizes range from 12px to 48px — a 4:1 ratio with clear visual steps.</p>
<p class="caption">Caption text is clearly distinct from body.</p>
<script src="../../../public/js/detect-antipatterns-browser.js"></script>
</body>
</html>