Design QA
Verifies a UI change against your design system with greps and DOM measurements before it takes a screenshot. Design QA is one of the Claude Code skills I wrote for building this site, with the site-specific values turned into placeholders and examples so it works on any project. The skill is the SKILL.md below, exactly as it ships in the zip.
Install
unzip design-qa.zip -d ~/.claude/skillsThat installs it for every project. For one project, unzip into that project’s .claude/skills/ instead. Either way, Claude Code reads the folder on its next session and applies the skill when a request matches its description:
Quantitative design verification for a site with a design system. Run after any UI, layout, or design change, before declaring work done and before pushing. Verifies the spacing, colour, and typography invariants with greps and DOM measurements through browser DevTools, and reserves screenshots for one final sign-off. Also invocable as /design-qa.
Inside the zip
- —
design-qa/SKILL.md· 8 KB
The skill
Verify with numbers first, pixels last. Screenshots are the most expensive check available (thousands of tokens each), and every invariant below has a cheaper measurement, so a screenshot happens at most once per changed page, after everything numeric already passes.
Run the stages in order and stop at the first failure. Report results in the table at the bottom with measured values, never “looks good”.
Invariants
Keep the site’s invariants in a table like this one, in this file or in the project’s instructions file, and update it when the system changes. These are examples of the kind of value that belongs here; replace them with yours.
| Invariant | Value |
|---|---|
| Fixed site header height | 48 px, so sticky elements pin with top-12 |
| Page title block to first content | 64 px (plus or minus 1) |
| Section title to subtitle gap | 4 px, one utility, never the neighbouring size |
| Page and section subtitles | no trailing period |
| Accent colour | one token, var(--color-accent), with its light and dark values written out |
| Charts | the accent token only; bar and line combinations use one fill opacity |
| Motion | each named transition’s duration and easing (a tab indicator at 250 ms, a 120 ms linear dot) |
| New pages | the shared page shell plus the grid and recipe classes, never a hand-rolled shell |
Stage 1. Static checks (free, run first)
Greps from the repo root that must produce no output. Write one per rule the system has, and exclude the directories that are allowed to break it (a lab sandbox, generated files, vendored packages). Typical rules:
# raw <img> where the framework's image component is required
grep -rn '<img' src --include='*.tsx' --exclude-dir=generated --exclude-dir=lab
# a client directive in a file that must stay a server module (the MDX component map)
grep -n 'use client' mdx-components.tsx
# hardcoded hex in themed UI (charts, dashboards) that should use tokens
grep -rn '#[0-9a-fA-F]\{3,8\}' src/components/dashboard/
# a retired colour or class that must not come back
grep -rn 'e8590c' src/ | grep -v 'src/app/lab/'
grep -rn 'section-label mt-1\.5' src/
# an attribute the UI library does not actually expose (see Gotchas)
grep -rn 'data-selected\|data-\[selected\]' src/
# subtitle props with a trailing period
grep -rEn '(subtitle|sub)="[^"]*\."' src/ --exclude-dir=labStage 2. Code health
- During iteration: the typecheck (
npx tsc --noEmit) and the linter. - Before a push: the full build, including any prebuild convention checks. It is slow, so do not run it per tweak; run it once before the push.
Stage 3. DOM measurements (browser DevTools)
Preflight: curl -s -o /dev/null -w '%{http_code}' --max-time 2 http://localhost:3000. If
that is not 200, start the dev server in the background and read its output for the port.
Always await document.fonts.ready before reading geometry. Faces loaded with
font-display: optional mean early measurement reads fallback metrics.
3a. Title to content gap (every changed page that uses the shell). Adjust the two selectors to the shell’s title and label classes.
async () => {
await document.fonts.ready;
const h1 = document.querySelector('h1.page-title');
if (!h1) return 'FAIL: no page title';
const headerEl = h1.closest('header');
const textEls = [h1, ...headerEl.querySelectorAll('.section-label')];
const titleBlockBottom = Math.max(...textEls.map(e => e.getBoundingClientRect().bottom));
const siblings = [...headerEl.parentElement.children].filter(el => {
if (el === headerEl) return false;
const r = el.getBoundingClientRect();
return r.height > 0 && r.top >= titleBlockBottom;
});
if (!siblings.length) return 'FAIL: no content below header';
const contentTop = Math.min(...siblings.map(e => e.getBoundingClientRect().top));
return { gap: Math.round(contentTop - titleBlockBottom), expected: 64 };
}3b. Section subtitle offset (any page with section headers):
async () => {
await document.fonts.ready;
const bad = [...document.querySelectorAll('.section-title + .section-label')]
.map(el => ({ text: el.textContent.slice(0, 40), mt: getComputedStyle(el).marginTop }))
.filter(r => r.mt !== '4px');
return bad.length ? bad : 'PASS: all section subtitles 4px';
}3c. An element is on the accent token (anything coloured in the change). Compare against a probe rather than a literal string, so P3 and sRGB serialisation differences cannot fail it falsely:
async () => {
const probe = document.createElement('span');
probe.style.color = 'var(--color-accent)';
document.body.appendChild(probe);
const accent = getComputedStyle(probe).color;
probe.remove();
const el = document.querySelector('SELECTOR'); // the element under test
const val = getComputedStyle(el).color; // or .fill, .backgroundColor
return { accent, val, match: accent === val };
}3d. Both themes (only when the change touches colour). Capture the stored theme
preference, switch to the other theme the way the site does (a storage key and a reload, or
the toggle), confirm the theme class on <html>, re-run 3c, then restore the original
value and reload.
3e. Mobile pass (every layout change). Resize to 390 by 844, then:
() => ({
overflow: document.documentElement.scrollWidth > window.innerWidth,
tabsClipped: (() => { const l = document.querySelector('[role="tablist"]');
return l ? l.scrollWidth > l.clientWidth : null; })(),
})overflow must be false. tabsClipped applies on pages with a tab row; if it reports true
that is a real finding, not a false positive. Restore the desktop viewport afterwards.
3f. Sticky elements (when a page with one is touched). At rest, 3a must hold to the
sticky row; after window.scrollTo(0, 1000), the row pins under the header:
Math.round(document.querySelector('[role="tablist"]').getBoundingClientRect().top) equals
the header height, with the intended padding visible above its labels.
Stage 4. Screenshot sign-off
Only after stages 1 to 3 pass: one viewport screenshot per changed page (never a full-page capture of a long page), and the dark theme in addition only if the change touched colour. This is confirmation, not investigation. If a screenshot surprises you, a numeric check is missing above; add it.
Report format
| Check | Expected | Measured | Pass |
|---|---|---|---|
| /collection title to content | 64 px | 64 px | pass |
Gotchas
- Headless UI libraries expose their own state attributes (Base UI tabs use
data-activeandaria-selected="true"; there is nodata-selected). Style with the attribute the library really sets. - Fonts:
font-display: optionalmeans geometry read beforedocument.fonts.readyuses fallback metrics; calldocument.fonts.load()for a face before measuring text. - UI glyphs: SVG icons, never font glyphs; browsers centre glyphs differently.
- Dev bundlers can miss newly introduced arbitrary utility classes and global CSS edits in hot reload. Put dynamic values in inline styles, and hard-refresh before trusting a “broken” style.
- Chart libraries generally accept CSS custom properties in
fillandstroke; pass the token directly.
Known exceptions
Keep a list of the places that are allowed to break a rule, with the reason, so a check never “fixes” them: a lab sandbox, components that bypass the image optimizer on purpose (a lightbox clone, a hi-res path), framework-agnostic published packages, illustrative SVGs with hardware colours, generated files.