mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-14 15:16:35 +03:00
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:
co-authored by
Claude Opus 4.6
parent
f9bfe18d26
commit
1fb896a3ff
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user