Design Quality
What separates a page that works from one that merely exists?
Every criterion below has a measurable threshold. Pass/fail, not opinion. Run this as a protocol, not a suggestion.
Sources: WCAG 2.2, Nielsen's 10 Heuristics, Google Core Web Vitals, Apple HIG, Material Design 3, USWDS Design Tokens, Butterick's Practical Typography
Rendering
Before evaluating quality, confirm the design renders at all. This gate must pass before any other check runs.
| # | Check | Threshold | How to Measure |
|---|---|---|---|
| 1.1 | Every CSS token resolves to a non-empty value | 0 unresolved tokens | getComputedStyle(el).getPropertyValue('--token') must not be empty |
| 1.2 | Every bg-* class produces a visible computed value | No rgba(0,0,0,0) on elements with semantic bg classes | getComputedStyle(el).backgroundColor on every .bg-* element |
| 1.3 | Every text color differs from its background | 0 invisible text elements | Compare color to nearest ancestor backgroundColor |
| 1.4 | Framework base styles don't override utility classes on h1-h6, a, button | Computed color matches utility class intent | Create element with class, compare getComputedStyle to token value |
| 1.5 | All images/SVGs render with visible dimensions | Width and height > 0, no broken icons | getBoundingClientRect() on every img and svg |
| 1.6 | No elements overflow creating hidden content | 0 unintended horizontal scrollbars | scrollWidth <= clientWidth |
Contrast
| # | Check | Threshold | How to Measure |
|---|---|---|---|
| 2.1 | Body text contrast ratio | >= 4.5:1 (AA) | Luminance ratio from color and backgroundColor RGB |
| 2.2 | Large text (>= 18.66px bold or >= 24px) contrast | >= 3:1 (AA) | Same calculation with size threshold |
| 2.3 | UI component contrast (borders, icons, forms) | >= 3:1 | Per WCAG 1.4.11 Non-text Contrast |
| 2.4 | Minimum body text size | >= 16px desktop, >= 15px mobile | getComputedStyle(el).fontSize on p elements |
| 2.5 | Minimum label/caption text | >= 12px | Smallest text on page |
| 2.6 | No informational elements below 0.5 opacity | Decorative only below 0.5 | Check opacity on elements with text or meaningful SVG |
| 2.7 | Body font weight | >= 400 (Regular) | Avoid Ultralight/Thin/Light for body |
| 2.8 | Link distinction from body text | Underline OR contrast >= 3:1 + non-color indicator | Per WCAG 1.4.1 Use of Color |
Type Scale
| # | Check | Threshold | How to Measure |
|---|---|---|---|
| 3.1 | Consistent scale ratio | Steps follow a ratio within +/- 10% (e.g. 1.25 Major Third) | Compute sizes[n+1] / sizes[n] for all sizes |
| 3.2 | Maximum distinct sizes | <= 6 per page | Count unique fontSize values |
| 3.3 | Heading hierarchy | H1 > H2 > H3 in DOM order, no skipped levels | Parse heading tags in DOM order |
| 3.4 | Same semantic level = same size | All H2s same size (+/- 1px), all H3s same | Group by tag, check variance |
| 3.5 | Heading-to-body ratio | H1: 2x-4x body. H2: 1.4x-2.5x body | h1Size / bodySize |
| 3.6 | Body line-height | 1.4-1.8x font-size | parseFloat(lineHeight) / parseFloat(fontSize) |
| 3.7 | Heading line-height | 1.1-1.3x font-size | Same on h1, h2, h3 |
| 3.8 | Line length (measure) | 45-75 characters | el.clientWidth / (parseFloat(fontSize) * 0.5) |
| 3.9 | Letter-spacing on uppercase text | 0.05em-0.2em | getComputedStyle(el).letterSpacing on text-transform: uppercase |
| 3.10 | Font families | <= 3 distinct | Count unique fontFamily values |
| 3.11 | Eyebrow size relative to body | >= 70% of body (body 18px = label >= 13px) | Compare label to body size |
Spacing
| # | Check | Threshold | How to Measure |
|---|---|---|---|
| 4.1 | Values align to base unit | >= 80% multiples of 4px or 8px | Audit all margin/padding |
| 4.2 | Gap after heading < gap between sections | heading.marginBottom < section.paddingTop | Direct comparison |
| 4.3 | Related elements closer than unrelated (Gestalt) | Intra-group < inter-group by >= 2x | Compare space-y within groups vs padding between |
| 4.4 | Same-level sections use same padding | Variance <= 8px | Group by type, compare padding |
| 4.5 | No dead zones > 150px | Max whitespace gap <= 150px | Bottom of section N to top of section N+1 |
| 4.6 | Heading-to-body gap consistency | Same gap (+/- 4px) everywhere | heading.bottom to nextSibling.top across sections |
| 4.7 | First-screen content density | Hero content >= 40% of viewport | totalContentHeight / viewportHeight |
| 4.8 | Repeated patterns identical spacing | Cards, rows: internal spacing variance = 0 | Compare across all instances |
| 4.9 | Mobile padding scales down | Mobile <= 66% of desktop | Compare at 375px vs 1280px |
Visual Flow
| # | Check | Threshold | How to Measure |
|---|---|---|---|
| 5.1 | Primary message above fold | H1 + value prop in first viewport | rect.bottom < viewportHeight |
| 5.2 | One focal point per viewport-height | No two elements of equal visual weight compete | Identify largest/boldest at each scroll position |
| 5.3 | Narrative section order | Hook, Framework, Evidence, Action | Map sections to roles; CTA after value established |
| 5.4 | Every section earns its place | No redundancy between sections | If two sections merge without loss, they should |
| 5.5 | Clear section boundaries | Background change, divider, or >= 64px gap | Visual differentiator between adjacent sections |
| 5.6 | CTA visibility | Highest-contrast, largest interactive element | Compare CTA contrast/size to all other interactive elements |
| 5.7 | Progressive disclosure | Complexity increases as user scrolls | Word count section 1 < section N |
| 5.8 | Scannable structure | Eyebrow + heading + <= 50 words before detail | Check intro length before lists/tables |
Responsive
| # | Check | Threshold | How to Measure |
|---|---|---|---|
| 6.1 | 4 breakpoints tested | 375px, 768px, 1024px, 1440px without breakage | Screenshot or programmatic test |
| 6.2 | No mobile horizontal overflow | scrollWidth <= clientWidth at 375px | Programmatic check |
| 6.3 | Mobile touch targets | All interactive elements >= 44x44px | getBoundingClientRect() at mobile width |
| 6.4 | Font scaling | H1 <= 48px at 375px, body stable | Computed sizes per breakpoint |
| 6.5 | Media scales proportionally | Aspect ratio maintained, fits viewport | No overflow or distortion |
| 6.6 | Cumulative Layout Shift | CLS <= 0.1 | Lighthouse or Web Vitals API |
| 6.7 | 400% zoom reflow | All content accessible without horizontal scroll | Per WCAG 1.4.10 |
Interactive
| # | Check | Threshold | How to Measure |
|---|---|---|---|
| 7.1 | Links/buttons distinct from static text | Color, underline, or border distinguishes | Can a new user tell what's clickable? |
| 7.2 | Hover state present | All interactive elements change on hover | Test :hover produces visible difference |
| 7.3 | Focus ring visible | All elements show focus indicator on Tab | Tab through page |
| 7.4 | Active/pressed state | Buttons show feedback on click | Test :active state |
| 7.5 | Disabled state distinct | Visually different from enabled | Compare computed styles |
| 7.6 | Consistent patterns | All links styled same, all buttons styled same | No link looks like button without reason |
| 7.7 | Navigation at every breakpoint | Menu accessible at all 4 breakpoints | Test hamburger opens/closes, links reachable |
Performance
| # | Check | Threshold | How to Measure |
|---|---|---|---|
| 8.1 | Largest Contentful Paint | <= 2.5s | Lighthouse |
| 8.2 | Interaction to Next Paint | <= 200ms | Lighthouse |
| 8.3 | Cumulative Layout Shift | <= 0.1 | Lighthouse |
| 8.4 | Total page weight | <= 1MB initial load | Network tab transfer size |
| 8.5 | Font loading | No FOIT > 100ms | font-display: swap; test throttled |
| 8.6 | Image optimization | Modern format (WebP/AVIF), correct dimensions | Check src formats and rendered vs natural size |
Accessibility
| # | Check | Threshold | How to Measure |
|---|---|---|---|
| 9.1 | All images have alt text | 0 img without alt | DOM audit |
| 9.2 | SVG descriptions | Informational SVGs have role="img" + aria-label | DOM audit |
| 9.3 | Valid heading structure | Exactly one H1, no skipped levels | Parse heading elements |
| 9.4 | Landmark regions | main, nav, footer present | DOM audit |
| 9.5 | Color not sole differentiator | No info conveyed by color alone | Check color-coded elements |
| 9.6 | Keyboard navigable | All elements reachable via Tab | Manual test |
| 9.7 | Reduced motion respected | Animations honor prefers-reduced-motion | Check CSS @media rule exists |
| 9.8 | Language declared | html lang="en" present | DOM check |
Content
| # | Check | Threshold | How to Measure |
|---|---|---|---|
| 10.1 | Value proposition length | Hero heading <= 12 words | Word count |
| 10.2 | Self-explanatory subheadings | H2s tell the story without body text | Read H2s in order |
| 10.3 | No orphan sections | Every section has heading + body | DOM audit |
| 10.4 | Consistent voice | Same person and tense throughout | Manual review |
| 10.5 | Action-oriented CTA | Button text contains a verb | Check primary CTA |
| 10.6 | No dead-end pages | >= 1 forward navigation path | DOM audit for internal links |
Audit Script
Run in browser console after any visual change. Returns pass/fail for each measurable criterion.
(function designAudit() {
const results = { pass: 0, fail: 0, issues: [] };
function check(id, name, passed, detail) {
if (passed) { results.pass++; }
else { results.fail++; results.issues.push({ id, name, detail }); }
}
// 1.2 Background tokens render
document.querySelectorAll('[class*="bg-"]').forEach(el => {
const cls = Array.from(el.classList).find(c =>
c.startsWith('bg-') && !c.startsWith('bg-gradient')
);
if (cls && !['bg-white', 'bg-transparent', 'bg-inherit'].includes(cls)) {
const bg = getComputedStyle(el).backgroundColor;
if (bg === 'rgba(0, 0, 0, 0)') {
check('1.2', 'BG token renders', false,
cls + ' resolves to transparent on <' + el.tagName + '>');
}
}
});
// 1.3 + 2.1 Text visibility and contrast
function luminance(r, g, b) {
const [rs, gs, bs] = [r, g, b].map(c => {
c = c / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
});
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}
function parseRGB(str) {
const m = str.match(/(\d+),\s*(\d+),\s*(\d+)/);
return m ? [+m[1], +m[2], +m[3]] : null;
}
function contrastRatio(c1, c2) {
const l1 = luminance(...c1), l2 = luminance(...c2);
const lighter = Math.max(l1, l2), darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
document.querySelectorAll('h1,h2,h3,p,span,a,li').forEach(el => {
if (!el.textContent.trim()) return;
const color = parseRGB(getComputedStyle(el).color);
const bgEl = el.closest('[class*="bg-"]') || document.body;
let bg = parseRGB(getComputedStyle(bgEl).backgroundColor);
if (!bg || (bg[0] === 0 && bg[1] === 0 && bg[2] === 0 &&
getComputedStyle(bgEl).backgroundColor.includes('0)'))) {
bg = [255, 255, 255];
}
if (color && bg) {
const ratio = contrastRatio(color, bg);
const size = parseFloat(getComputedStyle(el).fontSize);
const weight = parseInt(getComputedStyle(el).fontWeight);
const isLarge = size >= 24 || (size >= 18.66 && weight >= 700);
const threshold = isLarge ? 3 : 4.5;
if (ratio < threshold) {
check('2.1', 'Contrast', false,
el.tagName + ' "' + el.textContent.trim().substring(0, 30) +
'" ratio=' + ratio.toFixed(1) + ' need ' + threshold + ':1');
}
}
});
// 3.4 Same heading level = same size
const headingSizes = {};
document.querySelectorAll('main h1, main h2, main h3').forEach(h => {
const tag = h.tagName;
const size = getComputedStyle(h).fontSize;
if (!headingSizes[tag]) headingSizes[tag] = new Set();
headingSizes[tag].add(size);
});
Object.entries(headingSizes).forEach(([tag, sizes]) => {
check('3.4', tag + ' consistent size', sizes.size <= 1,
tag + ' has ' + sizes.size + ' sizes: ' + [...sizes].join(', '));
});
// 4.5 No dead zones > 150px
const sections = document.querySelectorAll('main > section');
for (let i = 1; i < sections.length; i++) {
const prevBottom = sections[i - 1].getBoundingClientRect().bottom;
const currTop = sections[i].getBoundingClientRect().top;
const gap = currTop - prevBottom;
if (gap > 150) {
check('4.5', 'No dead zones', false,
gap + 'px gap between section ' + (i - 1) + ' and ' + i);
}
}
// 9.1 Images have alt text
document.querySelectorAll('main img').forEach(img => {
check('9.1', 'Image alt text', img.hasAttribute('alt'),
'Missing alt on img src="' + img.src.substring(0, 50) + '"');
});
// 9.3 Heading structure
const headings = [...document.querySelectorAll(
'main h1,main h2,main h3,main h4,main h5,main h6'
)];
const h1Count = headings.filter(h => h.tagName === 'H1').length;
check('9.3', 'Single H1', h1Count === 1, 'Found ' + h1Count + ' H1 elements');
// Summary
console.log('\n=== DESIGN AUDIT ===');
console.log('PASS: ' + results.pass + ' | FAIL: ' + results.fail);
if (results.issues.length) {
console.log('\nISSUES:');
results.issues.forEach(i =>
console.error('[' + i.id + '] ' + i.name + ': ' + i.detail)
);
} else {
console.log('All checks passed.');
}
return results;
})();
How This Works
| Concept | Application |
|---|---|
| Standard | This checklist defines "good design" in measurable terms |
| Protocol | The audit script and manual checks are methods that recreate expected outcomes |
| Benchmark | A page passes with 0 FAIL results across all 10 categories |
| Trigger | Any FAIL triggers a fix workflow before the task completes |
Context
- Standards — Why consistency enables improvement
- Landing Page Checklist — Workflow-oriented page design
- Process Optimisation — Consistency enables measurement
- Control System — Feedback loops applied to standards