Design QA
Structured quality gate for UI code. Run against any component, page, or feature before shipping. Every check is binary — pass or fail. Report ONLY failures. Silence = quality.
Scanner
bash "$PI_SKILL_DIR/scripts/design-scan.sh" <target-dir> # human output
bash "$PI_SKILL_DIR/scripts/design-scan.sh" <target-dir> --json # machine output (stdout = pure JSON)
bash "$PI_SKILL_DIR/scripts/design-scan.sh" <target-dir> --critical-only # gate only Critical findings
bash "$PI_SKILL_DIR/scripts/design-scan.sh" <target-dir> --allowlist FILE # structured exceptions
bash "$PI_SKILL_DIR/scripts/design-scan.sh" <target-dir> --allow-empty # zero supported files is not an error
If $PISKILLDIR is unset, resolve the script relative to this SKILL.md's directory.
Dependencies: rg and jq required in every mode. ast-grep optional (adds structural TSX checks).
Support tiers: lexical checks cover tsx jsx ts js css scss html vue svelte astro mdx; structural checks are TSX-only (the shipped ast-grep rule set). Symlinked files are not followed.
Exit contract:
| Exit |
Meaning |
0 |
Supported files were scanned; no finding broke the active mode |
1 |
Scan completed; findings broke the active mode (any finding by default; Critical-only under --critical-only) |
2 |
Unreliable: bad arguments, missing dependency, missing target, zero supported files (without --allow-empty), tool failure, or invalid JSON |
JSON schema: status pass|fail, target, scannedFiles, support, total/critical/high/medium, advisory score, categories, issues[] (each: severity, category, file, one-based line, content, fix). On scanner error with --json: {"status":"error","errors":[…]} and exit 2.
Allowlist format (tab-separated, # comments): CATEGORY<TAB>PATH_REGEX<TAB>REASON. Category exact or *. Invalid rows exit 2.
The scanner covers only mechanical facts. Scanner silence says nothing about the judgment checks below — run the gates after the scan, in order, and report failures with line numbers and fixes. Finish with Gate 12 whenever the app can be rendered — the rendered page outranks source-level passes.
Gate 1: Anti-Slop (Critical)
Mechanical rows are scanner-automated; judgment rows apply the intent-and-repetition test — the treatment communicates state, hierarchy, focus, information, or brand meaning, and is bounded to a named role.
| Check |
Pass Condition |
| No slop gradients (auto) |
Zero purple/violet/indigo → blue/pink/fuchsia gradient combos; zero two-stop dark neutral gradients; zero gradient text on metrics/headings; zero blurred gradient orbs |
| No dead controls (auto) |
Every button/link has a real handler or destination — zero href="#", empty onClick, non-persisting toggles |
| Glass is functional |
backdrop-blur only on overlays over meaningful content, with a readable fallback — never on static cards |
| No fake proof |
Zero invented counts ("Trusted by 10,000+"), logo marquees, placeholder identities ("John Doe", "Acme"), round metrics (99.99%), fake terminal/mac-window chrome |
| No template composition |
Not centered-hero + identical card grid; not 3-4 equal big-number metric cards; not 3-tier pricing + "Most Popular" pill shipped verbatim; footer mirrors real IA |
| Stack is customized |
shadcn/Tailwind tokens, radii, and variants customized to the product — whitespace-nowrap on buttons is required, the rest of a verbatim default variant string is the tell |
| No template copy |
Zero: Effortlessly, Streamline, Revolutionize, Unlock, AI-powered (non-AI product), "It's not just X, it's Y", "Welcome to our platform", "Built with ❤️" — in rendered copy; identifiers don't count |
| Motion earns its place |
Pulse/ping only as skeleton feedback or accessible live-status; reveals differ by content, not one fade-in-up everywhere |
| No side-stripe accents (auto) |
Zero colored border-left bars on cards/callouts/items — tint or dot instead, never stripe + tint; blockquote exempt |
| Landing continuity |
Marketing pages: zero full-width border-top/bottom rules between sections (scanner SECTION_BORDER, High — allowlist app-shell seams); ≤2 background changes across the page (one emphasis panel + footer); component-internal rules exempt |
| Kickers are bounded |
One brand kicker or micro-meta label passes; the same uppercase-tracked eyebrow repeated across 2+ sections fails; numbered markers only on real sequences |
| Radius matches role |
≥24px on cards/sections/inputs fails outside a declared soft-organic/playful direction |
| Warmth is a system |
Warm neutrals pass as a coherent derived scale; a lone --cream/--paper body token fails |
| Sparklines carry data |
Real history + value + period + delta; decorative squiggles fail |
| Illustration is owned |
One consistent brand family in a named role; mixed stock/sketchy SVG fails |
| Combination tells |
2+ together fail: nested cards, identical icon-heading cells, ghost-cards (1px border + ≥16px-blur shadow), per-section background hues |
Gate 2: Typography
| Check |
How to Verify |
| Project type scale only |
Zero arbitrary font sizes: text-[px], text-[rem], font-size: outside the scale |
| Tracking within bounds |
Display/heading tracking between -0.03em and 0; uppercase micro-labels at most +0.05em; body text 0; zero tracking-tighter |
| Font is loadable |
Chosen font has a real source and license; @font-face/import resolves; stack ends in a generic family |
| Numeric data: tabular-nums |
All numbers that align or update — prices, counts, dates, table columns |
| Headings: text-balance |
<h1>–<h6> use text-balance or text-pretty |
| Line length controlled |
Body text capped at 45–75ch (max-w-prose or equivalent); CJK content ~40 glyphs with lang set |
| Max 3 font weights |
More than 3 distinct weights per view = flag |
| Web fonts are woff2 |
Owned font assets .woff2; font-synthesis: none; every used weight has a loaded file |
| Mobile inputs ≥ 16px |
Input text ≥16px on mobile; zoom never disabled to compensate |
Gate 3: Color
| Check |
How to Verify |
| No hardcoded colors |
Zero hex/rgb(/hsl(/oklch( in component markup; zero raw palette utilities (bg-purple-500 — scanner NAMED_COLOR); OKLCH lives in :root tokens |
| No pure black/white surfaces |
bg-black, bg-white, #000, #fff on containers/pages = flag |
| No gray on colored bg |
text-gray-/text-muted- on colored backgrounds = flag |
| Status colors semantic |
green=success, red=error, amber=warning, blue=info — never inverted, never the brand accent |
| Color never sole indicator |
Every status pairs with icon, text, or pattern |
| Contrast floors (WCAG 2.x) |
≥4.5:1 body, ≥3:1 large text, ≥3:1 required non-text UI (input borders, meaningful icons, focus rings) — against the actual rendered background, placeholders included. APCA is advisory only, never the pass condition |
| Accents match declared strategy |
Restrained/Committed: ≤2 accent colors per view. Full-palette and categorical data-viz: every hue maps to a named role — unnamed extras fail |
Gate 4: Spacing
| Check |
How to Verify |
| 4px grid |
All spacing multiples of 4px; zero p-[]/m-[]/gap-[*] magic values |
| Hierarchy reads |
Section gaps > group gaps > item gaps; consistent within the page |
| Inset ≠ stack monotony |
Repeated surfaces distinguish padding-inside from gap-between; not one value everywhere |
| Density consistent |
One density mode per surface; grouped controls share heights |
| Block rhythm |
Headings bind to what follows (space above > below); labels sit 4–8px from their inputs |
| No triple responsive padding |
p-2 md:p-4 lg:p-6 xl:p-8 = flag; one value per semantic context |
| No dead air |
At 1440px: zero empty bands >64px (app/data) / >128px (marketing) within one surface; zero bands >256px anywhere; height from content, not min-height + centering |
| Empty states top-anchored |
Inside a data region the message sits where the first row would; region collapses to message height |
| Section origin aligned |
Heading and its section controls share a baseline; adjacent columns start at the same y |
Gate 5: Component Reuse
| Check |
How to Verify |
| Existing primitives used |
Custom <button>/<input>/<dialog> when project equivalents exist = flag |
| No primitive mixing |
Imports from multiple UI libraries in same file = flag |
| CVA for variants |
Inline ternary chains for 3+ style variants = flag |
| data-slot attributes |
Component roots missing data-slot = flag (if project convention) |
Gate 6: Interaction States
| Check |
How to Verify |
| Five states present |
Every interactive element: default, hover (@media (hover: hover)), focus-visible ring (never removed), active, disabled |
| Async lifecycle |
Every async action: pending disables re-activation + shows status (aria-busy); success/error visible where the action happened |
| Icon-only: aria-label |
Every icon-only control |
| Destructive matches reversibility |
Reversible → immediate + undo; irreversible/high-stakes → explicit confirmation naming object and scale |
| Errors inline |
Adjacent to the trigger, not only in a toast/banner |
| Focus managed |
Dialogs set initial focus and restore focus to opener on close |
| aria-expanded |
Accordion/collapsible triggers have aria-expanded + aria-controls |
| Hit areas don't collide |
Expanded targets of adjacent controls never overlap |
| Paste not blocked |
No onPaste prevention |
Gate 7: Accessibility
| Check |
How to Verify |
| Semantic HTML |
<button> for actions, <a> for nav; no <div onClick> |
| Heading hierarchy |
h1→h2→h3, no skipping, one h1 per page |
| Alt text |
Every <img> has alt; decorative alt="" |
| Form labels |
Every input has a visible <label>, not just placeholder |
| Keyboard nav |
Logical tab order, no traps, no tabIndex > 0 |
| ARIA live regions |
Dynamic updates use aria-live="polite"/role="status" |
| Touch targets |
≥44px mobile primary controls (24px WCAG AA absolute floor) |
| prefers-reduced-motion |
All motion >200ms has a reduced path |
| h-dvh not h-screen |
Zero h-screen |
Gate 8: Product States & Edge Cases
| Check |
How to Verify |
| Four-state contract |
Every async data surface designs loading, populated, empty, error |
| Empty states typed |
First-use / cleared / no-results / error-caused get distinct copy; next-action CTA present when a real action exists |
| Error recovery |
Every error names what happened + how to recover; retry path present |
| Conditional states |
Permission-denied, offline, stale/partial designed where reachable |
| Long text handled |
truncate/line-clamp-*/break-words on names/titles; truncated material reachable in full |
| Flex/grid overflow |
min-w-0 on flex children with text; min-w-0 min-h-0 on grid children |
| Formatting |
Intl.NumberFormat/Intl.DateTimeFormat, never concatenation |
| Skeletons match layout |
Shapes mirror actual content and reserve its space |
| Data extremes |
Survives 0, 1, 1000+ items; 100+ char strings; emoji; RTL |
Gate 9: Performance
| Check |
How to Verify |
| No layout animations |
Zero animate-/transition- on width/height/top/left/margin/padding |
| No permanent will-change |
Only within animation scope |
| No animated blur |
No backdrop-blur + transition/animate together |
| Images lazy + reserved |
Below-fold loading="lazy"; async content has reserved space (zero shift) |
| Lists virtualized |
100+ items |
| Inputs debounced |
Search/filter 200–300ms |
| Cleanup on unmount |
Effects cancel subscriptions, abort fetches |
| No mount animation on defaults |
Default-state AnimatePresence initial={false} (deliberate first-load entrances exempt) |
| Content visible without JS animation |
Visibility never depends on a class-triggered transition (ships blank in hidden tabs/headless) |
| Hidden-tab timers pause |
Timed toasts/auto-dismiss pause while document hidden |
| No console.log |
Zero non-dev-gated console output |
Gate 10: Responsive
| Check |
How to Verify |
| Mobile layout works |
No horizontal scroll; content reflows |
| No hidden core features |
hidden md:block on essential functionality = flag |
| Logical CSS properties |
margin-inline-/ps- for RTL support |
| No fixed widths on text |
w-24/w-[200px] on text containers = flag |
| Zoom not disabled |
No user-scalable=no/maximum-scale=1 |
| Safe areas |
Fixed/sticky mobile elements pad with env(safe-area-inset-*) |
Gate 11: Error Resilience
| Check |
How to Verify |
| API errors by code |
401→login, 403→permission, 404→not found, 429→rate limit, 500→error surface |
| Error boundaries |
Around major sections |
| Optimistic rollback |
Failed optimistic updates revert visibly + show error |
| Offline/stale honest |
Offline states say what still works; stale data is timestamped |
Gate 12: Live Verification (agent-browser)
Static gates read source; this gate checks the rendered page.
Prerequisites: a dev/preview command exists (or a URL is supplied) and agent-browser responds. Both present → the gate is mandatory. Either missing → record Gate 12: N-A — <which prerequisite is missing and why>. N-A is never a pass; a state that should exist but cannot be exposed is a failing state check.
Viewports: 375×812 and 1440×900 always; 768×1024 when the layout has a tablet breakpoint.
States: loaded (both required viewports) — then empty, loading, error, long-content, interactive states at one representative viewport each; dark mode and reduced-motion when supported. Repeat a state at a second viewport only when responsive behavior could change its verdict.
Machine probes (binary): console/page/network errors = 0 · no horizontal overflow at 375px · one h1, ordered headings in the accessibility snapshot · rendered contrast meets Gate 3 floors · focus ring visible on every interactive element in a tab cycle · primary targets ≥44px at 375px · no dead-air bands above the runbook probe's fail tier at 1440px (judge-tier bands reviewed against Gate 4) · marketing: zero full-width section rules and ≤3 distinct section surfaces · reduced-motion emulation removes positional animation.
Screenshot review (judgment): overlap, hierarchy, blank/thin sections, truncation recovery, empty-state usefulness, dark-mode quality, visible anti-slop regressions — against Gate 1.
Exact commands, probe JavaScript, and evidence schema: READ [reference/live-verification.md](reference/live-verification.md) before running this gate.
Complete when every required state × viewport has a screenshot + console/error capture recorded, every machine probe is listed in the report with its measured result (a probe not listed was not run — the gate is incomplete), all probes pass, and no Critical visual finding remains. Attach screenshot paths and repro steps for every finding. A failure here outranks any static-gate pass — the rendered page is the product.
Report Format
## QA Report: [Component/Page Name]
### Critical (must fix before merge)
- **[Gate]: [Check]** — [File:Line] — [Issue and fix]
### High (fix soon)
- **[Gate]: [Check]** — [File:Line] — [Issue and fix]
### Medium (improve when touching this code)
- **[Gate]: [Check]** — [File:Line] — [Issue and fix]
Severity: Critical = accessibility blockers, dead controls, Gate 1 hard guardrails, Gate 12 machine-probe failures, blank/broken render. High = hardcoded colors, missing required states or recovery, spacing/type system violations, missing loading/rollback. Medium = contextual craft issues and non-blocking polish.
Quick-Check (6 items for fast PR reviews)
- No anti-slop patterns (Gate 1)
- No hardcoded colors (Gate 3)
- Focus visible on all interactives (Gate 6)
- Four-state contract on async surfaces (Gate 8)
- No layout animations (Gate 9)
- Renders clean at 1440px + 375px with zero console errors (Gate 12; N-A per its rule when no renderable target exists)