From 1fb896a3ff4d72fae5104c9490a4acdd7b62c7c9 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Tue, 17 Mar 2026 11:00:58 -0700 Subject: [PATCH] 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) --- .../critique/scripts/detect-antipatterns.mjs | 197 ++++++++++++++++-- .../critique/scripts/detect-antipatterns.mjs | 197 ++++++++++++++++-- tests/detect-antipatterns.test.js | 184 +++++++++++++++- .../antipatterns/typography-should-flag.html | 39 ++++ .../antipatterns/typography-should-pass.html | 45 ++++ 5 files changed, 628 insertions(+), 34 deletions(-) create mode 100644 tests/fixtures/antipatterns/typography-should-flag.html create mode 100644 tests/fixtures/antipatterns/typography-should-pass.html diff --git a/.claude/skills/critique/scripts/detect-antipatterns.mjs b/.claude/skills/critique/scripts/detect-antipatterns.mjs index 3cc7ec6a1..52da46c06 100644 --- a/.claude/skills/critique/scripts/detect-antipatterns.mjs +++ b/.claude/skills/critique/scripts/detect-antipatterns.mjs @@ -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; diff --git a/source/skills/critique/scripts/detect-antipatterns.mjs b/source/skills/critique/scripts/detect-antipatterns.mjs index 3cc7ec6a1..52da46c06 100644 --- a/source/skills/critique/scripts/detect-antipatterns.mjs +++ b/source/skills/critique/scripts/detect-antipatterns.mjs @@ -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; diff --git a/tests/detect-antipatterns.test.js b/tests/detect-antipatterns.test.js index cb8047ff6..a76ffc453 100644 --- a/tests/detect-antipatterns.test.js +++ b/tests/detect-antipatterns.test.js @@ -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('', '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 = ``; + 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 = ``; + 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 = ``; + 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 = ``; + const findings = detectAntiPatterns(content, 'test.html').filter(f => f.antipattern === 'flat-type-hierarchy'); + expect(findings).toHaveLength(0); + }); + + test('handles rem units', () => { + const content = ``; + const findings = detectAntiPatterns(content, 'test.html').filter(f => f.antipattern === 'flat-type-hierarchy'); + expect(findings).toHaveLength(1); + }); + + test('handles Tailwind text-* classes', () => { + const content = '
small
\n
base
\n
large
'; + 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 = '
small
\n
base
\n
heading
'; + 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 = ''; + 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); } }); }); diff --git a/tests/fixtures/antipatterns/typography-should-flag.html b/tests/fixtures/antipatterns/typography-should-flag.html new file mode 100644 index 000000000..7c4caacc9 --- /dev/null +++ b/tests/fixtures/antipatterns/typography-should-flag.html @@ -0,0 +1,39 @@ + + + + + + Typography Anti-Patterns — Should Flag + + + + +

Typography Anti-Patterns

+

This page triggers three typography detections:

+ +

1. Overused Font

+

Inter is loaded from Google Fonts and set as the only font-family. It's the most common AI default.

+ +

2. Single Font

+

There's no second font for headings or display text. Everything uses Inter — no typographic variety.

+ +

3. Flat Type Hierarchy

+

The font sizes are 13px, 14px, 15px, 16px, 18px — all crammed into a 5px range. No visual contrast between heading and body.

+

This caption is barely distinguishable from body text.

+ +

A Subheading

+

Can you tell this is a subheading? Exactly.

+ + + diff --git a/tests/fixtures/antipatterns/typography-should-pass.html b/tests/fixtures/antipatterns/typography-should-pass.html new file mode 100644 index 000000000..82d29a1f1 --- /dev/null +++ b/tests/fixtures/antipatterns/typography-should-pass.html @@ -0,0 +1,45 @@ + + + + + + Typography — Clean Patterns + + + + +

Good Typography

+

This page uses distinctive fonts, proper pairing, and strong hierarchy.

+ +

Two Font Families

+

Fraunces (serif) for headings, Instrument Sans for body. Clear contrast in both structure and personality.

+ +

Strong Size Hierarchy

+

Sizes range from 12px to 48px — a 4:1 ratio with clear visual steps.

+

Caption text is clearly distinct from body.

+ + +