mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-19 09:36:59 +03:00
Wire quality rules into the CLI and add Puppeteer fixture tests
The quality detection rules (line-length, cramped-padding, tight-leading, tiny-text, justified-text, all-caps-body, wide-tracking, skipped-heading) were originally added as browser-only and wired only into the overlay loop. The CLI's jsdom path silently skipped all of them. Two of the eight rules genuinely need real browser layout (line-length reads rect.width for chars-per-line; cramped-padding reads rect.width/height to filter small badges). The other six only need computed CSS values and pure DOM walks — they can run in jsdom too. Refactor - Extract a pure checkQuality(opts) from checkElementQualityDOM, taking pre-resolved lineHeightPx and letterSpacingPx so each adapter handles its own unit resolution. - Add resolveFontSizePx(el, win) — walks the parent chain to compute effective font-size in pixels, handling px / rem / em / % through inheritance. Browsers do this automatically in getComputedStyle, but jsdom returns "0.875rem" verbatim, which broke naive parseFloat math. - Add resolveLengthPx(value, fontSizePx) — generic CSS length → px helper used for line-height and letter-spacing in the Node adapter. - Extract checkPageQualityFromDoc(doc) and add a Node call site so skipped-heading fires from the CLI too. - Add checkElementQuality(el, style, tag, window) Node adapter and wire it into detectHtml's element loop. Tests - New tests/detect-antipatterns-browser.test.mjs — Puppeteer-backed runner that spins up a temporary static server (port 8765, mirrors the dev server's /fixtures/* and /js/* routes) and uses detectUrl() to load fixtures in headless Chrome. Asserts the two browser-only rules (cramped-padding, line-length) that need real layout. - New tests/fixtures/antipatterns/cramped-padding.html — focused side-by-side fixture for the cramped-padding rule. Pass column includes a faithful replica of .detection-cmd from the homepage (the disputed "small inline pill" case the user is deciding what to do with). Test asserts 3 findings: 2 from the obvious flag column + 1 from the disputed pill. - New tests/fixtures/antipatterns/quality.html — merged side-by-side replacement for the orphaned quality-should-flag/pass.html files. Covers all 7 typography-quality rules. The 6 jsdom-compatible rules are asserted in the jsdom test; line-length stays in the Puppeteer test. - Delete the orphaned quality-should-flag.html / quality-should-pass.html. - Wire the new browser test into bun run test (~2.6s overhead). Coverage win: the CLI now catches tight-leading, tiny-text, justified-text, all-caps-body, wide-tracking, and skipped-heading on real projects, where it previously missed all six. 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
8b93dfad5e
commit
5bc5ece1ab
+1
-1
@@ -49,7 +49,7 @@
|
||||
"dev": "bun run server/index.js",
|
||||
"preview": "bun run build && wrangler pages dev",
|
||||
"deploy": "bun run build && wrangler pages deploy build/",
|
||||
"test": "bun test tests/build.test.js tests/detect-antipatterns.test.js && node --test tests/detect-antipatterns-fixtures.test.mjs",
|
||||
"test": "bun test tests/build.test.js tests/detect-antipatterns.test.js && node --test tests/detect-antipatterns-fixtures.test.mjs && node --test tests/detect-antipatterns-browser.test.mjs",
|
||||
"prepack": "cp README.md README.repo.md && cp README.npm.md README.md",
|
||||
"postpack": "cp README.repo.md README.md && rm README.repo.md",
|
||||
"screenshot": "bun run scripts/screenshot-antipatterns.js",
|
||||
|
||||
@@ -988,42 +988,80 @@ function checkElementAIPaletteDOM(el) {
|
||||
|
||||
const QUALITY_TEXT_TAGS = new Set(['p', 'li', 'td', 'th', 'dd', 'blockquote', 'figcaption']);
|
||||
|
||||
function checkElementQualityDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
// Resolve a CSS font-size value to pixels by walking up the parent chain.
|
||||
// Browsers resolve em/rem/% to px in getComputedStyle, but jsdom returns the
|
||||
// specified value verbatim — so for the Node path we walk parents ourselves.
|
||||
function resolveFontSizePx(el, win) {
|
||||
const chain = []; // raw font-size strings, leaf → root
|
||||
let cur = el;
|
||||
while (cur && cur.nodeType === 1) {
|
||||
const fs = (win ? win.getComputedStyle(cur) : getComputedStyle(cur)).fontSize;
|
||||
chain.push(fs || '');
|
||||
cur = cur.parentElement;
|
||||
}
|
||||
// Walk root → leaf, resolving each value relative to its parent context.
|
||||
let px = 16; // root default
|
||||
for (let i = chain.length - 1; i >= 0; i--) {
|
||||
const v = chain[i];
|
||||
if (!v || v === 'inherit') continue;
|
||||
const num = parseFloat(v);
|
||||
if (isNaN(num)) continue;
|
||||
if (v.endsWith('px')) px = num;
|
||||
else if (v.endsWith('rem')) px = num * 16;
|
||||
else if (v.endsWith('em')) px = num * px;
|
||||
else if (v.endsWith('%')) px = (num / 100) * px;
|
||||
else px = num; // unitless — already resolved
|
||||
}
|
||||
return px;
|
||||
}
|
||||
|
||||
// Resolve a CSS length value (line-height, letter-spacing, etc.) given a
|
||||
// known font-size context. Returns null for "normal" / unparseable values.
|
||||
function resolveLengthPx(value, fontSizePx) {
|
||||
if (!value || value === 'normal' || value === 'auto' || value === 'inherit') return null;
|
||||
const num = parseFloat(value);
|
||||
if (isNaN(num)) return null;
|
||||
if (value.endsWith('px')) return num;
|
||||
if (value.endsWith('rem')) return num * 16;
|
||||
if (value.endsWith('em')) return num * fontSizePx;
|
||||
if (value.endsWith('%')) return (num / 100) * fontSizePx;
|
||||
// Unitless line-height = multiplier, return px equivalent
|
||||
return num * fontSizePx;
|
||||
}
|
||||
|
||||
// Pure quality checks. Most run on computed CSS and DOM-only inputs (work in
|
||||
// jsdom and the browser). Two checks (line-length, cramped-padding) gate on
|
||||
// element rect dimensions, which jsdom can't compute — pass `rect: null` from
|
||||
// the Node adapter to skip those.
|
||||
//
|
||||
// Both adapters resolve font-size, line-height and letter-spacing to pixels
|
||||
// before calling this so the pure function only deals with numbers.
|
||||
function checkQuality(opts) {
|
||||
const { el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax = 80 } = opts;
|
||||
const findings = [];
|
||||
// Skip browser extension injected elements
|
||||
const elId = el.id || '';
|
||||
if (elId.startsWith('claude-') || elId.startsWith('cic-')) return [];
|
||||
const style = getComputedStyle(el);
|
||||
const findings = [];
|
||||
if (elId.startsWith('claude-') || elId.startsWith('cic-')) return findings;
|
||||
|
||||
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 10);
|
||||
const textLen = el.textContent?.trim().length || 0;
|
||||
const fontSize = parseFloat(style.fontSize) || 16;
|
||||
const rect = el.getBoundingClientRect();
|
||||
|
||||
// --- Line length too long ---
|
||||
// Threshold is configurable via window.__IMPECCABLE_CONFIG__.lineLengthMax (default 80)
|
||||
const lineMax = (typeof window !== 'undefined' && window.__IMPECCABLE_CONFIG__?.lineLengthMax) || 80;
|
||||
if (hasDirectText && QUALITY_TEXT_TAGS.has(tag) && rect.width > 0 && textLen > lineMax) {
|
||||
// --- Line length too long --- (browser-only: needs rect.width)
|
||||
if (rect && hasDirectText && QUALITY_TEXT_TAGS.has(tag) && rect.width > 0 && textLen > lineMax) {
|
||||
const charsPerLine = rect.width / (fontSize * 0.5);
|
||||
if (charsPerLine > lineMax + 5) {
|
||||
findings.push({ id: 'line-length', snippet: `~${Math.round(charsPerLine)} chars/line (aim for <${lineMax})` });
|
||||
}
|
||||
}
|
||||
|
||||
// --- Cramped padding (skip small elements like labels/badges) ---
|
||||
if (hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) {
|
||||
// --- Cramped padding --- (browser-only: needs rect to skip small badges/labels)
|
||||
if (rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) {
|
||||
const borders = {
|
||||
top: parseFloat(style.borderTopWidth) || 0,
|
||||
right: parseFloat(style.borderRightWidth) || 0,
|
||||
bottom: parseFloat(style.borderBottomWidth) || 0,
|
||||
left: parseFloat(style.borderLeftWidth) || 0,
|
||||
};
|
||||
// Need at least 2 borders (a container), or a non-transparent background
|
||||
const borderCount = Object.values(borders).filter(w => w > 0).length;
|
||||
const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)';
|
||||
if (borderCount >= 2 || hasBg) {
|
||||
// Only check padding on sides that have borders or where bg creates containment
|
||||
const paddings = [];
|
||||
if (hasBg || borders.top > 0) paddings.push(parseFloat(style.paddingTop) || 0);
|
||||
if (hasBg || borders.right > 0) paddings.push(parseFloat(style.paddingRight) || 0);
|
||||
@@ -1040,16 +1078,14 @@ function checkElementQualityDOM(el) {
|
||||
|
||||
// --- Tight line height ---
|
||||
if (hasDirectText && textLen > 50 && !['h1','h2','h3','h4','h5','h6'].includes(tag)) {
|
||||
const lineHeight = parseFloat(style.lineHeight);
|
||||
if (lineHeight && lineHeight !== NaN) {
|
||||
const ratio = lineHeight / fontSize;
|
||||
if (ratio < 1.3 && ratio > 0) {
|
||||
if (lineHeightPx != null && fontSize > 0) {
|
||||
const ratio = lineHeightPx / fontSize;
|
||||
if (ratio > 0 && ratio < 1.3) {
|
||||
findings.push({ id: 'tight-leading', snippet: `line-height ${ratio.toFixed(2)}x (need >=1.3)` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// --- Justified text (without hyphens) ---
|
||||
if (hasDirectText && style.textAlign === 'justify') {
|
||||
const hyphens = style.hyphens || style.webkitHyphens || '';
|
||||
@@ -1062,7 +1098,7 @@ function checkElementQualityDOM(el) {
|
||||
// Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.)
|
||||
if (hasDirectText && textLen > 20 && fontSize < 12) {
|
||||
const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption'];
|
||||
const inUIContext = el.closest('button, a, label, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="label" i], [class*="caption" i]');
|
||||
const inUIContext = el.closest && el.closest('button, a, label, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="label" i], [class*="caption" i]');
|
||||
const isUppercase = style.textTransform === 'uppercase';
|
||||
if (!skipTags.includes(tag) && !inUIContext && !isUppercase) {
|
||||
findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` });
|
||||
@@ -1077,10 +1113,9 @@ function checkElementQualityDOM(el) {
|
||||
}
|
||||
|
||||
// --- Wide letter spacing on body text ---
|
||||
if (hasDirectText && textLen > 20) {
|
||||
const tracking = parseFloat(style.letterSpacing);
|
||||
if (tracking > 0 && style.textTransform !== 'uppercase') {
|
||||
const trackingEm = tracking / fontSize;
|
||||
if (hasDirectText && textLen > 20 && style.textTransform !== 'uppercase') {
|
||||
if (letterSpacingPx != null && letterSpacingPx > 0 && fontSize > 0) {
|
||||
const trackingEm = letterSpacingPx / fontSize;
|
||||
if (trackingEm > 0.05) {
|
||||
findings.push({ id: 'wide-tracking', snippet: `letter-spacing: ${trackingEm.toFixed(2)}em on body text` });
|
||||
}
|
||||
@@ -1090,25 +1125,57 @@ function checkElementQualityDOM(el) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
function checkPageQualityDOM() {
|
||||
const findings = [];
|
||||
function checkElementQualityDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
const style = getComputedStyle(el);
|
||||
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 10);
|
||||
const textLen = el.textContent?.trim().length || 0;
|
||||
// Browser getComputedStyle resolves everything to px — direct parseFloat
|
||||
// works.
|
||||
const fontSize = parseFloat(style.fontSize) || 16;
|
||||
const lineHeightPx = resolveLengthPx(style.lineHeight, fontSize);
|
||||
const letterSpacingPx = resolveLengthPx(style.letterSpacing, fontSize);
|
||||
const rect = el.getBoundingClientRect();
|
||||
const lineMax = (typeof window !== 'undefined' && window.__IMPECCABLE_CONFIG__?.lineLengthMax) || 80;
|
||||
return checkQuality({ el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax });
|
||||
}
|
||||
|
||||
// --- Skipped heading levels ---
|
||||
const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
||||
// Pure page-level skipped-heading walk. Takes a Document so it works in both
|
||||
// the browser and jsdom.
|
||||
function checkPageQualityFromDoc(doc) {
|
||||
const findings = [];
|
||||
const headings = doc.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
||||
let prevLevel = 0;
|
||||
for (const h of headings) {
|
||||
const level = parseInt(h.tagName[1]);
|
||||
if (prevLevel > 0 && level > prevLevel + 1) {
|
||||
findings.push({ type: 'skipped-heading', detail: `h${prevLevel} followed by h${level} (missing h${prevLevel + 1})` });
|
||||
findings.push({ id: 'skipped-heading', snippet: `h${prevLevel} followed by h${level} (missing h${prevLevel + 1})` });
|
||||
}
|
||||
prevLevel = level;
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
// Browser adapter (returns the legacy { type, detail } shape used by the overlay loop)
|
||||
function checkPageQualityDOM() {
|
||||
return checkPageQualityFromDoc(document).map(f => ({ type: f.id, detail: f.snippet }));
|
||||
}
|
||||
|
||||
// Node adapters — take pre-extracted jsdom computed style
|
||||
|
||||
// jsdom doesn't lay out OR resolve em/rem/% to px — so we pre-resolve every
|
||||
// CSS length the rule needs ourselves (walking the parent chain for
|
||||
// font-size inheritance), and pass `rect: null` to skip the two rules that
|
||||
// genuinely need element rects (line-length, cramped-padding).
|
||||
function checkElementQuality(el, style, tag, window) {
|
||||
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 10);
|
||||
const textLen = el.textContent?.trim().length || 0;
|
||||
const fontSize = resolveFontSizePx(el, window);
|
||||
const lineHeightPx = resolveLengthPx(style.lineHeight, fontSize);
|
||||
const letterSpacingPx = resolveLengthPx(style.letterSpacing, fontSize);
|
||||
return checkQuality({ el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect: null });
|
||||
}
|
||||
|
||||
function checkElementBorders(tag, style) {
|
||||
const sides = ['Top', 'Right', 'Bottom', 'Left'];
|
||||
const widths = {}, colors = {};
|
||||
@@ -1926,23 +1993,79 @@ if (IS_BROWSER) {
|
||||
overlays.push(banner);
|
||||
};
|
||||
|
||||
// Heuristic for skipping CSS-in-JS hashed class names like "css-1a2b3c" or "_2x4hG_".
|
||||
// These change between builds and produce brittle, ugly selectors.
|
||||
function isLikelyHashedClass(c) {
|
||||
if (!c) return true;
|
||||
if (/^(css|sc|emotion|jsx|module)-[\w-]{4,}$/i.test(c)) return true;
|
||||
if (/^_[\w-]{5,}$/.test(c)) return true;
|
||||
if (/^[a-z0-9]{6,}$/i.test(c) && /\d/.test(c)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function buildSelectorSegment(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
let sel = tag;
|
||||
|
||||
if (el.classList && el.classList.length > 0) {
|
||||
const classes = [...el.classList]
|
||||
.filter(c => !c.startsWith('impeccable-') && !isLikelyHashedClass(c))
|
||||
.slice(0, 2);
|
||||
if (classes.length > 0) {
|
||||
sel += '.' + classes.map(c => CSS.escape(c)).join('.');
|
||||
}
|
||||
}
|
||||
|
||||
// Disambiguate among siblings only if the parent has multiple matches
|
||||
const parent = el.parentElement;
|
||||
if (parent) {
|
||||
try {
|
||||
const matching = parent.querySelectorAll(':scope > ' + sel);
|
||||
if (matching.length > 1) {
|
||||
const sameType = [...parent.children].filter(c => c.tagName === el.tagName);
|
||||
const idx = sameType.indexOf(el) + 1;
|
||||
sel += `:nth-of-type(${idx})`;
|
||||
}
|
||||
} catch {
|
||||
const idx = [...parent.children].indexOf(el) + 1;
|
||||
sel = `${tag}:nth-child(${idx})`;
|
||||
}
|
||||
}
|
||||
return sel;
|
||||
}
|
||||
|
||||
function generateSelector(el) {
|
||||
if (el === document.body) return 'body';
|
||||
if (el === document.documentElement) return 'html';
|
||||
if (el.id) return '#' + CSS.escape(el.id);
|
||||
|
||||
const parts = [];
|
||||
let current = el;
|
||||
while (current && current !== document.body) {
|
||||
let sel = current.tagName.toLowerCase();
|
||||
if (current.id) { parts.unshift('#' + CSS.escape(current.id)); break; }
|
||||
const siblings = current.parentElement?.children;
|
||||
if (siblings && siblings.length > 1) {
|
||||
const index = [...siblings].indexOf(current) + 1;
|
||||
sel += ':nth-child(' + index + ')';
|
||||
let depth = 0;
|
||||
const MAX_DEPTH = 10;
|
||||
|
||||
while (current && current !== document.body && current !== document.documentElement && depth < MAX_DEPTH) {
|
||||
parts.unshift(buildSelectorSegment(current));
|
||||
|
||||
// Anchor on an ancestor's ID and stop walking up
|
||||
if (current.id) {
|
||||
parts[0] = '#' + CSS.escape(current.id);
|
||||
break;
|
||||
}
|
||||
parts.unshift(sel);
|
||||
|
||||
// Stop as soon as the partial selector uniquely identifies the target
|
||||
const trySelector = parts.join(' > ');
|
||||
try {
|
||||
const matches = document.querySelectorAll(trySelector);
|
||||
if (matches.length === 1 && matches[0] === el) {
|
||||
return trySelector;
|
||||
}
|
||||
} catch { /* invalid selector — keep walking */ }
|
||||
|
||||
current = current.parentElement;
|
||||
depth++;
|
||||
}
|
||||
|
||||
return parts.join(' > ');
|
||||
}
|
||||
|
||||
|
||||
+171
-42
@@ -983,42 +983,80 @@ function checkElementAIPaletteDOM(el) {
|
||||
|
||||
const QUALITY_TEXT_TAGS = new Set(['p', 'li', 'td', 'th', 'dd', 'blockquote', 'figcaption']);
|
||||
|
||||
function checkElementQualityDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
// Resolve a CSS font-size value to pixels by walking up the parent chain.
|
||||
// Browsers resolve em/rem/% to px in getComputedStyle, but jsdom returns the
|
||||
// specified value verbatim — so for the Node path we walk parents ourselves.
|
||||
function resolveFontSizePx(el, win) {
|
||||
const chain = []; // raw font-size strings, leaf → root
|
||||
let cur = el;
|
||||
while (cur && cur.nodeType === 1) {
|
||||
const fs = (win ? win.getComputedStyle(cur) : getComputedStyle(cur)).fontSize;
|
||||
chain.push(fs || '');
|
||||
cur = cur.parentElement;
|
||||
}
|
||||
// Walk root → leaf, resolving each value relative to its parent context.
|
||||
let px = 16; // root default
|
||||
for (let i = chain.length - 1; i >= 0; i--) {
|
||||
const v = chain[i];
|
||||
if (!v || v === 'inherit') continue;
|
||||
const num = parseFloat(v);
|
||||
if (isNaN(num)) continue;
|
||||
if (v.endsWith('px')) px = num;
|
||||
else if (v.endsWith('rem')) px = num * 16;
|
||||
else if (v.endsWith('em')) px = num * px;
|
||||
else if (v.endsWith('%')) px = (num / 100) * px;
|
||||
else px = num; // unitless — already resolved
|
||||
}
|
||||
return px;
|
||||
}
|
||||
|
||||
// Resolve a CSS length value (line-height, letter-spacing, etc.) given a
|
||||
// known font-size context. Returns null for "normal" / unparseable values.
|
||||
function resolveLengthPx(value, fontSizePx) {
|
||||
if (!value || value === 'normal' || value === 'auto' || value === 'inherit') return null;
|
||||
const num = parseFloat(value);
|
||||
if (isNaN(num)) return null;
|
||||
if (value.endsWith('px')) return num;
|
||||
if (value.endsWith('rem')) return num * 16;
|
||||
if (value.endsWith('em')) return num * fontSizePx;
|
||||
if (value.endsWith('%')) return (num / 100) * fontSizePx;
|
||||
// Unitless line-height = multiplier, return px equivalent
|
||||
return num * fontSizePx;
|
||||
}
|
||||
|
||||
// Pure quality checks. Most run on computed CSS and DOM-only inputs (work in
|
||||
// jsdom and the browser). Two checks (line-length, cramped-padding) gate on
|
||||
// element rect dimensions, which jsdom can't compute — pass `rect: null` from
|
||||
// the Node adapter to skip those.
|
||||
//
|
||||
// Both adapters resolve font-size, line-height and letter-spacing to pixels
|
||||
// before calling this so the pure function only deals with numbers.
|
||||
function checkQuality(opts) {
|
||||
const { el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax = 80 } = opts;
|
||||
const findings = [];
|
||||
// Skip browser extension injected elements
|
||||
const elId = el.id || '';
|
||||
if (elId.startsWith('claude-') || elId.startsWith('cic-')) return [];
|
||||
const style = getComputedStyle(el);
|
||||
const findings = [];
|
||||
if (elId.startsWith('claude-') || elId.startsWith('cic-')) return findings;
|
||||
|
||||
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 10);
|
||||
const textLen = el.textContent?.trim().length || 0;
|
||||
const fontSize = parseFloat(style.fontSize) || 16;
|
||||
const rect = el.getBoundingClientRect();
|
||||
|
||||
// --- Line length too long ---
|
||||
// Threshold is configurable via window.__IMPECCABLE_CONFIG__.lineLengthMax (default 80)
|
||||
const lineMax = (typeof window !== 'undefined' && window.__IMPECCABLE_CONFIG__?.lineLengthMax) || 80;
|
||||
if (hasDirectText && QUALITY_TEXT_TAGS.has(tag) && rect.width > 0 && textLen > lineMax) {
|
||||
// --- Line length too long --- (browser-only: needs rect.width)
|
||||
if (rect && hasDirectText && QUALITY_TEXT_TAGS.has(tag) && rect.width > 0 && textLen > lineMax) {
|
||||
const charsPerLine = rect.width / (fontSize * 0.5);
|
||||
if (charsPerLine > lineMax + 5) {
|
||||
findings.push({ id: 'line-length', snippet: `~${Math.round(charsPerLine)} chars/line (aim for <${lineMax})` });
|
||||
}
|
||||
}
|
||||
|
||||
// --- Cramped padding (skip small elements like labels/badges) ---
|
||||
if (hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) {
|
||||
// --- Cramped padding --- (browser-only: needs rect to skip small badges/labels)
|
||||
if (rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) {
|
||||
const borders = {
|
||||
top: parseFloat(style.borderTopWidth) || 0,
|
||||
right: parseFloat(style.borderRightWidth) || 0,
|
||||
bottom: parseFloat(style.borderBottomWidth) || 0,
|
||||
left: parseFloat(style.borderLeftWidth) || 0,
|
||||
};
|
||||
// Need at least 2 borders (a container), or a non-transparent background
|
||||
const borderCount = Object.values(borders).filter(w => w > 0).length;
|
||||
const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)';
|
||||
if (borderCount >= 2 || hasBg) {
|
||||
// Only check padding on sides that have borders or where bg creates containment
|
||||
const paddings = [];
|
||||
if (hasBg || borders.top > 0) paddings.push(parseFloat(style.paddingTop) || 0);
|
||||
if (hasBg || borders.right > 0) paddings.push(parseFloat(style.paddingRight) || 0);
|
||||
@@ -1035,16 +1073,14 @@ function checkElementQualityDOM(el) {
|
||||
|
||||
// --- Tight line height ---
|
||||
if (hasDirectText && textLen > 50 && !['h1','h2','h3','h4','h5','h6'].includes(tag)) {
|
||||
const lineHeight = parseFloat(style.lineHeight);
|
||||
if (lineHeight && lineHeight !== NaN) {
|
||||
const ratio = lineHeight / fontSize;
|
||||
if (ratio < 1.3 && ratio > 0) {
|
||||
if (lineHeightPx != null && fontSize > 0) {
|
||||
const ratio = lineHeightPx / fontSize;
|
||||
if (ratio > 0 && ratio < 1.3) {
|
||||
findings.push({ id: 'tight-leading', snippet: `line-height ${ratio.toFixed(2)}x (need >=1.3)` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// --- Justified text (without hyphens) ---
|
||||
if (hasDirectText && style.textAlign === 'justify') {
|
||||
const hyphens = style.hyphens || style.webkitHyphens || '';
|
||||
@@ -1057,7 +1093,7 @@ function checkElementQualityDOM(el) {
|
||||
// Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.)
|
||||
if (hasDirectText && textLen > 20 && fontSize < 12) {
|
||||
const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption'];
|
||||
const inUIContext = el.closest('button, a, label, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="label" i], [class*="caption" i]');
|
||||
const inUIContext = el.closest && el.closest('button, a, label, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="label" i], [class*="caption" i]');
|
||||
const isUppercase = style.textTransform === 'uppercase';
|
||||
if (!skipTags.includes(tag) && !inUIContext && !isUppercase) {
|
||||
findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` });
|
||||
@@ -1072,10 +1108,9 @@ function checkElementQualityDOM(el) {
|
||||
}
|
||||
|
||||
// --- Wide letter spacing on body text ---
|
||||
if (hasDirectText && textLen > 20) {
|
||||
const tracking = parseFloat(style.letterSpacing);
|
||||
if (tracking > 0 && style.textTransform !== 'uppercase') {
|
||||
const trackingEm = tracking / fontSize;
|
||||
if (hasDirectText && textLen > 20 && style.textTransform !== 'uppercase') {
|
||||
if (letterSpacingPx != null && letterSpacingPx > 0 && fontSize > 0) {
|
||||
const trackingEm = letterSpacingPx / fontSize;
|
||||
if (trackingEm > 0.05) {
|
||||
findings.push({ id: 'wide-tracking', snippet: `letter-spacing: ${trackingEm.toFixed(2)}em on body text` });
|
||||
}
|
||||
@@ -1085,25 +1120,57 @@ function checkElementQualityDOM(el) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
function checkPageQualityDOM() {
|
||||
const findings = [];
|
||||
function checkElementQualityDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
const style = getComputedStyle(el);
|
||||
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 10);
|
||||
const textLen = el.textContent?.trim().length || 0;
|
||||
// Browser getComputedStyle resolves everything to px — direct parseFloat
|
||||
// works.
|
||||
const fontSize = parseFloat(style.fontSize) || 16;
|
||||
const lineHeightPx = resolveLengthPx(style.lineHeight, fontSize);
|
||||
const letterSpacingPx = resolveLengthPx(style.letterSpacing, fontSize);
|
||||
const rect = el.getBoundingClientRect();
|
||||
const lineMax = (typeof window !== 'undefined' && window.__IMPECCABLE_CONFIG__?.lineLengthMax) || 80;
|
||||
return checkQuality({ el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax });
|
||||
}
|
||||
|
||||
// --- Skipped heading levels ---
|
||||
const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
||||
// Pure page-level skipped-heading walk. Takes a Document so it works in both
|
||||
// the browser and jsdom.
|
||||
function checkPageQualityFromDoc(doc) {
|
||||
const findings = [];
|
||||
const headings = doc.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
||||
let prevLevel = 0;
|
||||
for (const h of headings) {
|
||||
const level = parseInt(h.tagName[1]);
|
||||
if (prevLevel > 0 && level > prevLevel + 1) {
|
||||
findings.push({ type: 'skipped-heading', detail: `h${prevLevel} followed by h${level} (missing h${prevLevel + 1})` });
|
||||
findings.push({ id: 'skipped-heading', snippet: `h${prevLevel} followed by h${level} (missing h${prevLevel + 1})` });
|
||||
}
|
||||
prevLevel = level;
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
// Browser adapter (returns the legacy { type, detail } shape used by the overlay loop)
|
||||
function checkPageQualityDOM() {
|
||||
return checkPageQualityFromDoc(document).map(f => ({ type: f.id, detail: f.snippet }));
|
||||
}
|
||||
|
||||
// Node adapters — take pre-extracted jsdom computed style
|
||||
|
||||
// jsdom doesn't lay out OR resolve em/rem/% to px — so we pre-resolve every
|
||||
// CSS length the rule needs ourselves (walking the parent chain for
|
||||
// font-size inheritance), and pass `rect: null` to skip the two rules that
|
||||
// genuinely need element rects (line-length, cramped-padding).
|
||||
function checkElementQuality(el, style, tag, window) {
|
||||
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 10);
|
||||
const textLen = el.textContent?.trim().length || 0;
|
||||
const fontSize = resolveFontSizePx(el, window);
|
||||
const lineHeightPx = resolveLengthPx(style.lineHeight, fontSize);
|
||||
const letterSpacingPx = resolveLengthPx(style.letterSpacing, fontSize);
|
||||
return checkQuality({ el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect: null });
|
||||
}
|
||||
|
||||
function checkElementBorders(tag, style) {
|
||||
const sides = ['Top', 'Right', 'Bottom', 'Left'];
|
||||
const widths = {}, colors = {};
|
||||
@@ -1921,23 +1988,79 @@ if (IS_BROWSER) {
|
||||
overlays.push(banner);
|
||||
};
|
||||
|
||||
// Heuristic for skipping CSS-in-JS hashed class names like "css-1a2b3c" or "_2x4hG_".
|
||||
// These change between builds and produce brittle, ugly selectors.
|
||||
function isLikelyHashedClass(c) {
|
||||
if (!c) return true;
|
||||
if (/^(css|sc|emotion|jsx|module)-[\w-]{4,}$/i.test(c)) return true;
|
||||
if (/^_[\w-]{5,}$/.test(c)) return true;
|
||||
if (/^[a-z0-9]{6,}$/i.test(c) && /\d/.test(c)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function buildSelectorSegment(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
let sel = tag;
|
||||
|
||||
if (el.classList && el.classList.length > 0) {
|
||||
const classes = [...el.classList]
|
||||
.filter(c => !c.startsWith('impeccable-') && !isLikelyHashedClass(c))
|
||||
.slice(0, 2);
|
||||
if (classes.length > 0) {
|
||||
sel += '.' + classes.map(c => CSS.escape(c)).join('.');
|
||||
}
|
||||
}
|
||||
|
||||
// Disambiguate among siblings only if the parent has multiple matches
|
||||
const parent = el.parentElement;
|
||||
if (parent) {
|
||||
try {
|
||||
const matching = parent.querySelectorAll(':scope > ' + sel);
|
||||
if (matching.length > 1) {
|
||||
const sameType = [...parent.children].filter(c => c.tagName === el.tagName);
|
||||
const idx = sameType.indexOf(el) + 1;
|
||||
sel += `:nth-of-type(${idx})`;
|
||||
}
|
||||
} catch {
|
||||
const idx = [...parent.children].indexOf(el) + 1;
|
||||
sel = `${tag}:nth-child(${idx})`;
|
||||
}
|
||||
}
|
||||
return sel;
|
||||
}
|
||||
|
||||
function generateSelector(el) {
|
||||
if (el === document.body) return 'body';
|
||||
if (el === document.documentElement) return 'html';
|
||||
if (el.id) return '#' + CSS.escape(el.id);
|
||||
|
||||
const parts = [];
|
||||
let current = el;
|
||||
while (current && current !== document.body) {
|
||||
let sel = current.tagName.toLowerCase();
|
||||
if (current.id) { parts.unshift('#' + CSS.escape(current.id)); break; }
|
||||
const siblings = current.parentElement?.children;
|
||||
if (siblings && siblings.length > 1) {
|
||||
const index = [...siblings].indexOf(current) + 1;
|
||||
sel += ':nth-child(' + index + ')';
|
||||
let depth = 0;
|
||||
const MAX_DEPTH = 10;
|
||||
|
||||
while (current && current !== document.body && current !== document.documentElement && depth < MAX_DEPTH) {
|
||||
parts.unshift(buildSelectorSegment(current));
|
||||
|
||||
// Anchor on an ancestor's ID and stop walking up
|
||||
if (current.id) {
|
||||
parts[0] = '#' + CSS.escape(current.id);
|
||||
break;
|
||||
}
|
||||
parts.unshift(sel);
|
||||
|
||||
// Stop as soon as the partial selector uniquely identifies the target
|
||||
const trySelector = parts.join(' > ');
|
||||
try {
|
||||
const matches = document.querySelectorAll(trySelector);
|
||||
if (matches.length === 1 && matches[0] === el) {
|
||||
return trySelector;
|
||||
}
|
||||
} catch { /* invalid selector — keep walking */ }
|
||||
|
||||
current = current.parentElement;
|
||||
depth++;
|
||||
}
|
||||
|
||||
return parts.join(' > ');
|
||||
}
|
||||
|
||||
@@ -2233,6 +2356,9 @@ async function detectHtml(filePath) {
|
||||
for (const f of checkElementIconTile(el, 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));
|
||||
}
|
||||
}
|
||||
|
||||
// Page-level checks (only for full pages, not partials)
|
||||
@@ -2243,6 +2369,9 @@ async function detectHtml(filePath) {
|
||||
for (const f of checkPageLayout(document, window)) {
|
||||
findings.push(finding(f.id, filePath, f.snippet));
|
||||
}
|
||||
for (const f of checkPageQualityFromDoc(document)) {
|
||||
findings.push(finding(f.id, filePath, f.snippet));
|
||||
}
|
||||
for (const f of checkHtmlPatterns(html)) {
|
||||
findings.push(finding(f.id, filePath, f.snippet));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Puppeteer-backed fixture tests for browser-only detection rules.
|
||||
*
|
||||
* Some detection rules (cramped-padding, line-length, tight-leading,
|
||||
* skipped-heading, justified-text, tiny-text, all-caps-body, wide-tracking,
|
||||
* small-target) need real browser layout — they read getBoundingClientRect
|
||||
* and getComputedStyle results that jsdom can't compute. Those rules can't
|
||||
* be tested with the jsdom suite in detect-antipatterns-fixtures.test.mjs.
|
||||
*
|
||||
* This file uses detectUrl() (Puppeteer) to load fixtures in headless Chrome
|
||||
* via a temporary static HTTP server, so the fixtures can use absolute
|
||||
* <script src="/js/..."> paths just like in development.
|
||||
*
|
||||
* Run via Node's built-in test runner:
|
||||
* node --test tests/detect-antipatterns-browser.test.mjs
|
||||
*/
|
||||
import { describe, it, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { detectUrl } from '../src/detect-antipatterns.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const PORT = 8765;
|
||||
const BASE = `http://localhost:${PORT}`;
|
||||
|
||||
const MIME = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
};
|
||||
|
||||
let server;
|
||||
|
||||
before(async () => {
|
||||
// Static server: maps /fixtures/* to tests/fixtures/* and /js/* to public/js/*
|
||||
// (mirrors the routes in server/index.js so fixtures can use absolute paths)
|
||||
server = http.createServer((req, res) => {
|
||||
let filePath;
|
||||
if (req.url.startsWith('/fixtures/')) {
|
||||
filePath = path.join(ROOT, 'tests', req.url);
|
||||
} else if (req.url === '/js/detect-antipatterns-browser.js') {
|
||||
filePath = path.join(ROOT, 'src/detect-antipatterns-browser.js');
|
||||
} else {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const body = fs.readFileSync(filePath);
|
||||
const ext = path.extname(filePath);
|
||||
res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
|
||||
res.end(body);
|
||||
} catch {
|
||||
res.writeHead(404).end();
|
||||
}
|
||||
});
|
||||
await new Promise((resolve) => server.listen(PORT, resolve));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
});
|
||||
|
||||
describe('detectUrl — browser-only fixtures', () => {
|
||||
// Only two rules genuinely need real browser layout (getBoundingClientRect):
|
||||
// line-length → reads rect.width to compute chars-per-line
|
||||
// cramped-padding → reads rect.width/height to filter small badges
|
||||
// Everything else in the quality.html fixture runs in jsdom and is asserted
|
||||
// by tests/detect-antipatterns-fixtures.test.mjs.
|
||||
|
||||
it('cramped-padding: flag column triggers, small-pill case is currently a known false positive', async () => {
|
||||
const f = await detectUrl(`${BASE}/fixtures/antipatterns/cramped-padding.html`);
|
||||
const cramped = f.filter(r => r.antipattern === 'cramped-padding');
|
||||
// Flag column: 2 obvious cramped containers (4px and 2px padding).
|
||||
// Pass column: 1 finding from the .detection-cmd-style small pill —
|
||||
// currently a false positive that the user is deciding what to do with.
|
||||
// Total = 3. When the rule is relaxed for small inline pills, expect 2.
|
||||
assert.equal(cramped.length, 3, `expected 3 cramped-padding findings (2 flag + 1 disputed pill), got ${cramped.length}`);
|
||||
});
|
||||
|
||||
it('line-length: flag column triggers, pass column adds none', async () => {
|
||||
const f = await detectUrl(`${BASE}/fixtures/antipatterns/quality.html`);
|
||||
assert.equal(f.filter(r => r.antipattern === 'line-length').length, 1);
|
||||
});
|
||||
});
|
||||
@@ -117,6 +117,23 @@ describe('detectHtml — icon-tile-stack', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectHtml — quality (jsdom-compatible rules)', () => {
|
||||
// Six of the eight quality rules can run in jsdom because they only need
|
||||
// computed CSS values (tight-leading, tiny-text, justified-text,
|
||||
// all-caps-body, wide-tracking) or pure DOM walks (skipped-heading).
|
||||
// The other two (line-length, cramped-padding) need real layout rects and
|
||||
// live in tests/detect-antipatterns-browser.test.mjs (Puppeteer-backed).
|
||||
it('quality: flag column triggers all 6 jsdom-compatible quality rules', async () => {
|
||||
const f = await detectHtml(path.join(FIXTURES, 'quality.html'));
|
||||
assert.equal(f.filter(r => r.antipattern === 'tight-leading').length, 1);
|
||||
assert.equal(f.filter(r => r.antipattern === 'tiny-text').length, 1);
|
||||
assert.equal(f.filter(r => r.antipattern === 'justified-text').length, 1);
|
||||
assert.equal(f.filter(r => r.antipattern === 'all-caps-body').length, 1);
|
||||
assert.equal(f.filter(r => r.antipattern === 'wide-tracking').length, 1);
|
||||
assert.equal(f.filter(r => r.antipattern === 'skipped-heading').length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectHtml — layout', () => {
|
||||
it('layout: flag column triggers nested-cards, pass column adds none', async () => {
|
||||
const f = await detectHtml(path.join(FIXTURES, 'layout.html'));
|
||||
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Cramped Padding — Should Flag vs Should Pass</title>
|
||||
<style>
|
||||
/* Two-column fixture: left = should flag, right = should pass.
|
||||
Focused on the cramped-padding rule alone so we can think about
|
||||
its threshold and edge cases without other rules interfering. */
|
||||
body { font-family: system-ui, sans-serif; background: #fafafa; padding: 24px; margin: 0; color: #0f172a; }
|
||||
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; max-width: 1200px; margin: 0 auto; }
|
||||
.col h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.05em; margin: 0 0 16px; color: #475569; }
|
||||
.col h3 { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; margin: 24px 0 8px; color: #64748b; }
|
||||
.case { margin-bottom: 12px; }
|
||||
.case-label { display: block; font-size: 11px; color: #64748b; margin-bottom: 4px; }
|
||||
|
||||
/* ── FLAG: clearly cramped ── */
|
||||
|
||||
/* 4px padding on a bordered container with body-length text */
|
||||
.cramped-border {
|
||||
border: 1px solid #d1d5db;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 2px padding on a colored container */
|
||||
.cramped-bg {
|
||||
background: #e5e7eb;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Zero padding on a bordered container — text touches the border */
|
||||
.cramped-zero {
|
||||
border: 2px solid #3b82f6;
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ── PASS: comfortable padding ── */
|
||||
|
||||
.good-padding {
|
||||
border: 1px solid #d1d5db;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.good-padding-bg {
|
||||
background: #f1f5f9;
|
||||
padding: 14px 18px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Small inline command pill — exact replica of .detection-cmd on
|
||||
the impeccable.style homepage. The detector currently flags 6px
|
||||
vertical padding as "cramped", but visually this is a small
|
||||
inline badge where the generous 14px horizontal padding plus
|
||||
tight font-size makes 6px vertical look balanced. Under
|
||||
consideration: should the rule skip small inline pills, or
|
||||
relax for small font sizes? */
|
||||
.small-pill {
|
||||
display: inline-block;
|
||||
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.625; /* matches inherited body line-height on homepage */
|
||||
color: #0f172a;
|
||||
background: #f5f5f7;
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.small-pill::before {
|
||||
content: '➜ ';
|
||||
color: #ec4899;
|
||||
}
|
||||
|
||||
/* Same pill at the 8px threshold — should pass cleanly */
|
||||
.small-pill-8 {
|
||||
display: inline-block;
|
||||
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.625;
|
||||
color: #0f172a;
|
||||
background: #f5f5f7;
|
||||
padding: 8px 14px;
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.small-pill-8::before {
|
||||
content: '➜ ';
|
||||
color: #ec4899;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="grid">
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════════
|
||||
LEFT COLUMN: should flag
|
||||
═══════════════════════════════════════════════════════════ -->
|
||||
<div class="col" data-col="flag">
|
||||
<h2>Should flag</h2>
|
||||
|
||||
<h3>Bordered containers with too-small padding</h3>
|
||||
<div class="case">
|
||||
<span class="case-label">4px padding, bordered, body-length text</span>
|
||||
<div class="cramped-border">This text is crammed against the border with only 4px padding. It feels claustrophobic and hard to read.</div>
|
||||
</div>
|
||||
<div class="case">
|
||||
<span class="case-label">2px / 4px padding, colored background</span>
|
||||
<div class="cramped-bg">Cramped background padding makes the body text feel jammed against the edges of the colored container.</div>
|
||||
</div>
|
||||
<div class="case">
|
||||
<span class="case-label">Zero padding, bordered</span>
|
||||
<div class="cramped-zero">Zero padding on a bordered element. The text is literally touching the border.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════════
|
||||
RIGHT COLUMN: should pass
|
||||
═══════════════════════════════════════════════════════════ -->
|
||||
<div class="col" data-col="pass">
|
||||
<h2>Should pass</h2>
|
||||
|
||||
<h3>Comfortable container padding</h3>
|
||||
<div class="case">
|
||||
<span class="case-label">16px padding, bordered</span>
|
||||
<div class="good-padding">This container has 16px padding, giving the text room to breathe within its border.</div>
|
||||
</div>
|
||||
<div class="case">
|
||||
<span class="case-label">14px / 18px padding, colored background</span>
|
||||
<div class="good-padding-bg">Generous padding inside the colored container — text has plenty of room from the edge.</div>
|
||||
</div>
|
||||
|
||||
<h3>Small inline pills (under consideration)</h3>
|
||||
<div class="case">
|
||||
<span class="case-label">6px / 14px small pill (matches .detection-cmd on impeccable.style)</span>
|
||||
<code class="small-pill">npx impeccable detect src/</code>
|
||||
</div>
|
||||
<div class="case">
|
||||
<span class="case-label">8px / 14px small pill (clears the rule's current 8px floor)</span>
|
||||
<code class="small-pill-8">npx impeccable detect src/</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/js/detect-antipatterns-browser.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,340 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>General Design Quality Issues That Should Be Flagged</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
background: #f9fafb;
|
||||
padding: 2rem;
|
||||
/* No max-width on body - text will run edge to edge on wide screens */
|
||||
}
|
||||
h1 { font-size: 2rem; margin-bottom: 0.5rem; }
|
||||
h2 { font-size: 1.125rem; margin: 2.5rem 0 0.75rem; color: #6b7280; border-bottom: 1px solid #e5e7eb; padding-bottom: 0.5rem; }
|
||||
.demo { margin-bottom: 1rem; }
|
||||
.demo-label { font-size: 0.75rem; color: #9ca3af; margin-bottom: 0.25rem; }
|
||||
|
||||
/* ============================================
|
||||
1. LINE LENGTH TOO LONG
|
||||
Text that runs wider than ~75ch is hard to read.
|
||||
============================================ */
|
||||
.long-lines {
|
||||
font-size: 16px;
|
||||
/* No max-width - on a wide screen this paragraph will be 150+ chars per line */
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
2. CRAMPED PADDING
|
||||
Text crammed against the edge of a container.
|
||||
============================================ */
|
||||
.cramped-border {
|
||||
border: 1px solid #d1d5db;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.cramped-bg {
|
||||
background: #e5e7eb;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.cramped-zero {
|
||||
border: 2px solid #3b82f6;
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
3. TINY BODY TEXT
|
||||
Text below 12px is hard to read for body content.
|
||||
============================================ */
|
||||
.tiny-text {
|
||||
font-size: 10px;
|
||||
color: #374151;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.tiny-text-11 {
|
||||
font-size: 11px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
4. TIGHT LINE HEIGHT
|
||||
Line-height below 1.3 for body text makes reading difficult.
|
||||
============================================ */
|
||||
.tight-leading {
|
||||
font-size: 16px;
|
||||
line-height: 1.0;
|
||||
max-width: 40ch;
|
||||
}
|
||||
.tight-leading-px {
|
||||
font-size: 16px;
|
||||
line-height: 16px; /* Same as font-size = ratio of 1.0 */
|
||||
max-width: 40ch;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
5. JUSTIFIED TEXT
|
||||
Creates uneven word spacing ("rivers of white").
|
||||
============================================ */
|
||||
.justified {
|
||||
text-align: justify;
|
||||
max-width: 40ch;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
6. MISSING FOCUS STYLES
|
||||
Removing outline without providing an alternative.
|
||||
============================================ */
|
||||
.no-focus:focus {
|
||||
outline: none;
|
||||
}
|
||||
.no-focus-zero:focus {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
7. SMALL TOUCH TARGETS
|
||||
Interactive elements smaller than 44x44px.
|
||||
============================================ */
|
||||
.tiny-button {
|
||||
padding: 2px 6px;
|
||||
font-size: 11px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 3px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tiny-link {
|
||||
font-size: 11px;
|
||||
display: inline-block;
|
||||
padding: 0;
|
||||
}
|
||||
.tiny-icon-button {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 3px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
8. SKIPPED HEADING LEVELS
|
||||
h1 -> h3 (missing h2), h3 -> h5 (missing h4)
|
||||
============================================ */
|
||||
|
||||
/* ============================================
|
||||
9. Z-INDEX ABUSE
|
||||
Absurdly high z-index values.
|
||||
============================================ */
|
||||
.z-index-war {
|
||||
position: relative;
|
||||
z-index: 99999;
|
||||
background: white;
|
||||
padding: 1rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.z-index-war-extreme {
|
||||
position: relative;
|
||||
z-index: 2147483647;
|
||||
background: white;
|
||||
padding: 1rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
10. VIEWPORT-DEPENDENT FIXED WIDTHS
|
||||
Hard-coded pixel widths that will break on smaller screens.
|
||||
============================================ */
|
||||
.fixed-width {
|
||||
width: 800px;
|
||||
background: #e5e7eb;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
11. ALL-CAPS BODY TEXT
|
||||
Long passages in uppercase are hard to read.
|
||||
============================================ */
|
||||
.all-caps-body {
|
||||
text-transform: uppercase;
|
||||
font-size: 14px;
|
||||
max-width: 40ch;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
12. IMPORTANT OVERUSE
|
||||
============================================ */
|
||||
.important-chain {
|
||||
color: red !important;
|
||||
font-size: 16px !important;
|
||||
margin: 0 !important;
|
||||
padding: 8px !important;
|
||||
background: yellow !important;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
13. INCONSISTENT BORDER RADIUS
|
||||
Different radii on sibling elements that should match.
|
||||
============================================ */
|
||||
.radius-soup {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.radius-soup > div {
|
||||
padding: 12px 16px;
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
font-size: 14px;
|
||||
}
|
||||
.radius-2 { border-radius: 2px; }
|
||||
.radius-8 { border-radius: 8px; }
|
||||
.radius-16 { border-radius: 16px; }
|
||||
.radius-full { border-radius: 999px; }
|
||||
|
||||
/* ============================================
|
||||
14. LETTER SPACING ON BODY TEXT
|
||||
Tracking on body text hurts readability.
|
||||
============================================ */
|
||||
.wide-tracking {
|
||||
letter-spacing: 0.15em;
|
||||
font-size: 14px;
|
||||
max-width: 40ch;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
15. DEEP NESTING / WRAPPER DIVS
|
||||
Excessive DOM depth for no structural reason.
|
||||
============================================ */
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Design Quality Issues: Should Flag</h1>
|
||||
<p class="demo-label">Every example on this page has a common design quality problem.</p>
|
||||
|
||||
<!-- 1. Long lines -->
|
||||
<h2>1. Line Length Too Long</h2>
|
||||
<div class="demo">
|
||||
<p class="long-lines">This paragraph has no max-width constraint at all, which means on a wide monitor or ultrawide display, each line of text can stretch to 150 or even 200 characters wide. Research consistently shows that line lengths beyond 75 characters significantly reduce reading speed and comprehension. The eye has to travel too far to find the beginning of the next line, causing readers to lose their place. This is one of the most common and easily fixable typographic issues on the web, yet it persists because developers test on narrow browser windows and never see the problem. A simple max-width of 65ch to 75ch on the paragraph or its container would fix this entirely. But without that constraint, this text will just keep going and going across the full width of whatever viewport or container it finds itself in, making it genuinely unpleasant to read.</p>
|
||||
</div>
|
||||
|
||||
<!-- 2. Cramped padding -->
|
||||
<h2>2. Cramped Padding</h2>
|
||||
<div class="demo">
|
||||
<div class="cramped-border">This text is crammed against the border with only 4px padding. It feels claustrophobic and hard to read.</div>
|
||||
</div>
|
||||
<div class="demo">
|
||||
<div class="cramped-bg">Cramped background padding</div>
|
||||
</div>
|
||||
<div class="demo">
|
||||
<div class="cramped-zero">Zero padding on a bordered element. The text is literally touching the border.</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. Tiny text -->
|
||||
<h2>3. Tiny Body Text</h2>
|
||||
<div class="demo">
|
||||
<p class="tiny-text">This body text is only 10px. While this might be fine for a disclaimer or legal footnote, it's too small for primary content that users need to actually read. Accessibility guidelines generally recommend a minimum of 16px for body text, with 12px as an absolute floor.</p>
|
||||
</div>
|
||||
<div class="demo">
|
||||
<p class="tiny-text-11">This is 11px body text. Still too small for comfortable reading, especially on high-DPI screens where the physical size is even smaller than the pixel count suggests.</p>
|
||||
</div>
|
||||
|
||||
<!-- 4. Tight line height -->
|
||||
<h2>4. Tight Line Height</h2>
|
||||
<div class="demo">
|
||||
<p class="tight-leading">This paragraph has a line-height of 1.0, which means the lines are touching. Multi-line body text needs breathing room between lines for readability. A line-height of 1.5 to 1.7 is generally recommended for body text.</p>
|
||||
</div>
|
||||
<div class="demo">
|
||||
<p class="tight-leading-px">This paragraph uses line-height: 16px with font-size: 16px, giving an effective ratio of 1.0. Same problem expressed differently.</p>
|
||||
</div>
|
||||
|
||||
<!-- 5. Justified text -->
|
||||
<h2>5. Justified Text</h2>
|
||||
<div class="demo">
|
||||
<p class="justified">This paragraph uses text-align: justify, which forces each line to stretch to fill the full width. Without hyphenation support, this creates uneven gaps between words known as "rivers of white space" that flow vertically through the text. These rivers make the text harder to read because the inconsistent spacing disrupts the reading rhythm. Left-aligned text with a ragged right edge is almost always more readable on the web.</p>
|
||||
</div>
|
||||
|
||||
<!-- 6. Missing focus styles -->
|
||||
<h2>6. Missing Focus Styles</h2>
|
||||
<div class="demo">
|
||||
<button class="no-focus">outline: none (try tabbing)</button>
|
||||
<button class="no-focus-zero">outline: 0 (try tabbing)</button>
|
||||
</div>
|
||||
|
||||
<!-- 7. Small touch targets -->
|
||||
<h2>7. Small Touch Targets</h2>
|
||||
<div class="demo" style="display: flex; gap: 8px; align-items: center;">
|
||||
<button class="tiny-button">Tiny</button>
|
||||
<a href="#" class="tiny-link">Small link</a>
|
||||
<button class="tiny-icon-button">x</button>
|
||||
<button class="tiny-icon-button">+</button>
|
||||
</div>
|
||||
|
||||
<!-- 8. Skipped heading levels -->
|
||||
<h2>8. Skipped Heading Levels</h2>
|
||||
<div class="demo">
|
||||
<h3>This H3 follows the H2 above (OK)</h3>
|
||||
<h5>But this H5 skips H4 entirely (bad for accessibility and document structure)</h5>
|
||||
<p>Screen readers use heading levels to build a document outline. Skipping levels breaks that navigation.</p>
|
||||
</div>
|
||||
|
||||
<!-- 9. Z-index abuse -->
|
||||
<h2>9. Z-Index Abuse</h2>
|
||||
<div class="demo">
|
||||
<div class="z-index-war">z-index: 99999 (why?)</div>
|
||||
<div class="z-index-war-extreme">z-index: 2147483647 (the maximum 32-bit integer)</div>
|
||||
</div>
|
||||
|
||||
<!-- 10. Fixed pixel widths -->
|
||||
<h2>10. Fixed Pixel Widths</h2>
|
||||
<div class="demo">
|
||||
<div class="fixed-width">This element has width: 800px. On any screen narrower than 800px, it will overflow and cause horizontal scrolling.</div>
|
||||
</div>
|
||||
|
||||
<!-- 11. All-caps body text -->
|
||||
<h2>11. All-Caps Body Text</h2>
|
||||
<div class="demo">
|
||||
<p class="all-caps-body">This entire paragraph is in uppercase via text-transform. While all-caps works for short labels, headings, or navigation items, longer body text in uppercase is significantly harder to read because we lose the word shape cues that come from ascenders and descenders in mixed-case text.</p>
|
||||
</div>
|
||||
|
||||
<!-- 12. !important overuse -->
|
||||
<h2>12. !important Overuse</h2>
|
||||
<div class="demo">
|
||||
<p class="important-chain">This element has 5 !important declarations. It's a sign of specificity wars and unmaintainable CSS.</p>
|
||||
</div>
|
||||
|
||||
<!-- 13. Inconsistent border radius -->
|
||||
<h2>13. Inconsistent Border Radius</h2>
|
||||
<div class="demo">
|
||||
<div class="radius-soup">
|
||||
<div class="radius-2">2px radius</div>
|
||||
<div class="radius-8">8px radius</div>
|
||||
<div class="radius-16">16px radius</div>
|
||||
<div class="radius-full">pill</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 14. Letter spacing on body text -->
|
||||
<h2>14. Wide Letter Spacing</h2>
|
||||
<div class="demo">
|
||||
<p class="wide-tracking">This body text has letter-spacing: 0.15em applied to it. While subtle tracking adjustments can improve readability for headings or all-caps text, adding significant letter spacing to body text actually makes it harder to read by disrupting natural character groupings.</p>
|
||||
</div>
|
||||
|
||||
<script src="/js/detect-antipatterns-browser.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,183 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Good Design Quality Patterns That Should Pass</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
background: #f9fafb;
|
||||
padding: 2rem;
|
||||
max-width: 40rem;
|
||||
}
|
||||
h1 { font-size: 2rem; margin-bottom: 0.5rem; }
|
||||
h2 { font-size: 1.5rem; margin: 2rem 0 0.75rem; }
|
||||
h3 { font-size: 1.25rem; margin: 1.5rem 0 0.5rem; }
|
||||
p.intro { color: #6b7280; margin-bottom: 2rem; max-width: 36rem; font-size: 0.875rem; }
|
||||
.demo { margin-bottom: 1rem; }
|
||||
|
||||
/* Good line length */
|
||||
.good-measure {
|
||||
max-width: 65ch;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Good padding */
|
||||
.good-padding {
|
||||
border: 1px solid #d1d5db;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Good text size */
|
||||
.good-text {
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
max-width: 65ch;
|
||||
}
|
||||
|
||||
/* Small text that's OK (labels, captions) */
|
||||
.caption {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* Good touch targets */
|
||||
.good-button {
|
||||
padding: 12px 24px;
|
||||
font-size: 14px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* All-caps that's OK (short label) */
|
||||
.label-caps {
|
||||
text-transform: uppercase;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* Focus with custom style (outline: none is OK here) */
|
||||
.custom-focus:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.5);
|
||||
}
|
||||
|
||||
/* Justified text with hyphens (OK) */
|
||||
.justified-with-hyphens {
|
||||
text-align: justify;
|
||||
hyphens: auto;
|
||||
max-width: 40ch;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Consistent border radius */
|
||||
.consistent-radius {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.consistent-radius > div {
|
||||
padding: 12px 16px;
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Reasonable z-index */
|
||||
.reasonable-z {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
background: white;
|
||||
padding: 1rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Responsive width */
|
||||
.responsive-width {
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
background: #e5e7eb;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Design Quality: Should Pass</h1>
|
||||
<p class="intro">None of these should trigger quality warnings.</p>
|
||||
|
||||
<h2>Good Line Length</h2>
|
||||
<div class="demo">
|
||||
<p class="good-measure">This paragraph has a max-width of 65ch, keeping the line length comfortable for reading. The eye can easily track from the end of one line to the beginning of the next.</p>
|
||||
</div>
|
||||
|
||||
<h2>Short Text in Wide Container (OK)</h2>
|
||||
<div class="demo">
|
||||
<p style="max-width: none; width: 100%;">This is a short sentence in a wide container.</p>
|
||||
<p style="max-width: none; width: 100%;">Just a few words here.</p>
|
||||
</div>
|
||||
|
||||
<h2>Good Padding</h2>
|
||||
<div class="demo">
|
||||
<div class="good-padding">This container has 16px padding, giving the text room to breathe within its border.</div>
|
||||
</div>
|
||||
|
||||
<h2>Good Text Sizes</h2>
|
||||
<div class="demo">
|
||||
<p class="good-text">This is 16px body text with 1.6 line-height. Comfortable to read.</p>
|
||||
<p class="caption">This is a 12px caption. Small but appropriate for its purpose.</p>
|
||||
</div>
|
||||
|
||||
<h2>Good Touch Targets</h2>
|
||||
<div class="demo">
|
||||
<button class="good-button">Properly Sized Button</button>
|
||||
</div>
|
||||
|
||||
<h2>Proper Heading Hierarchy</h2>
|
||||
<div class="demo">
|
||||
<h3>This H3 follows H2 correctly</h3>
|
||||
<p>No skipped levels.</p>
|
||||
</div>
|
||||
|
||||
<h2>Short Labels in Caps (OK)</h2>
|
||||
<div class="demo">
|
||||
<span class="label-caps">Category Label</span>
|
||||
</div>
|
||||
|
||||
<h2>Custom Focus Style</h2>
|
||||
<div class="demo">
|
||||
<button class="custom-focus good-button">Custom focus ring</button>
|
||||
</div>
|
||||
|
||||
<h2>Consistent Border Radius</h2>
|
||||
<div class="demo">
|
||||
<div class="consistent-radius">
|
||||
<div>Card A</div>
|
||||
<div>Card B</div>
|
||||
<div>Card C</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Reasonable Z-Index</h2>
|
||||
<div class="demo">
|
||||
<div class="reasonable-z">z-index: 10</div>
|
||||
</div>
|
||||
|
||||
<h2>Responsive Width</h2>
|
||||
<div class="demo">
|
||||
<div class="responsive-width">max-width: 800px, width: 100%. Adapts to any screen.</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/detect-antipatterns-browser.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Quality (Typography & Readability) — Should Flag vs Should Pass</title>
|
||||
<style>
|
||||
/* Two-column fixture: left = should flag, right = should pass.
|
||||
Covers the typography-quality rules that need real browser layout
|
||||
(line-length, tight-leading, tiny-text, justified-text, all-caps-body,
|
||||
wide-tracking, skipped-heading). All are browser-only — see
|
||||
tests/detect-antipatterns-browser.test.mjs for assertions. */
|
||||
body { font-family: system-ui, sans-serif; background: #fafafa; padding: 24px; margin: 0; color: #0f172a; line-height: 1.6; }
|
||||
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; max-width: 1280px; margin: 0 auto; }
|
||||
.col h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.05em; margin: 0 0 16px; color: #475569; }
|
||||
.col h3 { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; margin: 24px 0 8px; color: #64748b; }
|
||||
.case { margin-bottom: 16px; padding: 16px; background: white; border: 1px solid #e2e8f0; border-radius: 8px; }
|
||||
.case-label { display: block; font-size: 12px; color: #64748b; margin-bottom: 6px; font-style: italic; }
|
||||
|
||||
/* ── FLAG: typography quality issues ── */
|
||||
|
||||
/* Line length: 12px body text fits >85 chars/line in a ~528px column.
|
||||
12px is not below the tiny-text 12px floor (rule fires at <12px). */
|
||||
.long-lines {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Tight leading */
|
||||
.tight-leading {
|
||||
font-size: 16px;
|
||||
line-height: 1.0;
|
||||
max-width: 40em;
|
||||
}
|
||||
|
||||
/* Tiny body text */
|
||||
.tiny-text {
|
||||
font-size: 10px;
|
||||
line-height: 1.6;
|
||||
max-width: 40em;
|
||||
}
|
||||
.tiny-text-11 {
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
max-width: 40em;
|
||||
}
|
||||
|
||||
/* Justified text without hyphens */
|
||||
.justified-no-hyphens {
|
||||
text-align: justify;
|
||||
max-width: 30em;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* All-caps long body */
|
||||
.all-caps-body {
|
||||
text-transform: uppercase;
|
||||
font-size: 16px;
|
||||
max-width: 40em;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Wide letter spacing on body */
|
||||
.wide-tracking-body {
|
||||
letter-spacing: 0.15em;
|
||||
font-size: 16px;
|
||||
max-width: 40em;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ── PASS: comfortable typography ── */
|
||||
|
||||
.good-measure {
|
||||
max-width: 65ch;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.good-leading {
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
max-width: 40em;
|
||||
}
|
||||
|
||||
.good-text-size {
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
max-width: 40em;
|
||||
}
|
||||
|
||||
/* Justified text WITH hyphens — fine */
|
||||
.justified-with-hyphens {
|
||||
text-align: justify;
|
||||
hyphens: auto;
|
||||
-webkit-hyphens: auto;
|
||||
max-width: 30em;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Short label in all-caps (passes — under 30 chars) */
|
||||
.label-caps {
|
||||
text-transform: uppercase;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
/* Wide tracking on a short uppercase label (passes — both conditions excluded) */
|
||||
.label-tracking {
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="grid">
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════════
|
||||
LEFT COLUMN: should flag
|
||||
═══════════════════════════════════════════════════════════ -->
|
||||
<div class="col" data-col="flag">
|
||||
<h2>Should flag</h2>
|
||||
|
||||
<h3>Line length too long</h3>
|
||||
<div class="case">
|
||||
<span class="case-label">no max-width on a paragraph</span>
|
||||
<p class="long-lines">This paragraph has no max-width constraint at all, which means on a wide monitor or ultrawide display, each line of text can stretch to 150 or even 200 characters wide. Research consistently shows that line lengths beyond 75 characters significantly reduce reading speed and comprehension. The eye has to travel too far to find the beginning of the next line, causing readers to lose their place. A simple max-width of 65ch to 75ch on the paragraph or its container would fix this entirely.</p>
|
||||
</div>
|
||||
|
||||
<h3>Tight line height</h3>
|
||||
<div class="case">
|
||||
<span class="case-label">line-height: 1.0</span>
|
||||
<p class="tight-leading">This paragraph has a line-height of 1.0, which means the lines are touching. Multi-line body text needs breathing room between lines for readability. A line-height of 1.5 to 1.7 is generally recommended for body text.</p>
|
||||
</div>
|
||||
|
||||
<h3>Tiny body text</h3>
|
||||
<div class="case">
|
||||
<span class="case-label">10px body text</span>
|
||||
<p class="tiny-text">This body text is only 10px. While this might be fine for a disclaimer or legal footnote, it's too small for primary content that users need to actually read. Aim for at least 14px for body content, 16px is ideal.</p>
|
||||
</div>
|
||||
|
||||
<h3>Justified text without hyphens</h3>
|
||||
<div class="case">
|
||||
<span class="case-label">text-align: justify, no hyphens: auto</span>
|
||||
<p class="justified-no-hyphens">This paragraph uses text-align: justify, which forces each line to stretch to fill the full width. Without hyphenation support, this creates uneven gaps between words known as "rivers of white space" that flow vertically through the text. Left-aligned text is almost always more readable on the web.</p>
|
||||
</div>
|
||||
|
||||
<h3>All-caps body text</h3>
|
||||
<div class="case">
|
||||
<span class="case-label">text-transform: uppercase on a long passage</span>
|
||||
<p class="all-caps-body">This entire paragraph is in uppercase via text-transform. While all-caps works for short labels, headings, or navigation items, longer body text in uppercase is significantly harder to read because we lose the word shape cues that come from ascenders and descenders in mixed-case text.</p>
|
||||
</div>
|
||||
|
||||
<h3>Wide letter spacing on body text</h3>
|
||||
<div class="case">
|
||||
<span class="case-label">letter-spacing: 0.15em on body text</span>
|
||||
<p class="wide-tracking-body">This body text has letter-spacing: 0.15em applied to it. While subtle tracking adjustments can improve readability for headings or all-caps text, adding significant letter spacing to body text actually makes it harder to read by disrupting natural character groupings.</p>
|
||||
</div>
|
||||
|
||||
<h3>Skipped heading levels</h3>
|
||||
<div class="case">
|
||||
<span class="case-label">h1 → h3 (missing h2)</span>
|
||||
<h1 style="font-size: 20px; margin: 0 0 4px;">Top heading</h1>
|
||||
<h3 style="font-size: 16px; margin: 0;">Skips straight to h3</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════════
|
||||
RIGHT COLUMN: should pass
|
||||
═══════════════════════════════════════════════════════════ -->
|
||||
<div class="col" data-col="pass">
|
||||
<h2>Should pass</h2>
|
||||
|
||||
<h3>Comfortable line length</h3>
|
||||
<div class="case">
|
||||
<span class="case-label">max-width: 65ch on the paragraph</span>
|
||||
<p class="good-measure">This paragraph has a max-width of 65ch, keeping the line length comfortable for reading. The eye can easily track from the end of one line to the beginning of the next without losing its place.</p>
|
||||
</div>
|
||||
|
||||
<h3>Comfortable line height</h3>
|
||||
<div class="case">
|
||||
<span class="case-label">line-height: 1.6</span>
|
||||
<p class="good-leading">This paragraph has a line-height of 1.6, which gives multi-line text plenty of room to breathe and improves the rhythm of the page.</p>
|
||||
</div>
|
||||
|
||||
<h3>Comfortable body text size</h3>
|
||||
<div class="case">
|
||||
<span class="case-label">16px body text</span>
|
||||
<p class="good-text-size">This is 16px body text — the recommended baseline for comfortable reading on modern displays.</p>
|
||||
</div>
|
||||
|
||||
<h3>Justified text with hyphens</h3>
|
||||
<div class="case">
|
||||
<span class="case-label">text-align: justify + hyphens: auto</span>
|
||||
<p class="justified-with-hyphens">When justified text is paired with hyphens: auto, the browser can break long words across lines, eliminating the rivers of white space and making the justification look intentional rather than awkward.</p>
|
||||
</div>
|
||||
|
||||
<h3>Short label in all-caps</h3>
|
||||
<div class="case">
|
||||
<span class="case-label">short label, uppercase</span>
|
||||
<span class="label-caps">Featured</span>
|
||||
</div>
|
||||
|
||||
<h3>Wide tracking on a short uppercase label</h3>
|
||||
<div class="case">
|
||||
<span class="case-label">letter-spacing on a short uppercase label</span>
|
||||
<span class="label-tracking">Beta</span>
|
||||
</div>
|
||||
|
||||
<h3>Proper heading hierarchy</h3>
|
||||
<div class="case">
|
||||
<span class="case-label">h1 → h2 → h3</span>
|
||||
<h1 style="font-size: 20px; margin: 0 0 4px;">Top heading</h1>
|
||||
<h2 style="font-size: 18px; margin: 0 0 4px;">Second level</h2>
|
||||
<h3 style="font-size: 16px; margin: 0;">Third level</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/js/detect-antipatterns-browser.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user