Skip to content
Dave Klein

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/skills

That 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.

InvariantValue
Fixed site header height48 px, so sticky elements pin with top-12
Page title block to first content64 px (plus or minus 1)
Section title to subtitle gap4 px, one utility, never the neighbouring size
Page and section subtitlesno trailing period
Accent colourone token, var(--color-accent), with its light and dark values written out
Chartsthe accent token only; bar and line combinations use one fill opacity
Motioneach named transition’s duration and easing (a tab indicator at 250 ms, a 120 ms linear dot)
New pagesthe 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=lab

Stage 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

CheckExpectedMeasuredPass
/collection title to content64 px64 pxpass

Gotchas

  • Headless UI libraries expose their own state attributes (Base UI tabs use data-active and aria-selected="true"; there is no data-selected). Style with the attribute the library really sets.
  • Fonts: font-display: optional means geometry read before document.fonts.ready uses fallback metrics; call document.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 fill and stroke; 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.