Add 9 design quality detections, improve browser overlay UX

New detections (browser-only, DOM-based):
- line-length: text wider than ~85 chars per line
- cramped-padding: <8px padding in bordered/bg containers (2+ borders)
- tight-leading: line-height < 1.3x on body text
- small-target: interactive elements < 44x44px
- skipped-heading: heading levels that skip (h1 then h3)
- justified-text: text-align: justify without hyphens: auto
- tiny-text: font-size < 12px on body text (>20 chars)
- all-caps-body: text-transform: uppercase on >30 chars of body text
- wide-tracking: letter-spacing > 0.05em on non-uppercase body text

Browser overlay improvements:
- Hover swaps label for detail tooltip (CSS-based, not JS events)
- Border goes transparent on hover to reveal element underneath
- Fixtures: quality-should-flag.html and quality-should-pass.html

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-03-18 23:12:41 -07:00
co-authored by Claude Opus 4.6
parent 4d2ef64935
commit 50485e0453
6 changed files with 1175 additions and 4047 deletions
@@ -149,6 +149,60 @@ const ANTIPATTERNS = [
description:
'Dark backgrounds with colored box-shadow glows are the default "cool" look of AI-generated UIs. Use subtle, purposeful lighting instead — or skip the dark theme entirely.',
},
{
id: 'line-length',
name: 'Line length too long',
description:
'Text lines wider than ~80 characters are hard to read. The eye loses its place tracking back to the start of the next line. Add a max-width (65ch to 75ch) to text containers.',
},
{
id: 'cramped-padding',
name: 'Cramped padding',
description:
'Text is too close to the edge of its container. Add at least 8px (ideally 12-16px) of padding inside bordered or colored containers.',
},
{
id: 'tight-leading',
name: 'Tight line height',
description:
'Line height below 1.3x the font size makes multi-line text hard to read. Use 1.5 to 1.7 for body text so lines have room to breathe.',
},
{
id: 'small-target',
name: 'Small touch target',
description:
'Interactive elements should be at least 44x44px to be comfortably tappable. Small targets cause misclicks and frustrate users, especially on touch devices.',
},
{
id: 'skipped-heading',
name: 'Skipped heading level',
description:
'Heading levels should not skip (e.g. h1 then h3 with no h2). Screen readers use heading hierarchy for navigation. Skipping levels breaks the document outline.',
},
{
id: 'justified-text',
name: 'Justified text',
description:
'Justified text without hyphenation creates uneven word spacing ("rivers of white"). Use text-align: left for body text, or enable hyphens: auto if you must justify.',
},
{
id: 'tiny-text',
name: 'Tiny body text',
description:
'Body text below 12px is hard to read, especially on high-DPI screens. Use at least 14px for body content, 16px is ideal.',
},
{
id: 'all-caps-body',
name: 'All-caps body text',
description:
'Long passages in uppercase are hard to read. We recognize words by shape (ascenders and descenders), which all-caps removes. Reserve uppercase for short labels and headings.',
},
{
id: 'wide-tracking',
name: 'Wide letter spacing on body text',
description:
'Letter spacing above 0.05em on body text disrupts natural character groupings and slows reading. Reserve wide tracking for short uppercase labels only.',
},
];
// ─── Section 2: Color Utilities ─────────────────────────────────────────────
@@ -709,6 +763,128 @@ function checkElementAIPaletteDOM(el) {
return findings;
}
const QUALITY_TEXT_TAGS = new Set(['p', 'li', 'td', 'th', 'dd', 'blockquote', 'figcaption']);
const QUALITY_INTERACTIVE_TAGS = new Set(['button', 'a', 'input', 'select', 'textarea']);
function checkElementQualityDOM(el) {
const tag = el.tagName.toLowerCase();
const style = getComputedStyle(el);
const 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 ---
// Only flag if text is long enough to actually fill the line (>80 chars)
if (hasDirectText && QUALITY_TEXT_TAGS.has(tag) && rect.width > 0 && textLen > 80) {
const charsPerLine = rect.width / (fontSize * 0.5);
if (charsPerLine > 85) {
findings.push({ id: 'line-length', snippet: `~${Math.round(charsPerLine)} chars/line (aim for <80)` });
}
}
// --- Cramped padding ---
if (hasDirectText && textLen > 10) {
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);
if (hasBg || borders.bottom > 0) paddings.push(parseFloat(style.paddingBottom) || 0);
if (hasBg || borders.left > 0) paddings.push(parseFloat(style.paddingLeft) || 0);
if (paddings.length > 0) {
const minPad = Math.min(...paddings);
if (minPad < 8) {
findings.push({ id: 'cramped-padding', snippet: `${minPad}px padding (need >=8px)` });
}
}
}
}
// --- 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) {
findings.push({ id: 'tight-leading', snippet: `line-height ${ratio.toFixed(2)}x (need >=1.3)` });
}
}
}
// --- Small touch targets ---
if (QUALITY_INTERACTIVE_TAGS.has(tag) || el.hasAttribute('tabindex') || el.getAttribute('role') === 'button') {
if (rect.width > 0 && rect.height > 0 && (rect.width < 44 || rect.height < 44)) {
if (style.display !== 'none' && style.visibility !== 'hidden') {
findings.push({ id: 'small-target', snippet: `${Math.round(rect.width)}x${Math.round(rect.height)}px (need 44x44)` });
}
}
}
// --- Justified text (without hyphens) ---
if (hasDirectText && style.textAlign === 'justify') {
const hyphens = style.hyphens || style.webkitHyphens || '';
if (hyphens !== 'auto') {
findings.push({ id: 'justified-text', snippet: 'text-align: justify without hyphens: auto' });
}
}
// --- Tiny body text ---
if (hasDirectText && textLen > 20 && fontSize < 12) {
if (!['sub', 'sup', 'code', 'kbd', 'samp', 'var'].includes(tag)) {
findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` });
}
}
// --- All-caps body text ---
if (hasDirectText && textLen > 30 && style.textTransform === 'uppercase') {
if (!['h1','h2','h3','h4','h5','h6'].includes(tag)) {
findings.push({ id: 'all-caps-body', snippet: `text-transform: uppercase on ${textLen} chars of body text` });
}
}
// --- 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 (trackingEm > 0.05) {
findings.push({ id: 'wide-tracking', snippet: `letter-spacing: ${trackingEm.toFixed(2)}em on body text` });
}
}
}
return findings;
}
function checkPageQualityDOM() {
const findings = [];
// --- Skipped heading levels ---
const headings = document.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})` });
}
prevLevel = level;
}
return findings;
}
// Node adapters — take pre-extracted jsdom computed style
function checkElementBorders(tag, style) {
@@ -1040,6 +1216,18 @@ if (IS_BROWSER) {
const LABEL_BG = 'oklch(55% 0.25 350)';
const OUTLINE_COLOR = 'oklch(60% 0.25 350)';
// Inject hover styles via CSS (more reliable than JS event listeners)
const styleEl = document.createElement('style');
styleEl.textContent = `
.impeccable-overlay { pointer-events: auto; }
.impeccable-overlay .impeccable-tooltip { display: none; }
.impeccable-overlay .impeccable-label { display: block; }
.impeccable-overlay:hover { border-color: transparent !important; }
.impeccable-overlay:hover .impeccable-tooltip { display: block; }
.impeccable-overlay:hover .impeccable-label { display: none; }
`;
(document.head || document.documentElement).appendChild(styleEl);
const overlays = [];
const TYPE_LABELS = {};
for (const ap of ANTIPATTERNS) {
@@ -1055,14 +1243,14 @@ if (IS_BROWSER) {
top: `${rect.top + scrollY - 2}px`, left: `${rect.left + scrollX - 2}px`,
width: `${rect.width + 4}px`, height: `${rect.height + 4}px`,
border: `2px solid ${OUTLINE_COLOR}`, borderRadius: '4px',
pointerEvents: 'none', zIndex: '99999', boxSizing: 'border-box',
zIndex: '99999', boxSizing: 'border-box',
});
const label = document.createElement('div');
label.className = 'impeccable-label';
label.textContent = findings.map(f => TYPE_LABELS[f.type || f.id] || f.type || f.id).join(', ');
Object.assign(label.style, {
position: 'absolute', top: '-20px', left: '0',
position: 'absolute', top: '-22px', left: '0',
background: LABEL_BG, color: 'white',
fontSize: '11px', fontFamily: 'system-ui, sans-serif', fontWeight: '600',
padding: '2px 8px', borderRadius: '3px', whiteSpace: 'nowrap',
@@ -1074,25 +1262,14 @@ if (IS_BROWSER) {
tooltip.className = 'impeccable-tooltip';
tooltip.innerHTML = findings.map(f => f.detail || f.snippet).join('<br>');
Object.assign(tooltip.style, {
position: 'absolute', bottom: '-28px', left: '0',
position: 'absolute', top: '-22px', left: '0',
background: 'rgba(0,0,0,0.85)', color: '#e5e5e5',
fontSize: '11px', fontFamily: 'ui-monospace, monospace',
padding: '4px 8px', borderRadius: '3px', whiteSpace: 'nowrap',
lineHeight: '16px', display: 'none', zIndex: '100000',
padding: '2px 8px', borderRadius: '3px', whiteSpace: 'nowrap',
lineHeight: '16px', letterSpacing: '0.02em', zIndex: '100000',
});
outline.appendChild(tooltip);
outline.addEventListener('mouseenter', () => {
outline.style.pointerEvents = 'auto';
tooltip.style.display = 'block';
outline.style.background = 'oklch(60% 0.25 350 / 0.08)';
});
outline.addEventListener('mouseleave', () => {
outline.style.pointerEvents = 'none';
tooltip.style.display = 'none';
outline.style.background = 'none';
});
document.body.appendChild(outline);
overlays.push(outline);
};
@@ -1163,6 +1340,7 @@ if (IS_BROWSER) {
...checkElementMotionDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
...checkElementGlowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
...checkElementAIPaletteDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
...checkElementQualityDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
];
if (findings.length > 0) {
@@ -1185,6 +1363,13 @@ if (IS_BROWSER) {
allFindings.push({ el, findings: [f] });
}
// Page-level quality checks (headings, etc.)
const qualityFindings = checkPageQualityDOM();
if (qualityFindings.length > 0) {
showPageBanner(qualityFindings);
allFindings.push({ el: document.body, findings: qualityFindings });
}
// Regex-on-HTML checks (shared with Node)
const htmlPatternFindings = checkHtmlPatterns(document.documentElement.outerHTML);
if (htmlPatternFindings.length > 0) {