
Ux Audit
- 2.1k installs
- 946 repo stars
- Updated July 2, 2026
- jezweb/claude-skills
ux-audit is an agent skill that Walk through a live web app AS a real user to find usability + behavioural bugs that static reviews miss. REQUIRES proof.
About
Walk through a live web app AS a real user The audit is interaction first typing clicking sending watching screenshotting A static DOM sweep cannot produce a verdict Pass Critical 0 High 0 all hard gates green Interaction Manifest complete Conditional Pass Critical 0 High 0 all hard gates green but Medium Low present Fail at least one Critical or High finding OR a hard gate red Incomplete Interaction Manifest missing required entries a phase wasn t run OR the audit the audit meta check fires manifest timestamps clustered 0 5s apart screenshots fewer than 2 routes console reads fewer than 1 routes Phase 3 took 1m for an exhaustive audit Not legal to upgrade to Pass even if everything observed looked fine If the work doesn t include a complete Interaction Manifest the only legal verdict is Incomplete It looked OK is not Pass A clean Pass with implausible timings is rejected the agent must redo the audit with real interaction These auto fail the audit They cannot be
- description: "Walk through a live web app AS a real user to find usability + behavioural bugs that static reviews miss.
- Walk through a live web app AS a real user. The audit is **interaction-first** — typing, clicking, sending, watching, sc
- - **Pass** — Critical = 0, High = 0, all hard gates green, Interaction Manifest complete.
- Follow ux-audit SKILL.md steps and documented constraints.
- Follow ux-audit SKILL.md steps and documented constraints.
Ux Audit by the numbers
- 2,061 all-time installs (skills.sh)
- +59 installs in the week ending Jul 29, 2026 (Skillselion tracking)
- Ranked #557 of 16,565 AI & Agent Building skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Jul 31, 2026 (Skillselion catalog sync)
ux-audit capabilities & compatibility
- Capabilities
- description: "walk through a live web app as a r · walk through a live web app as a real user. the · **pass** — critical = 0, high = 0, all hard ga · follow ux audit skill.md steps and documented co
- Use cases
- orchestration
What ux-audit says it does
description: "Walk through a live web app AS a real user to find usability + behavioural bugs that static reviews miss. REQUIRES proof of interaction (typing, clicking, sending, observing) before any
Walk through a live web app AS a real user. The audit is **interaction-first** — typing, clicking, sending, watching, screenshotting. A static DOM sweep cannot produce a verdict.
- **Pass** — Critical = 0, High = 0, all hard gates green, Interaction Manifest complete.
npx skills add https://github.com/jezweb/claude-skills --skill ux-auditAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2.1k |
|---|---|
| repo stars | ★ 946 |
| Security audit | 2 / 3 scanners passed |
| Last updated | July 2, 2026 |
| Repository | jezweb/claude-skills ↗ |
When should an agent use ux-audit and what problem does it solve?
Walk through a live web app AS a real user to find usability + behavioural bugs that static reviews miss. REQUIRES proof of interaction (typing, clicking, sending, observing) before any verdict — a sw
Who is it for?
Developers invoking ux-audit as documented in the skill source.
Skip if: Skip when requirements fall outside ux-audit documented scope.
When should I use this skill?
Walk through a live web app AS a real user to find usability + behavioural bugs that static reviews miss. REQUIRES proof of interaction (typing, clicking, sending, observing) before any verdict — a sw
What you get
Outputs aligned with the ux-audit SKILL.md workflow and stated deliverables.
- Per-page violation arrays
- Severity-mapped audit log entries
By the numbers
- Catches roughly 80% of structural accessibility bugs manual keyboard walks miss
- Completes axe-core scans in under 1 second per page
Files
UX Audit
Walk through a live web app AS a real user. The audit is interaction-first — typing, clicking, sending, watching, screenshotting. A static DOM sweep cannot produce a verdict.
Verdict states
The audit ends in exactly one of:
- Pass — Critical = 0, High = 0, all hard gates green, Interaction Manifest complete.
- Conditional Pass — Critical = 0, High = 0, all hard gates green, but Medium/Low present.
- Fail — at least one Critical or High finding, OR a hard gate red.
- Incomplete — Interaction Manifest missing required entries, a phase wasn't run, OR the audit-the-audit meta-check fires (manifest timestamps clustered < 0.5s apart, screenshots fewer than 2 × routes, console reads fewer than 1 × routes, Phase 3 took < 1m for an exhaustive audit). Not legal to upgrade to Pass even if everything observed looked fine.
If the work doesn't include a complete Interaction Manifest, the only legal verdict is Incomplete. "It looked OK" is not Pass. A clean Pass with implausible timings is rejected — the agent must redo the audit with real interaction.
Hard gates
These auto-fail the audit. They cannot be downgraded.
| Gate | Threshold | Severity if violated |
|---|---|---|
| Console errors during walkthrough | > 0 | Critical |
| Console warnings during walkthrough | > 0 | High |
| Network 5xx | > 0 | Critical |
| Network 403 / 404 on authenticated pages | > 0 | High |
| Layout collapse at any tested viewport / pane combo | > 0 | High |
| axe-core Critical violations on any audited page | > 0 | Critical |
| axe-core Serious violations on any audited page | > 0 | High |
| LCP on representative route (pragmatic budget) | > 4.0s | High |
| CLS on representative route | > 0.25 | High |
| INP on representative route | > 500ms | High |
| Required Interaction Manifest entry missing | n/a | Incomplete |
| Manifest median gap between entries < 0.5s | n/a | Incomplete (didn't actually interact) |
A console warning is High minimum. A 5xx is Critical automatically. There is no "Medium console error" — that category does not exist in this skill.
axe-core thresholds are run per page (>1 violation on any single page fails). Performance thresholds are run once on a representative route (per-page is overkill); pragmatic budget is well above broken, well below CWV-strict. Full thresholds + rationale in references/performance-budget.md. Full a11y wiring + severity mapping in references/a11y-automation.md.
Allowlist for known noise
Some apps have known-noisy console / network categories that aren't bugs (Sentry info logs, browser-extension chatter, expected 401 on auth-check probes). Read the audit-config file before Phase 3 and apply its allowlist. Path fallback: .jez/audit-config.yml → audit-config.yml → .audit/config.yml. Allowlisted entries stay in the Interaction Manifest but suppress from findings. Verdict block shows both raw and allowlisted: Console warnings: 3 (1 allowlisted, 2 reportable).
Default without a config: every console error / warning is a finding. Format, semantics, surface overrides in references/audit-config.md.
Phases (in order)
1. Pre-flight — Persona Lock, browser tool, URL, viewport, capability tests 2. Discovery — sitemap, thread inventory, element inventory 3. Walkthrough — Interaction Manifest, threads, element exhaustion, multi-pane stress, first-time-user lens, live interaction smoke 4. Polish — visual polish sweep, component perfection checklist 5. Stress — scenario battery (11 scenarios) + extended stress recipes 6. Verdict — verdict state, hard-gate scorecard, perfection roadmap, findings with reproduction 7. Fix-and-verify — patch findings, re-walk affected slices, update report
For a 30-second pre-deploy check, use the dogfood drill at the bottom of this file — a project-level rule, not a skill invocation.
Phase 1 — Pre-flight
Five gates. Stop if any fail.
1. Persona Lock
The audit needs a persona before anything else. Without a locked persona, findings drift toward generic "looks fine".
Source the persona in this order:
1. Argument — if the user provided one ("ux audit as a busy insurance broker") 2. Project personas — read existing persona files (fallback chain: .jez/audit-personas/<slug>.md → docs/personas/<slug>.md → personas/<slug>.md → .audit/personas/<slug>.md). The first match wins. 3. Ask once — "Who uses this app and what are they trying to get done?"
Capture: role, tech comfort, time pressure, emotional state, device context. A good persona predicts what they'd miss ("A receptionist between phone calls won't scroll below the fold").
Lock the persona by writing the chosen persona at the top of the audit report. Every finding must be defensible from this persona's perspective. If you catch yourself thinking "a developer would know..." — stop. Your persona doesn't.
Always also run the first-time-user lens (mandatory, see Phase 3) on every multi-page feature, even when the explicit persona is something else. It's the single biggest blind spot for AI / internal tooling.
See references/persona-lock.md for the persona library and writing protocol.
2. Browser tool
| Target | Tool | Why |
|---|---|---|
| Authenticated app | Chrome MCP | Uses your real logged-in Chrome session — OAuth, cookies, RBAC just work |
| Public site | Playwright MCP | No login needed |
| Neither available | Stop | Ask the user to connect Chrome MCP or install Playwright |
Do not silently fall back to a fresh Playwright session for an authenticated app — the audit is worthless if you can't log in. If Chrome MCP isn't connected, stop and say: "Open Chrome, click Connect in the Claude extension, then rerun."
See references/browser-tools.md for commands.
3. URL
Prefer the deployed/live version — real auth, real latency, real CDN and CORS. Discovery: read project CLAUDE.md / README for "URL" → check stack config (wrangler.jsonc, vite.config.ts, next.config.js, config/database.yml, manage.py, .env APP_URL, wp-config.php) → lsof -i :PORT (common: 5173 Vite, 3000 Next/Rails, 8000 Django/Laravel, 8787 Wrangler, 4321 Astro) → ask. Stack-specific guide in references/project-adaptation.md. Use local only if the user asks or the feature isn't deployed.
4. Viewport
Pin the window at 1440×900 to start. Phase 3 multi-pane stress tests 375 / 768 / 1024 / 1280 / 1440 / 1920. Do not go above 2000px — it breaks the harness.
5. Capability tests
Before any walkthrough, prove the tools work — one call each:
- One screenshot
- One console read
- One network request inventory
- One element selector query
If any fail, stop and fix the connection before starting the audit. An audit blind to console output is worthless.
Phase 2 — Discovery
Sitemap crawl
Build the complete page inventory before auditing any page.
1. Router config — read the app's route definitions (React Router, TanStack Router, Next.js app dir) 2. Nav crawl — click through every section and sub-section of the sidebar/menu 3. Deep links — URLs in CLAUDE.md, docs, or a prior audit report
One line per route, with purpose: /app/clients — list of clients, search, add new.
Thread inventory
Identify 3–5 real tasks that make up a user's day. These are the spines of the audit.
How to find them: ask the user, read CLAUDE.md / README, infer from top-level nav. Examples: insurance broker → renew a policy, create a client, work today's queue. Project management → morning triage, update a task, send a client summary. Spaces / chat app → create a space, send a message, open a thread.
Element inventory
For each route as you reach it, list every interactive element. Build inventories lazily — per-page as you traverse, not all up-front. This drives the coverage metric: "tested 29 of 31 elements on /app/clients".
Phase 3 — Walkthrough (the audit itself)
Interaction Manifest (MANDATORY)
Every walkthrough produces a manifest. Without it, verdict = Incomplete.
INTERACTION MANIFEST — /dashboard/spaces/marketing-pod
Persona: SME owner, time-pressed, low tech comfort
[✓] 14:32:01 Typed "@assistant test" into message input (textarea[placeholder*="message"])
[✓] 14:32:03 Picked @assistant from autocomplete (li[data-mention-id="assistant"])
[✓] 14:32:05 Clicked Send (button[aria-label="Send"])
[✓] 14:32:06 Verified input cleared within 1000ms (textarea.value === "")
[✓] 14:32:08 Verified message appeared in transcript ([data-message-id] count +1)
[✓] 14:32:12 Opened thread on the message ([data-thread-trigger])
[✓] 14:32:13 Verified main column width ≥ 200px after thread open (getBoundingClientRect().width)
[✓] Console read after each step (0 warnings, 0 errors)
[✓] Screenshot before + after each step
[✓] Network requests inventoried (0 5xx, 0 403/404 on auth pages)Every checkbox needs a tool call (a click, a screenshot, a console read) and the timestamps + selectors are logged. The agent cannot produce a "Pass" report without a complete manifest.
Required entries per page audited:
- ≥ 1 input typed into (real text, not just clicked)
- ≥ 1 primary action triggered (Send / Save / Submit / Create / Publish — whichever fits)
- ≥ 1 modal or detail pane opened
- ≥ 1 console read after the primary action
- ≥ 1 screenshot before AND after the primary action
- Verification of expected post-action state (input cleared, success toast, route change, list updated)
Full template + replay protocol in references/interaction-manifest.md.
Thread Traversal
For each thread:
1. Start from the app entry point — not mid-thread. Real users land at / or /dashboard. 2. Walk as the persona — if they'd skim, skim. If they'd misread a label, misread it. Note hesitations. 3. Screenshot every state change — default → hover/focus → active → after click → after load → confirmation. The filmstrip is the evidence. 4. Track the cost — click count, decision points, dead ends, interrupt recovery (close tab at step 3, return at step 4 — did state survive?) 5. Hand screenshots to a sub-agent for review at the end of each thread.
At the end of each thread, answer (as the persona): Did it end clearly? Would I come back? One thing to make this twice as easy?
See references/walkthrough-checklist.md and references/workflow-comprehension.md.
Element Exhaustion
For each route, work the inventory. Skip elements already exercised by thread traversal. Detail in references/walkthrough-checklist.md.
For every list/table, test at volumes 0 / 1 / 100 / 1000+ if data permits.
Multi-Pane Stress (mandatory for apps with collapsible UI)
Pane combinations hide the worst layout bugs — the 2026-04-29 vertical-text-in-spaces bug only manifested at 1024-1280px with all three panes open. For apps with sidebars / members / threads / drawers / sheets, run the matrix: 1920 / 1440 / 1280 (high-bug) / 1024 (catastrophic-bug) / 768 / 375 × all-open, 2-pane, 1-pane, default. For each combination: scroll the longest content, capture a screenshot, run layout-detection JS for overflow / clipping / vertical-text-stacks. Full matrix + JS snippets + automation in references/multi-pane-stress.md.
First-time-user lens (mandatory)
Beyond the locked persona, every multi-page feature must pass the first-time user check. This catches the single biggest UX failure mode in internal/AI tooling: features built by the people who designed them work for them, but a brand-new user landing on the same screen has no idea what any of the controls mean.
Adopt the persona of someone signing in to this app for the very first time, with no prior context, no source access, no internal documentation. For each screen ask:
| Question | What it catches |
|---|---|
| Could I complete the task without reading the code or docs? | Hidden technical knowledge baked into the form |
| Are field labels in plain language, not internal vocabulary? | agentClass, slug, webhook_id leaking into UI |
| Do dropdowns / pickers show what each option does, not just an ID? | Snake_case enums, raw class names, opaque slugs |
| Are defaults sensible enough to keep them and move on? | Required fields with no defaults, mandatory ID inputs |
| Is there a discoverable list of valid values when something needs to be entered? | Free-text inputs where a combobox should be |
| If I'd say "click Skip" because I don't understand a setting, that's a UX bug. | Optional-but-confusing settings exposed as primary inputs |
When the lens fires, log a finding even if the screen technically works. Common fixes: replace text inputs with pickers, surface metadata, auto-derive values, hide internal IDs under "Advanced", add inline guidance.
A screen passes the lens when a brand-new persona could complete the task without back-channel help.
Live Interaction Smoke
Code reading verifies a button exists and has an onClick. It does not verify clicking actually does something observable. For every interactive control on every page:
1. Click it. Pointer moves, element highlights, click lands. 2. Watch the Network tab. Did a request fire? To the right URL? Correct method + body? 3. Watch the DOM. Did something visibly change — new element, removed element, state transition? 4. If nothing changed in (2) or (3), that's a bug.
Known silent-failure controls (Approve/Deny on tool-call cards, OAuth-in-dialog popup-blocked, async-validation forms, optimistic-UI delete, off-by-one pagination, filter chips with stale TanStack Query, Reply/Forward without Message-ID): see references/live-interaction-smoke.md for the full silent-failure catalogue and SDK contract checks (@ai-sdk/react, better-auth, TanStack Query, React Router v7, Radix Dialog, zodResolver).
Round-trip Workflow Integrity (mandatory)
Covered by Scenario 10 in the battery (Phase 5). Run it as part of Phase 3 walkthrough too — every A→B→A flow encountered while traversing threads gets the round-trip check before moving on. Full protocol in references/round-trip-workflows.md.
Responsive Sweep
Layout-detection JS at every width (overflow, clipping, invisible text). Capture transition points. Combined with multi-pane stress above for full coverage.
Auth-expired mid-audit
If a navigation or API call returns 401/403 on a previously-authenticated route, the session dropped. Don't silently re-auth — every subsequent observation is corrupted. Stop, capture the breaking step (silent expiry is itself a finding), terminate with verdict Incomplete, recommend re-auth + restart. Full protocol + finding criteria in references/auth-expired-handling.md.
Phase 4 — Polish
Visual Polish Sweep
A page-by-page micro-polish pass covering ten AI-tell categories: optical centring, nested border-radius rule, off-scale spacing, vibe greys, border-weight drift, drop-shadow direction, animation timings off canonical, hover-delta calibration, underline / uppercase letter-spacing, symmetry vs editorial pacing.
Plus a per-component optical pass for buttons, badges, inputs, dropdowns, cards, tabs, avatars, toasts.
Full protocol with DevTools workflow, severity guide, and reference apps for calibration in references/visual-polish.md.
Component Perfection Checklist
Component-level granularity that page-level audits miss. Six categories, each with concrete yes/no checks that need proof artefacts:
1. Buttons & Triggers — state clarity, intent matching, micro-copy, loading state, hierarchy 2. Inputs & Forms — persistent labels, masks, inline validation, error clarity, defaulting 3. Navigation & Hierarchy — Where am I?, click depth, search logic, sticky headers 4. Visual Coherence — icon consistency, empty states, border radii, contrast ratio 5. Mobile & Touch — tappable surface (≥ 48×48), keyboard optimisation, swipe gestures 6. Performance & Feedback — skeleton screens, success toasts, confirmation modals only for high-stakes
Plus six visual states per major component (default, skeleton, empty, partial, error, disabled) — skeleton-on-blank is not a skeleton.
Each checkbox in the report cites a proof artefact (screenshot, console line, DOM selector, code reference). No proof = doesn't count.
Full checklist in references/perfection-checklist.md.
Automated accessibility (axe-core, mandatory)
Manual keyboard-only walks (Scenario 5) catch focus traps and tab-order. They miss ~80% of structural a11y bugs (heading skips, hover/focus contrast failures, missing aria-labels, role mismatches). axe-core covers that 80% in <1 second per page.
For every audited page:
1. Inject axe-core via CDN (one script tag). 2. Run axe.run() after the page settles. 3. Map violations → audit findings (axe critical → audit Critical, axe serious → audit High, etc). 4. Hard-gate: > 0 axe Critical OR > 0 axe Serious on any page = audit Fails.
Total runtime for a 16-route audit is ~16 seconds. Allowlist via the project's audit-config (path fallback chain in references/audit-config.md) for builder-mode reference pages (Components, Style guide). Full snippet, severity mapping, and findings format in references/a11y-automation.md.
Performance budget (pragmatic, mandatory)
Run the Performance API capture once on a representative route (the page real users hit most — usually dashboard or main work surface). Pragmatic thresholds (LCP < 4.0s, CLS < 0.25, INP < 500ms) sit well above broken and well below Google's CWV "Good" tier. CWV-strict is for landing pages and marketing teams; for app interiors that real users sit inside, pragmatic is the right bar.
Add to verdict block. Hard-gate any threshold breach. Diagnose with Chrome DevTools Performance trace (large hero, late font, heavy click handler, hydration cost). Full measurement snippet, throttling spec, and common offenders in references/performance-budget.md.
Phase 5 — Stress
Scenario Battery (11 scenarios)
All eleven, always. They catch what screen-by-screen testing misses. Full protocols in references/scenario-tests.md.
1. First Contact — figure out the app with zero prior knowledge, write a 2-min plain-English guide to each thread. 2. Interrupted Workflow — start a task, close the tab, refresh, navigate away mid-form. Does state survive? 3. Wrong Turn Recovery — deliberately click wrong. How many clicks to recover? 4. Returning User — repeat a thread. Faster? Shortcuts? Can you tell what changed since last visit? 5. Keyboard Only — every thread keyboard-only. Focus visible, tab order logical, Escape closes. 6. Heavy Data — seed 500+ records. Lists virtualise, search returns the right thing, filters narrow. 7. Destructive Confidence — every delete/send/publish/pay/share: consent clear, copy specific, undo available. 8. Second User (Role) — restricted role (viewer not editor, client not staff). Read-only views, permission errors. 9. Lifecycle Position — same role at user #1 (founder), #2 (first invitee, partial state), #N (later joiner, populated workspace). Each sees a different reality. 10. Round-Trip Workflow Integrity — every A→B→A flow: complete mutation on B, verify A reflects new state on return without reload. Discoverable back affordance. Header badges update. The single biggest "the project is just empty when I go back" source. 11. Data Seasoning — Day 0 / 1 / 7 / 30 seed horizons. Time-shaped data catches what quantity-only seeds miss: time dividers, recency sort, cron-fired side effects, notification badge overflow, search-with-history performance, chart bucketing. Skip only if no time-distributed data exists.
Extended Stress Recipes
Beyond scenarios, run every relevant recipe in references/stress-test-recipes.md:
| Stress | What it catches |
|---|---|
| Empty / saturated / long content | Edge layouts AI rarely sees during dev |
| Race conditions (double-click, fast-type-then-blur, slow network) | Optimistic UI bugs, debounce failures |
| Slow network (3G throttle) | Loading states, skeleton rhythm, timeout UX |
Reduced motion (prefers-reduced-motion: reduce) | Animations that ignore the preference |
| i18n (long German, RTL Arabic, CJK widths) | Layout assumptions about text length |
| Offline mode | Retry / queue / dirty-state UX |
| Print stylesheet | Forgotten media query |
| High-contrast mode | Forced-colors media query handling |
| Real-flavour data battery (mandatory for any form-accepting app) | Validation that strips characters silently (apostrophe, accents, RTL); length truncation without warning; SQL/XSS canaries not escaped; file uploads that don't fit (.heic, 8000×8000 PNG, 50MB PDF). AI-built UIs are notoriously dev-data clean. |
Phase 6 — Verdict
Verdict block (mandatory at the top of the report)
═══════════════════════════════════════════════════════════
VERDICT: [Pass / Conditional Pass / Fail / Incomplete]
Persona: [locked persona slug]
Surfaces audited: N / M routes
Interaction Manifest: complete / incomplete (X of Y required entries)
Hard Gates: console errors [N], warnings [N], network 5xx [N], 403/404 auth [N], layout-collapse [N], axe Critical [N], axe Serious [N] (all must be 0; allowlisted counts shown in parens)
Performance (on /[route]): LCP [N]s / CLS [N] / INP [N]ms — thresholds 4.0s / 0.25 / 500ms
Findings:
Critical: [count] High: [count] Medium: [count] Low: [count]
Self-critique pass (sub-agent): Drafted: [N] Kept: [N] Generic: [N] Duplicate: [N]
Time per phase: Phase 3 [N]m / Total [N]m (Phase 3 ≥ 5m for exhaustive)
Manifest plausibility: [N] entries (≥ 6/route), median gap [N]s (< 0.5s = Incomplete), [N] screenshots (≥ 2/route)
TOP 5 (ranked by impact × ease, senior-designer pick):
1-5. [F-id] Title — one-sentence reason this edges out the others
═══════════════════════════════════════════════════════════Top 5, Self-critique pass, and the Hold-this-in-your-hands closing paragraph (after Phase 7) are mandatory. Without them the verdict is Incomplete. Full discipline + format + anti-patterns in references/audit-output-discipline.md.
Self-critique pass (mandatory before publishing)
After the findings draft, dispatch a fresh sub-agent with the draft list and this prompt:
"Read these audit findings. For each, mark KEEP / GENERIC / DUPLICATE. KEEP = specific to this app, this persona, this surface. GENERIC = would apply to any web app. DUPLICATE = same root cause as another finding. Drop GENERIC and DUPLICATE before publishing."
A fresh sub-agent works because the original drafter is invested in its own output. Self-critique done in-context tends to defend rather than prune. Log the pass: Drafted: 23 Kept: 14 Generic: 5 Duplicate: 4.
Audit-the-audit triggers (non-negotiable, auto-flip to Incomplete)
| Signal | Implies |
|---|---|
| Phase 3 took < 1m / manifest first→last span < 5m for exhaustive | Walkthrough skipped or rushed |
| Median gap between manifest entries < 0.5s | Entries batch-emitted, no real interaction |
| Screenshots fewer than 2 × routes / console reads fewer than 1 × routes | Pages weren't actually checked |
| Top 5 missing or padded with filler slots | Discipline broken (see audit-output-discipline.md) |
| Self-critique pass not logged | Filler not pruned |
| Findings use "Suggested fix" / "Consider X" / "Improve Y" | Filler-shaped patches, not committable |
[✓] PASS rows lack one-sentence proof + artefact | Vibe PASS |
| Hold-this-in-your-hands paragraph missing | No holistic judgement applied |
Findings format (mandatory per finding)
Every finding must include: ID (severity-letter + number), Layer (Architecture / Interaction / Visual / Feedback / Delight), Severity, Surface (route + viewport + panes), Persona, Reproduce (numbered steps), Observed, Expected, Evidence (screenshot paths + console / network captures), Suspected location (file:line), Smallest possible patch (concrete + committable — not "Suggested fix" / "Consider X"). A finding without reproduction + evidence + suspected location is rejected. Filler patches ("improve X", "consider Y", "make Z better") get flipped to Incomplete by the self-critique pass. Worked example + Five-Layer Hierarchy in references/report-template.md and references/perfection-checklist.md. Discipline rules in references/audit-output-discipline.md.
Hold this in your hands (mandatory closing paragraph)
Every audit ends with one paragraph, no template, that answers: if this app were a physical object, would I want to hold it? This is the one place where vibe is the point — the holistic judgement no checklist surfaces. Format + worked examples in references/audit-output-discipline.md.
Perfection Roadmap (mandatory)
Group findings into:
- Quick Wins (24-48h) — micro-copy, hover states, contrast fixes, single-line CSS adjustments
- Structural (1-2 weeks) — primitive replacements, route restructures, multi-pane refactors
- Advanced Polish (post-launch) — micro-animations, skeleton variations, personalised empty states
Full report structure in references/report-template.md.
Phase 7 — Fix-and-verify
After the report, offer the loop:
"Found N Critical and M High issues. Fix them now and re-verify?"
If yes: 1. Group findings by file/area 2. Patch each one 3. Re-walk just the affected slice (not the whole app) — including the original interaction that surfaced the bug, with a fresh screenshot 4. Update the report: mark ✓ fixed, ✗ still present, or ⚠ new issue found 5. Close with a "fixed in this session" summary
Closes the loop in one session instead of waiting for tomorrow's audit.
Cross-reference with ux-extract and brains-trust
If a pattern library exists (fallback: .jez/artifacts/ux-extracts/<ref>.md → docs/ux-extracts/<ref>.md → audits/extracts/<ref>.md), read it before starting and use it as the bar for findings.
After the verdict, optionally run dev-tools:brains-trust for a second-opinion review (every 4-6 weeks). When merging findings back, dedup by `(reproduction-steps, suspected-location)` — same bug from a second model is one finding with two confirmations, not two findings. Add a Confirmed by: line. Don't append the second report verbatim; produces noise + inflates severity.
The 30-Second Dogfood Drill (project-level rule)
The audit is heavy. For per-change pre-deploy checks, recommend a project rule in CLAUDE.md:
Before declaring any UI change "done", run the 30-second dogfood drill:
1. Open the affected page
2. Type into any input
3. Click the primary action
4. Watch the next state for 2 seconds
5. Open a related view (thread, modal, detail)
6. Read the console
If any step shows unexpected behaviour, the change isn't done.
Six steps, ~30 seconds. Catches behavioural bugs that surface immediately. Pair with the full ux-audit weekly.
Playwright killer-flow tests
Audits find what's broken now. Tests prevent regressions. Recommend writing 10-15 Playwright tests for the killer flows — see references/playwright-killer-flows.md for starter examples (input clears after send, no console warnings on mount, message column width ≥ 200px after thread open, @-mention exactly one pill, etc.). Run on every deploy via CI.
Autonomy
- Just do it: Navigate, screenshot, read pages, inject layout-detection JS, submit forms with fake test data, write the report file, dispatch screenshot-review sub-agents.
- Ask first: Destructive actions (delete, send, publish, pay). For Destructive Confidence testing, ask once before running that scenario.
- Stop and confirm: Anything that emails / notifies external people.
Execution discipline
1. Drive the audit from the main session, not a sub-agent. Cross-interaction state lives in the session that's been watching. A fresh sub-agent starts cold and misses second-order findings. 2. Use the browser tool directly. Chrome MCP or Playwright MCP from the main session. Don't hand screenshots to a fresh agent for opinions. 3. Loop to exhaustion with variations. After each pass, generate a new angle (different persona, different workflow, different input volume, different starting point). Stop only when a full pass produces no new findings.
For audits expected to run > 30 minutes, set up a 15-min /loop check-in alongside the main session — it journals findings, grounds the session, and provides a natural termination signal. See references/long-running-check-in-pattern.md.
Reference files
| When | Read |
|---|---|
| Cross-skill output discipline (Top 5, self-critique, smallest-patch, proof-PASS, hold-this) | references/audit-output-discipline.md |
| Project adaptation — non-default stacks (NextAuth/Lucia/Devise/Django auth, Prisma/TypeORM/ActiveRecord seeds, WordPress/Rails/Django URL discovery, persona library by app type) | references/project-adaptation.md |
| Persona library + writing protocol + persona-overload pattern | references/persona-lock.md |
| Auth expiry mid-audit — protocol + headless test-auth resumption | references/auth-expired-handling.md |
| Data seasoning horizons (Day 0 / 1 / 7 / 30) + project seed-script architecture | references/data-seasoning.md |
| Audit-config allowlist format + semantics + surface overrides | references/audit-config.md |
| Interaction Manifest template + replay protocol | references/interaction-manifest.md |
| Multi-pane stress matrix + automation snippets | references/multi-pane-stress.md |
| Per-screen evaluation questions, layout-detection JS | references/walkthrough-checklist.md |
| Wayfinding, mental model, page-to-page continuity | references/workflow-comprehension.md |
| Full protocol for each of the 11 scenarios | references/scenario-tests.md |
| Extended stress recipes (race, slow network, reduced motion, i18n) | references/stress-test-recipes.md |
| Component-level perfection checklist (6 categories + 6 states) | references/perfection-checklist.md |
| AI-tell catalogue, optical centring, design-token discipline | references/visual-polish.md |
| Silent-failure controls + SDK contract checks | references/live-interaction-smoke.md |
| Playwright killer-flow test starters | references/playwright-killer-flows.md |
| Report format, verdict block, severity rubric, reproduction-step format | references/report-template.md |
| Browser tool commands and viewport notes | references/browser-tools.md |
| Round-trip workflow integrity (A→B→A pattern) | references/round-trip-workflows.md |
| Automated accessibility (axe-core injection + severity mapping) | references/a11y-automation.md |
| Pragmatic performance budget (LCP/CLS/INP via Performance API) | references/performance-budget.md |
Long-running audit supervision via 15-min /loop | references/long-running-check-in-pattern.md |
Tips
- Every hesitation is a finding. If you paused to figure out what to click, that's friction worth reporting.
- Use the eyedropper liberally. Single fastest way to find vibe greys, off-token colours, design-system drift.
- Coverage is arithmetic. Inventoried ÷ tested. Publish the ratio in the report.
- Sub-agents for screenshot review and write findings incrementally. Don't drive the browser and analyse 200 screenshots in one loop. The report file is cheaper memory than your context.
Automated Accessibility (axe-core)
Manual keyboard-only walks (Scenario 5) catch focus traps and tab-order problems. They miss roughly 80% of structural a11y bugs — heading skips, contrast at hover/focus state, missing aria-labels, dynamic-content announcements, role mismatches. axe-core covers that 80% in <1 second per page.
This is mandatory in Phase 4 (Polish) for every audited page.
The protocol
For every page audited:
1. Navigate to the page. 2. Inject axe-core (single CDN script tag). 3. Run axe.run() in the browser context. 4. Capture violations array. 5. Map severity → audit severity (table below) and log findings.
// In the browser eval / playwright-cli / chrome MCP
await page.evaluate(async () => {
// Inject axe-core if not present
if (!window.axe) {
await new Promise((resolve, reject) => {
const s = document.createElement('script')
s.src = 'https://cdnjs.cloudflare.com/ajax/libs/axe-core/4.10.0/axe.min.js'
s.onload = resolve
s.onerror = reject
document.head.appendChild(s)
})
}
const results = await window.axe.run()
return {
violations: results.violations.map(v => ({
id: v.id,
impact: v.impact,
description: v.description,
help: v.help,
helpUrl: v.helpUrl,
nodes: v.nodes.length,
sample: v.nodes[0]?.html?.substring(0, 200),
})),
incomplete: results.incomplete.length,
passes: results.passes.length,
}
})Severity mapping
axe reports impact per violation. Map to audit severity:
axe impact | Audit severity | Hard-gate? |
|---|---|---|
critical | Critical | YES — audit auto-fails |
serious | High | YES — audit auto-fails |
moderate | Medium | No — finding, not gate |
minor | Low | No — finding, not gate |
Hard-gate threshold
Add to the SKILL.md hard gates table:
| Gate | Threshold | Severity if violated |
|---|---|---|
| axe-core Critical violations | > 0 | Critical |
| axe-core Serious violations | > 0 | High |
A page with > 0 axe Critical or > 0 axe Serious cannot pass the audit. axe Moderate / Minor accumulate as Medium / Low findings respectively.
Allowlist for known-noise
Some axe rules fire on intentional design choices that aren't real bugs (e.g. landmark-one-main on a chrome-less embed page, region on a wrapper that intentionally has no landmark). Allowlist via .jez/audit-config.yml:
axe:
ignore_rules:
- landmark-one-main # iframe-style embed pages
- region # legacy wrapper, refactor scheduled 2026-Q3
ignore_pages:
- /dashboard/components # builder-mode, intentional
- /dashboard/style-guide # builder-mode, intentionalDefault with no config: every axe Critical / Serious is a finding. Allowlist is opt-in.
Findings format
Each axe violation becomes a finding in the report:
ID: H-N
Layer: Visual / Interaction (a11y)
Severity: High (axe Serious)
Surface: /dashboard/inbox
axe rule: color-contrast
Description: Elements must have sufficient color contrast (WCAG AA: 4.5:1 normal, 3:1 large)
Help: https://dequeuniversity.com/rules/axe/4.10/color-contrast
Nodes affected: 3
Sample: <button class="btn-secondary">Mark all read</button>
Computed contrast: 3.2:1 (text-muted-foreground on bg-muted)
Required: 4.5:1
Reproduce:
1. Navigate to /dashboard/inbox
2. Inspect "Mark all read" button (or any .btn-secondary)
3. Read computed colour ratios in DevTools
Suspected location: src/components/ui/button.tsx — secondary variant; or the
--muted-foreground token in src/styles/globals.css
Suggested fix: bump --muted-foreground from oklch(0.55 0 0) to oklch(0.45 0 0)
or change secondary variant to use --foreground.What axe doesn't catch
axe is structural. It misses:
- Whether the screen-reader announcement actually conveys meaning
- Whether dynamic content (toasts, live updates) announces to AT
- Whether keyboard tab-order makes sense (axe checks focusability, not order quality)
- Whether copy is comprehensible to assistive tech users
Those still require Scenario 5 (Keyboard Only) + manual screen-reader testing for high-stakes apps. Don't drop those in favour of axe — combine.
Run once per page
Don't loop axe per element. One axe.run() per page, after the page has settled (wait for networkidle or 1.5s). Re-run after major state changes (modal opened, drawer expanded) since aria attributes shift.
For a 16-route audit, total axe runtime is ~16 seconds. Cheap.
Skipping pages
For builder-mode reference pages (Components, Style guide), axe is overkill — the page exists to display every component variant including disabled / error / contrast-test states that intentionally fail strict rules. Add to ignore_pages in the audit config.
For genuinely user-facing pages, no skipping. Even auth pages (sign-in, sign-up) audit cleanly.
Companion: keyboard-only manual sweep
axe + Scenario 5 (Keyboard Only) is the right pair. axe catches the structural 80%; Scenario 5 catches the experiential 20% (tab order quality, focus visibility under custom themes, escape-key behaviour, focus trap in modals).
If you only have time for one, do axe (more bugs per minute). If the app is high-stakes (legal/medical/finance), do both.
Last Updated: 2026-04-30
Audit Config — Allowlist for known noise
Some apps have known-noisy console / network categories that aren't bugs:
- Sentry info logs in dev (
[Sentry] DSN not configured) - Third-party CDN deprecation chatter the team can't fix
- Expected 401s on auth-check probes (e.g.
GET /api/auth/get-session 401on first page load) - Browser-extension chatter from Lighthouse / React DevTools / Vue DevTools
- Service-worker registration warnings in dev mode
Without an escape hatch the audit drowns the report in non-findings, and the agent ends up downgrading legitimate hard gates because "this looks like the usual noise". v2's allowlist is the principled escape hatch: opt-in per project, transparent in the verdict, easy to audit.
File location
The audit reads the first match in this order, before Phase 3 starts:
1. .jez/audit-config.yml (Jezweb convention) 2. .jez/audit-config.json 3. audit-config.yml (project root) 4. audit-config.json 5. .audit/config.yml (hidden-folder fallback) 6. .audit/config.json
If no file is found, default behaviour applies: every console error / warning is a finding, every 4xx on an auth page is a finding. The allowlist is opt-in per project, not a global escape hatch.
For projects using a different convention (e.g. quality/audit-config.yml or tests/audit/config.yml), the audit honours whatever path is documented in the project's CLAUDE.md or README — the fallback chain above is for projects without an explicit convention.
File format
# .jez/audit-config.yml
console_allow:
- "[Sentry] DSN not configured" # dev-only info log
- "Lighthouse Tools is loaded" # browser extension chatter
- "/^Download the React DevTools/" # leading + trailing slash = regex
- "[HMR] Waiting for update signal" # vite dev-mode noise
network_allow:
- "GET https://o*.ingest.sentry.io" # Sentry probe — 401 expected when DSN missing
- "GET /api/auth/get-session 401" # unauth-on-load probe pattern
- "/^GET .*\\.hot-update\\.json/" # vite HMR check requests
# Optional — affordable noise floors per surface
surface_overrides:
- surface: "/dashboard/admin"
console_warnings_allowed: 0
note: "Admin pages must stay clean — no exceptions"
- surface: "/dashboard/dev-playground"
console_warnings_allowed: 5
note: "Dev-only sandbox, intentionally chatty"JSON equivalent (same keys, same semantics):
{
"console_allow": [
"[Sentry] DSN not configured",
"/^Download the React DevTools/"
],
"network_allow": [
"GET /api/auth/get-session 401"
]
}Allowlist semantics
| Pattern syntax | Match type | Example |
|---|---|---|
"plain string" | Substring match against the message / URL | "VoiceClient: protocol mismatch" matches anywhere in the message |
"/regex/" | Full regex match (delimiter slashes required) | "/^\\[Sentry\\]/" matches messages starting with [Sentry] |
"GET /path 403" for network | Method + URL substring + status | Matches a GET to any URL containing /path returning 403 |
Allowlisted entries are still recorded in the Interaction Manifest (transparency) but suppressed from the findings count.
Verdict block transparency
The Verdict block in the report ALWAYS shows raw counts AND allowlisted counts so the reader can audit the audit:
Hard Gates:
Console errors: 3 RED ✗ (1 allowlisted, 2 reportable)
Console warnings: 8 GREEN ✓ (8 allowlisted, 0 reportable)
Network 5xx: 0 GREEN ✓
Network 403/404 auth: 4 GREEN ✓ (4 allowlisted, 0 reportable)
Layout collapse: 0 GREEN ✓If the reader thinks an allowlisted entry shouldn't be allowed, they can review .jez/audit-config.yml and contest the entry — every entry should have a one-line comment explaining why it's allowed.
When to add an allowlist entry
Three legitimate reasons:
1. Truly out of the team's control — third-party CDN, browser extension, dev-only build artifact 2. Deliberate dev-only behaviour — Sentry probe in dev mode, HMR signal, debug log behind a flag 3. Documented expected pattern — auth probe that's expected to 401 on first load before sign-in
NOT legitimate reasons:
- "It's been like this for ages" → fix it or escalate
- "It only happens in this rare case" → investigate the rare case first
- "Tests are flaky" → fix the test or remove the warning, don't allowlist
- "We don't know what's causing it" → don't allowlist unknown noise; investigate
When you find yourself wanting to allowlist something for the first time on a project, add it to the file with a one-line comment explaining why it's allowed. Future audits should be able to re-justify each entry.
Audit-the-allowlist (quarterly)
Every 4-6 weeks, re-evaluate the allowlist:
1. For each entry, ask: is the upstream cause still present? 2. If the upstream cause is fixed, remove the entry — that's a regression gate now active again 3. If the entry has expanded scope ("we added a few more variants"), tighten it back down 4. Audit the comments — are they still accurate?
Stale allowlists hide real bugs. The discipline mirrors how teams manage .eslintignore or // eslint-disable comments — every line should have a reason and a periodic review.
Surface overrides (advanced)
Some apps have surfaces with materially different noise profiles. Examples:
- An admin dashboard that must stay perfectly clean
- A dev playground / sandbox surface where warnings are expected
- A demo / showcase area that runs a noisy third-party widget
The surface_overrides block lets you tune noise floors per route pattern:
surface_overrides:
- surface: "/dashboard/admin"
console_warnings_allowed: 0
- surface: "/dashboard/dev-playground"
console_warnings_allowed: 5
- surface: "/embed/showcase"
console_warnings_allowed: 3
note: "third-party demo widget — see issue #123"When the audit visits a surface that matches surface_overrides[].surface, it uses the override threshold instead of the default 0. The verdict block notes which override applied.
Surface overrides are stricter than allowlists — they allow N noise items without naming them. Use sparingly, prefer named allowlists.
Cross-project re-use
If multiple projects in a team share the same noise patterns (a shared better-auth + Sentry stack, for example), maintain a shared audit-config-base.yml in your cross-project folder and have project-local .jez/audit-config.yml files extend or override it.
A simple include pattern:
# .jez/audit-config.yml
extends: ~/path/to/shared/audit-config-base.yml
console_allow:
# project-specific additions
- "[CustomComponent] missing prop X"(The audit reads extends paths and merges. Conflicts: project-local wins.)
Default behaviour without a config file
If no audit-config exists:
- Every console error during walkthrough = Critical finding
- Every console warning during walkthrough = High finding
- Every 5xx = Critical
- Every 403/404 on an authenticated page = High
- Layout collapse at any tested viewport = High
This is the strict default. Add an audit-config only when the strict default is producing too many false positives to act on.
Audit Output Discipline
Cross-skill reference. This file is the shared discipline used by every audit-shaped skill in dev-tools: ux-audit, code-review, design-review, project-health, fork-discipline, codex-review. Other audit/review skills should link to this file rather than re-state the rules.
What this exists to fix
Audit-shaped skills drift toward rigour-shaped output: every checklist row marked, every category covered, every page screenshotted. The output looks complete. It usually isn't useful — the reader still has to ask "what should I do first?".
This file forces judgement-shaped output: the reader gets a ranked decision-ready list, every finding earns its place, and the agent has done the work of comparing alternatives instead of dumping them.
The discipline is five rules. Skills enforce them in their verdict block, findings format, and meta-checks.
---
Rule 1 — Top 5 by senior-designer judgement (mandatory ranking)
Every audit produces a ranked Top 5 in the verdict block. Strict format:
TOP 5 (ranked by impact × ease, senior-designer pick)
1. [F-id] Short title — one-sentence reason. Why it edges out the others.
2. [F-id] Short title — one-sentence reason.
3. [F-id] Short title — one-sentence reason.
4. [F-id] Short title — one-sentence reason.
5. [F-id] Short title — one-sentence reason.Constraints:
- Exactly five. Not 4, not 7. The constraint is the value.
- Each entry references a specific finding ID from the body.
- One sentence on why this finding edges out the alternatives. Not the finding itself — that's already in the body.
- Order is ranked, not chronological.
- If the audit produced fewer than 5 findings, list what you have and explicitly mark the slots: "4. (no further finding warrants top-5 placement)." — don't pad.
Why exactly 5: it forces the agent to argue priority. "Is this finding really top-5" against the alternatives is a different cognitive task than "is this finding worth reporting".
Verdict cannot be Pass without a Top 5 (or explicit acknowledgement of fewer findings).
---
Rule 2 — Self-critique pass (KEEP / GENERIC / DUPLICATE)
After the findings draft and before publishing the verdict, the agent dispatches a sub-agent with the draft findings list and this prompt:
"Read these audit findings. For each, answer: would this finding make sense applied to ANY web app on ANY persona, or is it specific to THIS app on THIS locked persona? Mark each finding KEEP / GENERIC / DUPLICATE. Drop GENERIC and DUPLICATE before publishing."
A finding is:
- KEEP — specific to this app, this persona, this surface. Names a concrete element, action, or behaviour.
- GENERIC — would apply to most apps without modification. Reads as boilerplate.
- DUPLICATE — same root cause as another finding (or another phase already flagged it).
A fresh sub-agent works because the original drafter is invested in its own output. Self-critique done in-context tends to defend rather than prune.
The sub-agent's verdict per finding is logged in the report:
Self-critique pass:
Drafted: 23 findings
Kept: 14 (61%)
Generic: 5 (dropped)
Duplicate: 4 (merged into kept)Verdict cannot be Pass without a self-critique pass logged.
---
Rule 3 — Smallest possible patch (no "Suggested fix")
Every finding has a Smallest possible patch field, not Suggested fix. The rename is the discipline.
Bad (suggested fix):
Suggested fix: Improve the accessibility of this button.
Suggested fix: Consider adding a confirmation dialog.
Suggested fix: Make the empty state more helpful.
Good (smallest possible patch):
Smallest possible patch: Addaria-label="Edit message"tosrc/components/MessageRow.tsx:42.
Smallest possible patch: Wrap the existing destroy handler in<AlertDialog>from@radix-ui/react-alert-dialog.
Smallest possible patch: Replace the empty<div>with<EmptyState title="No clients yet" cta={<Button>Add client</Button>} />.
The rename targets the filler patterns: "consider X", "improve X", "make X better". A patch is concrete and committable. If the agent can't write a smallest-possible-patch, the finding may not be actionable enough to ship.
If the smallest patch is genuinely large (refactor, primitive replacement, schema change), say so — but in concrete terms: "Smallest possible patch: replace the ad-hoc dropdown in `<UserPicker>` with `<Combobox>` from `frontend:shadcn-ui`. Touches 3 files; ~80 lines diff."
---
Rule 4 — Proof-required PASS rows (no vibe-PASS)
Every PASS row in a checklist needs: 1. The check name 2. A one-sentence "why" with concrete evidence 3. A proof artefact (screenshot path / DOM selector / computed style / network capture / code reference)
Bad (vibe PASS):
- [✓] Hover delta perceptible
- [✓] Focus state visible
- [✓] Empty state has CTA
Good (sentence-proven PASS):
- [✓] Hover delta perceptible — primary button background shifts from
hsl(220 100% 45%)tohsl(220 100% 38%), ~7% lightness drop. Eyedropper proof:evidence/btn-hover.png. - [✓] Focus state visible — 2px ring in
--ringtoken, contrast vs background = 4.7:1 (WCAG AA). DevTools computed style:outline: 2px solid hsl(212 96% 56%). - [✓] Empty state has CTA — "/clients" with 0 records shows "No clients yet. Add your first client" + primary button. Screenshot:
evidence/clients-empty.png.
Vibe PASS rows get rejected by the meta-check. The audit-the-audit pass scans for [✓] rows without a proof artefact and flips the verdict to Incomplete.
The rule applies symmetrically to FAIL rows — they need reproduction + evidence + suspected location (already specified in the standard findings format).
---
Rule 5 — "Hold this in your hands" closing paragraph
Every audit ends with one paragraph, no template, that answers the question:
If this app were a physical object, would I want to hold it?
This is the one place where vibe is the point. It captures the holistic judgement that no checklist surfaces — the way the whole product feels in use, beyond any individual finding. It's where senior-designer pattern-recognition lives.
Format:
HOLD THIS IN YOUR HANDS
[One paragraph, ~150 words. Plain prose. No bullet points. Persona's voice
allowed if it sharpens the assessment. Says what the app is, what it
nearly is, and what's between the two.]Examples of paragraphs that work:
"This app is a clipboard with a brain. The Inbox is the right primitive — every other surface earns its place by feeding into it. The dashboard could disappear and I'd lose nothing. The agents page feels like a settings screen for something more interesting than itself; once it has personality (names that aren't class names, model labels that aren't IDs, descriptions in plain English), the whole product comes into focus. Right now I'd recommend it cautiously to a developer friend; in two iterations I'd recommend it to my SME-owner sister."
"Every screen does its job. Few screens make me happy. The product passes every gate and clears every bar — but there's no moment where I'd describe a feature to a colleague with affection. That's the gap to close. Pick one surface — Inbox, Approvals, or the agent edit panel — and pour love into it: micro-copy, hover deltas, motion that has weight. The rest of the app will look better next to a single surface that's been loved."
Constraints:
- One paragraph. Not two. Not a list.
- Speaks to use, not features. "What is this like to live with?"
- Avoids both flattery and pile-on. Should feel earned.
- May reference findings but isn't an aggregation of them.
---
How skills consume this discipline
A skill that adopts this discipline:
1. References this file in its Reference files table (not duplicates the rules) 2. Includes Top 5 + Self-critique outcome + Smallest-possible-patch field + Hold-this paragraph in its report template 3. Runs the audit-the-audit meta-check that flips Pass → Incomplete when:
- Top 5 missing or padded
- Self-critique pass not logged
- Findings use "Suggested fix" instead of "Smallest possible patch"
- PASS rows lack proof artefacts
- Hold-this paragraph missing or templated
A skill that doesn't adopt the discipline produces breadth-shaped output. That's a legitimate choice for some skills (a discovery / inventory skill, a "what files exist" skill). But for any skill that outputs findings worth ranking, the discipline above is the bar.
---
Anti-patterns this discipline rules out
- Template completism: every section filled, every checkbox covered, no judgement applied
- Filler fixes: "consider X", "improve Y", "make Z better"
- Generic findings: "the app could use better empty states" without naming a specific empty state
- Duplicate findings: the same root cause logged on each surface it affects (should be one finding with a list of affected surfaces)
- Vibe PASS: "[✓] Looks fine" without proof
- Findings list with no priority: 30 things wrong, no decision-ready Top 5
- Holistic-judgement skip: audit ends at the last finding with no overall assessment
---
Project-side adoption
Projects that consume audit skills can encourage the discipline by:
1. Adding to CLAUDE.md: "Audit reports must include Top 5 + Hold-this paragraph. Reports without these are incomplete." 2. Adding to PR templates: "If this PR includes an audit, paste the Top 5 here." 3. Tracking Top 5 outcomes over time — does the same finding keep landing in Top 5? That's a regression class worth a Playwright test (see playwright-killer-flows.md).
The discipline is most useful when the project treats the Top 5 as a forcing function, not an aspirational frame.
---
Cross-skill consistency
Skills that reference this file produce comparable output. A code-review Top 5 and a ux-audit Top 5 read in the same shape; a project lead can scan both and prioritise across them. That coherence is the second-order benefit — once the discipline is shared, audits and reviews become composable.
When updating this file, post a note in any skill that references it. The cross-skill effect is real: a tightening here propagates to every audit family member.
Auth-expired mid-audit
A long audit (30+ min) can outlast the session expiry. If during the walkthrough a navigation OR an API call returns 401/403 on a route that previously authenticated, the session has dropped.
Don't try to silently re-auth. From this point onward, every observation is potentially corrupted (signed-out user sees different surfaces, hits different gates, gets different copy).
Protocol
1. Stop immediately on the first unexpected 401/403 in the manifest. 2. Capture the exact step that broke (network log + screenshot) — that itself is evidence for a possible "session expired without warning" finding. Apps that drop session without UI feedback fail Critical: the user has no way to know they're not logged in. 3. Terminate the audit with verdict Incomplete, cause = auth expired mid-audit at <step>. 4. Note in the Verdict block how far the audit got: which pages had complete manifest, which were mid-flight. 5. Recommend next steps: re-auth in Chrome (or re-run test-auth /cookies if headless) and resume from the point of failure with a fresh session.
This is intentional: silently re-authenticating mid-audit hides session-expiry bugs (the very thing the user might want to know about) AND mixes pre-expiry and post-expiry observations into one report.
Headless test-auth case
If the audit is running headless via test-auth cookies and the cookies expire mid-walkthrough, the same protocol applies:
1. Re-mint cookies via /api/test-auth/cookies 2. Restart the audit from scratch (don't stitch two halves together) 3. The pre-expiry partial manifest is preserved as evidence — log "Audit attempt 1 terminated at <step> due to cookie expiry; attempt 2 fresh"
Detecting expiry
Three signals worth watching for:
| Signal | What it means |
|---|---|
| Sudden 401 on previously-200 endpoint | Session token expired or revoked |
Sudden redirect to /signin mid-flow | Auth middleware kicked in |
| Empty data on a list that had data 5 minutes ago | Possibly session-scoped state cleared |
The first two are unambiguous. The third can be a normal data refresh — check the network log for the underlying call and look for an unauthorized response.
What constitutes a finding
Auth expiring mid-audit is itself a UX finding if any of these are true:
- No warning before expiry (Critical) — the user lost their work because the app didn't say "your session is about to end"
- Silent redirect to signin (High) — the user is mid-flow, gets bounced, and may not understand what happened
- Drafts not preserved across re-auth (Critical) — typed text, form data, in-progress work all lost
- Re-auth doesn't return to the same page (High) — sign back in, end up at /dashboard instead of where you were
These get logged in the audit's findings even though the audit itself terminated Incomplete. The mid-audit termination is itself the evidence.
Prevention recommendations (audit output)
When the audit catches an unexpected expiry, recommend:
1. Idle warning — modal at 5 minutes before expiry: "Your session ends in 5 minutes. Stay signed in?" 2. Refresh-on-activity — sliding session that extends on user input 3. Graceful re-auth — bounce to signin, then back to the original page with form state preserved 4. Indicator in header — small "Session: 23 minutes left" text for power users 5. Better-auth `sessionOptions.refetchOnWindowFocus` + cookieCache enabled to extend on tab focus
Audit resumption
When restarting after a fresh auth:
- Don't try to "continue" the previous manifest — start a clean one
- Note in the new audit that it's "resumption attempt 2" with a reference to the prior
Incompletereport - The prior report stays as evidence; the new report is the official verdict
- Re-walk the surfaces that were already audited — the post-expiry session may be on a different state than the pre-expiry session
Browser Tool Reference
Tool Selection
| Tool | Use for | Auth | Setup |
|---|---|---|---|
| Chrome MCP | Authenticated apps with a human at the keyboard — uses real logged-in Chrome session | Full — uses your logged-in Chrome session | Claude Code extension in Chrome |
| Playwright MCP / `playwright-cli` | Public / unauthenticated sites, OR authenticated apps via test-auth cookie injection (see below) | None / scripted | MCP plugin / npm package |
Rule: For authenticated apps, prefer Chrome MCP when a human is at the keyboard. For headless / autonomous runs (cron, CI, sub-agent, no human), use the test-auth cookie-injection path below. Don't silently fall back to a fresh unauthed Playwright session for an authenticated app — the audit becomes worthless.
For public sites with no login, Playwright MCP is fine (and can run in parallel sessions if needed).
Headless authentication via project test-auth endpoint
If the project exposes a /api/test-auth/cookies-style endpoint (gated on a server-side TEST_AUTH_TOKEN secret), a headless agent can mint real session cookies and inject them into Playwright. This unlocks autonomous audits without requiring Chrome MCP.
The example below is better-auth-specific. NextAuth / Auth.js, Lucia, Rails Devise, Django auth, custom JWT, magic-link, and WordPress have different patterns — see project-adaptation.md "Test-auth alternatives" for each. The general principle is the same across stacks: gate on a server-side env flag, use a token / shared secret, lock to email patterns like *@test.<app>.local to prevent real-user takeover.
Pattern (better-auth example):
# 1. Mint session cookies for a test user (creates if needed)
curl -sX POST "$URL/api/test-auth/cookies" \
-H "X-Test-Auth: $TEST_AUTH_TOKEN" \
-H "Content-Type: application/json" \
-d '{"email":"alice@test.<app>.local"}' > /tmp/cookies.json
# 2. Inject into Playwright via context.addCookies()
# or pass to playwright-cli state-loadImplementation pattern (better-auth's testUtils() plugin loaded conditionally on the secret env var) is documented in the project's CLAUDE.md under "Test-auth (headless agent login)" if present.
Email is locked to *@test.<anything>.local so the endpoint can never accidentally take over a real user account. Different email per call → different test user (multi-user friendly).
When to use which:
| Scenario | Use |
|---|---|
| Human is at the keyboard, Chrome already signed in | Chrome MCP |
Headless / cron / CI / sub-agent, project has /api/test-auth/* | Playwright + cookie injection |
| Headless and project has no test-auth endpoint | Stop. Ask the user to add one (it's ~80 lines), or run on an authenticated dev server with the user signed in |
| Public / unauth sites | Playwright MCP or playwright-cli |
Screenshot sizing — Retina 2x trap
Most browser MCP tools capture at the OS device-pixel ratio. On Retina Macs that's 2x → a 1440×900 viewport screenshot is actually 2880×1800 pixels (~3 MB), and full-page captures of long timelines hit ~3000-4000px tall. This bloats the audit context fast and can hit Claude's image-input limit (~2000px per side).
Always resize captures to ≤ 1440 longest side before referencing or committing them:
sips -Z 1440 path/to/screenshot.png --out path/to/screenshot.pngOr batch:
for f in .jez/audit-evidence/2026-04-29/*.png; do
sips -Z 1440 "$f" --out "$f"
doneCapture-time prevention (preferred):
| Tool | Default DPR | Note |
|---|---|---|
chrome-devtools-mcp take_screenshot | 2x | Resize after, OR use fullPage: false to bound height |
mcp__claude-in-chrome__computer action=screenshot | 2x | Follows OS DPR |
mcp__claude-in-chrome__gif_creator | 2x frames | Encoder downsamples but still heavy |
playwright-cli / Playwright MCP | 1x (CSS pixels) | Prefer this when a reference screenshot is the goal |
The user-global ~/.claude/rules/screenshot-sizing.md covers the full pattern. There's also a PreToolUse hook on Read that auto-resizes oversized images — it's a safety net, not a substitute for capture-time discipline.
Viewport
Pin the window at the start of every audit:
width: 1440, height: 900This is the baseline (standard MacBook resolution). Responsive sweeps then test 1280, 1024, 768, 375.
Do not resize above 2000px wide — it breaks the harness. If the user asks to test ultra-wide layouts, screenshot at 1920 max.
Chrome MCP Commands
| Action | Tool |
|---|---|
| See current tabs | mcp__claude-in-chrome__tabs_context_mcp |
| Open new tab | mcp__claude-in-chrome__tabs_create_mcp with URL |
| Read page content | mcp__claude-in-chrome__read_page |
| Get page text | mcp__claude-in-chrome__get_page_text |
| Click element | mcp__claude-in-chrome__computer with click action |
| Fill form field | mcp__claude-in-chrome__form_input |
| Navigate | mcp__claude-in-chrome__navigate |
| Take screenshot | mcp__claude-in-chrome__computer with screenshot action |
| Run JavaScript | mcp__claude-in-chrome__javascript_tool |
| Resize window | mcp__claude-in-chrome__resize_window |
| Record GIF | mcp__claude-in-chrome__gif_creator |
Important: Call tabs_context_mcp first to see what tabs exist. Avoid triggering JS alerts/confirms — they block the extension.
Playwright MCP Commands
| Action | Tool |
|---|---|
| Navigate | mcp__plugin_playwright_playwright__browser_navigate |
| Take screenshot | mcp__plugin_playwright_playwright__browser_take_screenshot |
| Click | mcp__plugin_playwright_playwright__browser_click |
| Fill form | mcp__plugin_playwright_playwright__browser_fill_form |
| Get page snapshot | mcp__plugin_playwright_playwright__browser_snapshot |
| Resize | mcp__plugin_playwright_playwright__browser_resize |
| Run code | mcp__plugin_playwright_playwright__browser_run_code |
playwright-cli Commands
playwright-cli -s=audit open https://app.example.com
playwright-cli -s=audit snapshot # Get element refs
playwright-cli -s=audit click e5 # Click by ref
playwright-cli -s=audit screenshot --filename=issue-1.png
playwright-cli -s=audit closeUse -s=NAME for named sessions. Use --persistent to keep login state.
Mobile Viewport Testing
# Chrome MCP
mcp__claude-in-chrome__resize_window — width: 375, height: 812
# Playwright MCP
mcp__plugin_playwright_playwright__browser_resize — width: 375, height: 812
# playwright-cli
playwright-cli -s=audit resize 375 812Data Seasoning
The audit's existing volume tests (0 / 1 / 100 / 1000+) catch quantity-related bugs. They miss time-shaped bugs.
100 inbox rows seeded fresh ≠ 100 inbox rows accumulated over 30 days. Same row count, completely different UI behaviour: time dividers, recency-based sort, cron-fired side effects, notification overflow, signal-vs-noise pressure on the user's eye. Most "the app feels different after using it for a while" complaints come from this gap.
Scenario 11 — Data Seasoning — closes it.
Why time-shaped data matters
| Quantity test misses | Time-shaped catches |
|---|---|
| Today / Yesterday dividers don't exist | Day 1 horizon — single-item pluralisation, returning-user feel |
| Cron-driven side effects (routine_runs, scheduled emails) never fire in fresh seed | Day 7 horizon — recurring jobs have actually run ~50 times |
| Notification badge overflow (47 unread, 99+) | Day 30 horizon — counters break at real volumes |
| Sort order meaning (newest at top) | Day 30 — does "recent" still feel useful with a month of activity? |
| Search-with-history (200 saved memories, 50 conversations) | Day 30 — search latency, result ranking, fuzzy match performance |
| Chart bucketing (per-day vs per-week vs per-month) | Day 30 — UI must adapt or it's unreadable |
| TanStack Query invalidation cascade | Day 30 — many keys = visible re-render storms |
The four horizons
Day 0 — fresh install
What's there: nothing. Brand new account, no history, no peers.
Test:
- Every list/queue/observability surface is empty
- Onboarding affordances visible? "Open Routines" / "Add your first" / "Get started" CTAs present?
- Dimmed placeholders that hint at structure ("Your inbox will appear here")
- No phantom data leaking from another user's session
- No 404 / 500 from APIs that assume "at least one record exists"
Day 1 — light, recent activity
What's there: a handful of items, all created today. Maybe one created yesterday.
Test:
- "Today" divider appears
- "Yesterday" divider appears (single item is fine)
- Pluralisation correct ("1 message" vs "2 messages") at every count boundary
- Returning-user feel: signing in for the second time, does the data feel like yours or like demo content?
- Activity feed renders with one entry without looking hollow
- Mention picker / assignee dropdown shows just the founder + one teammate (does it work with 2 humans?)
- Pagination doesn't show controls when there's nothing to paginate
Day 7 — modest density, time-distributed
What's there: 50-100 items spread across the week. Some recurring (cron-fired routines have run ~50 times), some one-off, some old (>3 days), some today.
Test:
- "This week" / "Older than X" boundaries
- Pagination first appears here — does it work at the breakpoint?
- Weekly summaries / digests visible? Do they read as useful?
- Sort orders work (recent first, oldest first) — try both
- Filter by date range
- Cron-fired side-effects visible (a routine that runs every hour has produced ~168 entries in agent_runs — does observability render them or melt down?)
- Modest density still readable at desktop and mobile
Day 30 — seasoned, real signal-vs-noise
What's there: 200+ items, distributed across a month, with realistic mix (some pending, some decided, some archived). Notification counters in tens.
Test:
- Notification bell / unread badge: does it show "47" cleanly, or "99+" at the cap?
- Header counters break at any threshold (some apps clip at 9, 99, 999)?
- Sort order — does "newest first" still feel useful, or is recent stuff buried?
- Search performance: type a query, does it return ranked-relevant results fast?
- Chart bucketing — do charts that worked at Day 7 stay readable, or become a wall of bars?
- TanStack Query invalidation: trigger an action that should invalidate multiple keys; observe re-render storm or smooth transition
- Memory / saved-state surfaces: 500+ saved items — does the UI scale?
- "Snooze" / "Archive" / "Mark all read" affordances present? At Day 30 the user needs them
- Empty state path tested — mark all read, does the empty state still work?
Surfaces this applies to
For most apps:
- Lists / tables (clients, projects, files, contacts)
- Queues (Inbox, Approvals, Pending)
- Observability (agent_runs, audit logs, activity feeds)
- History / search (Memory, conversations, message history)
- Notification surfaces (header bell, tab counters, dot indicators)
- Charts and dashboards
- Filter / sort surfaces (saved views, custom filters)
Skip the scenario only if the app has zero time-distributed data — a one-shot calculator, a static directory, an embed-only widget. Most apps don't qualify for skip.
Severity guide
| Symptom | Severity |
|---|---|
| Notification badge overflows or breaks visually at 47+ unread | High |
| Sort order becomes meaningless at Day 30 (recent buried) | High |
| Pagination breaks at Day 7 horizon | High |
| Charts unreadable at Day 30 (no per-week / per-month bucketing) | High |
| TanStack Query invalidation visibly cascades for >2s at Day 30 | High |
| Empty-state CTA wrong for Day 30 (e.g. shown after mark-all-read) | High |
| Missing Today/Yesterday dividers when data warrants | Medium |
| Single-item pluralisation off ("1 messages") | Medium |
| Cron observability surface melts at Day 7 (168 entries) | Medium-High depending on UI |
| Search latency >500ms at 500 saved items | Medium |
| Activity feed feels hollow at Day 1 (one entry) | Low (hard to fix without diluting signal) |
Project-side seed-script architecture
The audit assumes the project provides seasoning seeds. The example below uses Cloudflare Workers + Drizzle + tsx; for Prisma / TypeORM / Sequelize / ActiveRecord (Rails) / Django ORM / Laravel Eloquent / WordPress / raw SQL equivalents, see project-adaptation.md "Seed-script architectures by ORM".
scripts/
seed/
index.ts # CLI entry: pnpm seed:day-N
horizons/
day-0.ts # empty / onboarding state
day-1.ts # ~10 items, all today/yesterday
day-7.ts # ~75 items, week-distributed
day-30.ts # ~250 items, month-distributed
factories/
inbox.ts # faker-driven
approval.ts
conversation.ts
memory.ts
routine-run.ts
fixtures/
personas.ts # test-auth users
package.json:
"scripts": {
"seed:day-0": "tsx scripts/seed/index.ts day-0",
"seed:day-1": "tsx scripts/seed/index.ts day-1",
"seed:day-7": "tsx scripts/seed/index.ts day-7",
"seed:day-30": "tsx scripts/seed/index.ts day-30",
"seed:reset": "tsx scripts/seed/index.ts reset"
}Each horizon script:
- Resets the test-auth user's data
- Calls factories with horizon-appropriate parameters (date ranges, counts, mix of states)
- Logs what was seeded so the audit report can cite "seeded with day-7 fixture: 75 inbox items, 12 routines, 30 memories"
Use @faker-js/faker for content. Realistic-flavoured strings (names, emails, addresses, message bodies), respecting locale where the app supports it.
For Cloudflare D1 / Drizzle stacks, the seeds are batched inserts through the project's existing schema. Half-day to write the first horizon, then progressively cheaper.
Audit protocol per horizon
For each seasoning-sensitive surface:
1. Run pnpm seed:day-N (or whatever the project calls it) 2. Sign in as the test-auth user 3. Walk the surface — every list, every filter, every action 4. Run an Interaction Manifest at this horizon 5. Capture screenshots labelled surface-day-N.png 6. Note any bug that's specific to this horizon 7. Reset (pnpm seed:reset) and move to the next horizon
Don't skip a horizon because "the app probably handles it" — every horizon catches a bug class the others miss.
When the project has no seasoning seed
If the project has no day-N seed scripts:
1. Note in the audit report that Scenario 11 was attempted but couldn't run (missing fixture) 2. Verdict for the audit can still be Pass / Conditional Pass on other grounds, but Scenario 11 coverage = 0 3. Recommend the project write the seeds (cite this reference for the architecture) 4. Re-run the audit when seeds exist
Don't try to manually create 250 inbox items via UI — that's not the audit's job, and it produces unrealistic time distributions anyway.
Known seasoning-sensitive surfaces in common apps
| App type | Surfaces likely to need seasoning |
|---|---|
| Chat / messaging | Conversations list, message history, mention pickers, unread counters |
| Inbox / queue | Inbox list, Approvals, Pending, Mark-all-read affordances |
| AI / agent | agent_runs observability, prompt history, memory search, routine_runs |
| Project management | Project list, task lists, activity feeds, assignee pickers |
| Inbox, Sent, Drafts, threads | |
| CMS / docs | Recent edits, version history, search |
| E-commerce | Order history, abandoned cart, recently viewed |
Document which surfaces are seasoning-sensitive in the project's CLAUDE.md so future audits know where to escalate.
Composing with other scenarios
Data seasoning composes with — doesn't replace — other scenarios:
- Scenario 6 (Heavy Data) — pure quantity at 500+ items. Data seasoning adds time-distribution. Run both: Heavy Data catches list-virtualisation bugs; Data Seasoning catches time-divider bugs.
- Scenario 9 (Lifecycle Position) — person time (founder / first invitee / late joiner). Data seasoning is data time. Together they cover both axes.
- Scenario 4 (Returning User) — assumes the user has data; this scenario seeds it realistically.
- Multi-Pane Stress — run multi-pane stress at Day 30, not Day 0, when surfaces actually have content to break under squeeze.
The full audit at all four horizons takes ~4 hours for a substantial app. Day 0 + Day 7 covers ~80% of the bugs in ~1 hour if time is short.
Interaction Manifest
The Interaction Manifest is the proof that the walkthrough actually walked. Without it, the audit cannot produce a verdict — the only legal output is Incomplete.
This file specifies what counts as a complete manifest, the template, the replay protocol when a manifest entry doesn't match expected behaviour, and worked examples.
Why this exists
The previous version of ux-audit had a "Walkthrough" mode but didn't enforce it. In practice, agents drifted to sweep mode — query the DOM, read text, move on — because it's faster and feels productive. That mode is blind to behavioural bugs by definition.
The 2026-04-29 vite-flare-starter audit failure proved the cost: six catastrophic bugs went live (Send button doesn't clear input, @-mention doubles the handle, vertical-text-stacking in spaces, etc.) while the audit said "no Critical issues". Every one of those bugs only surfaces under interaction.
The manifest closes that gap. It's a non-negotiable proof requirement. Skipping it means the audit terminates with Incomplete.
What counts as "complete"
A manifest is complete when, for every page audited, all six required entry types are logged with proof:
| Required entry | What it looks like | Proof artefact |
|---|---|---|
| Type into ≥ 1 input | Real text, not just clicked | textarea.value after typing, screenshot |
| Trigger ≥ 1 primary action | Send / Save / Submit / Create / Publish | Network request fired, screenshot before + after |
| Open ≥ 1 modal or detail pane | Click a row, open a thread, open a settings panel | Screenshot of opened state |
| Console read after primary action | read_console_messages call | Output captured (0 warnings or list of warnings) |
| Screenshot before AND after primary action | Two screenshots, ideally same viewport | Files written, paths logged |
| Verify expected post-action state | Input cleared, success toast, route change, list updated | Assertion logged with selector + expected value |
Six types per page, one row per check. Multi-pane stress adds more entries; scenarios add more entries. The minimum is six per audited route.
Manifest template
INTERACTION MANIFEST — /dashboard/spaces/marketing-pod
Persona: SME owner, time-pressed, low tech comfort
Viewport: 1280×800 (desktop default for this persona)
Panes open: sidebar, members, thread
[✓] 14:32:01 TYPE Typed "@assistant test" into message input
Selector: textarea[placeholder*="message"]
Result: textarea.value === "@assistant test"
[✓] 14:32:03 PICK Picked @assistant from autocomplete
Selector: li[data-mention-id="assistant"]
Screenshot: .jez/audit-evidence/spaces-after-pick.png
[✓] 14:32:05 SUBMIT Clicked Send
Selector: button[aria-label="Send"]
Screenshot before: .jez/audit-evidence/spaces-before-send.png
Screenshot after: .jez/audit-evidence/spaces-after-send.png
Network: POST /api/messages → 200 (87ms)
[✓] 14:32:06 ASSERT Verified input cleared within 1000ms
Assertion: textarea.value === ""
Result: PASSED (cleared in 142ms)
[✓] 14:32:08 ASSERT Verified message appeared in transcript
Assertion: [data-message-id] count increased by 1
Result: PASSED (count: 12 → 13)
[✓] 14:32:12 OPEN Opened thread on the new message
Selector: [data-message-id="msg-13"] [data-thread-trigger]
Screenshot: .jez/audit-evidence/spaces-thread-open.png
[✓] 14:32:13 ASSERT Verified main column width ≥ 200px after thread open
Assertion: main.getBoundingClientRect().width >= 200
Result: FAILED (width: 184px) ← FINDING H-2
[✓] 14:32:14 CONSOLE Read after thread open
Errors: 0
Warnings: 1 ← FINDING H-1
Warning: "VoiceClient: protocol mismatch (1.2 vs 1.4)"
[✓] Network requests inventoried for the page session
5xx: 0 403/404 (auth pages): 0 Total: 8
─────────────────────────────────────────────────────────────────
Required entries: 6 / 6 ✓
Findings produced from this manifest: 2 (H-1 console, H-2 layout)Required entry types in detail
TYPE
- Real text into an input. Not just a click on the input.
- For password fields, type a fake password (something like
audit-test-123!). - For email fields, type a valid-format fake address.
- For search inputs, type something likely to match (or
zzzzzto trigger empty state). - Verify the input received the text (
getByRole('textbox').toHaveValue(...)).
PICK
- Optional but common: dropdown selection, autocomplete pick, file upload, date picker, mention picker.
- Required if the page has any picker/combobox/autocomplete that's part of the primary flow.
- Verify the picked value is reflected in the form / display.
SUBMIT
- The primary action: Send, Save, Submit, Create, Publish, Pay, Share, Send invitation.
- Click it. Watch the network, watch the DOM.
- Capture screenshot before AND after.
- Capture the network request: URL, method, status code, latency.
OPEN
- Click into a row, open a modal, open a detail pane, open a thread, open a settings panel.
- Captures the "what happens after this lands" view.
- Required because most layout / pane interaction bugs are only visible after opening.
ASSERT
- Each ASSERT is a yes/no check on observed state.
- Common assertions:
- Input cleared after submit
- Success toast appeared (
.toast,[role="status"], etc.) - Route changed (URL pathname check)
- List item count increased / decreased
- Loading spinner appeared during action
- Loading spinner disappeared after action
- Error toast appeared on invalid input
- Pane width ≥ minimum (200px is a common floor)
- Failed assertions become findings.
CONSOLE
- After every SUBMIT (and ideally every state change), read the console.
- Tool:
mcp__claude-in-chrome__read_console_messagesor Playwrightpage.on('console', ...). - Any warning or error becomes a finding (Hard Gate).
NETWORK
- Inventory at the end of each page session.
- Status code distribution: 2xx, 3xx, 4xx, 5xx counts.
- Any 5xx = Critical. Any 403/404 on an authenticated page = High.
Replay protocol — what to do when an assertion fails
When an ASSERT fails, the finding workflow:
1. Don't move on. That's a behavioural bug surfacing right now. 2. Capture the failure state: screenshot, DOM at the failure point, network log, console. 3. Repeat the action — does the bug reproduce? If yes, it's stable. If no, capture the variation. 4. Vary one input: type different text, click in different order, change viewport. Does the bug always happen, or only with specific input? 5. Check the underlying code if accessible — Read the relevant file, identify the suspected location. 6. Write the finding with full reproduction steps, evidence path, suspected location, suggested fix. 7. Continue the manifest with the rest of the required entries — don't skip them just because one failed.
A page can have multiple findings; the manifest captures all of them in order.
Example manifest entries for common page types
List page (clients, projects, messages)
- TYPE into search
- ASSERT search filters the list
- OPEN one item (click row)
- ASSERT detail pane / detail page loaded
- CONSOLE read
Form page (settings, create new X)
- TYPE into every required field with realistic data
- TYPE into one optional field
- PICK any required dropdown
- SUBMIT (Save / Create)
- ASSERT success toast OR error toast appeared
- ASSERT route changed (or stayed, depending on intent)
- CONSOLE read
Conversation / thread page
- TYPE a message
- SUBMIT (Send)
- ASSERT input cleared
- ASSERT message appeared in transcript
- OPEN thread on message
- ASSERT thread pane opened, main column width ≥ 200px (multi-pane stress!)
- CONSOLE read
Dashboard (multi-widget)
- For each widget: read content, screenshot, OPEN any drill-down
- TYPE into any inline filter / search
- CONSOLE read after each interaction (dashboards are notorious for racy data fetches)
Interaction Manifest as a coverage metric
At the end of the audit, publish:
INTERACTION COVERAGE
Pages audited: 14
Required entries logged: 84 / 84
Pages with complete manifest: 14 / 14
Pages with incomplete manifest: 0If any page has < 6 required entries, that page is Incomplete and the overall verdict is Incomplete — even if every observed interaction passed.
Anti-patterns
Things that look like manifest entries but aren't:
- "Read the page text" — not an interaction. Doesn't count.
- "Hovered over the menu" — counts as a state change but doesn't satisfy SUBMIT requirement.
- "Clicked the menu open" — only counts if the menu IS the primary action; otherwise it's an OPEN.
- "Took a screenshot" — supporting evidence, not an entry on its own.
- "Read the source code" — useful for finding suspected location but doesn't replace interaction.
If your manifest is full of READ, INSPECT, QUERY, you've drifted to sweep mode. Stop, restart with TYPE.
How this maps to the verdict
| Manifest state | Allowed verdicts |
|---|---|
| Complete (≥ 6 entries per page, all phases run) | Pass / Conditional Pass / Fail |
| Incomplete on any page | Incomplete (cannot upgrade to Pass) |
| Console hard gate breached | Fail (cannot be Pass / Conditional) |
| Network 5xx hard gate breached | Fail (Critical) |
Hard gates auto-fail. Incomplete manifest auto-blocks Pass. Together they make "audit said clean while bugs were live" structurally difficult.
Live Interaction Smoke
Code reading verifies a button exists and has an onClick. It does not verify that clicking the button actually does something observable. Bugs of this shape — "the handler runs, fires a call into an SDK, but the flow never completes" — are invisible to static analysis and require a live click + network check.
The protocol
For every interactive control on every page audited:
1. Click it. Pointer moves, element highlights, click lands. 2. Watch the Network tab. Did a request fire? To the right URL? Correct method + body shape? 3. Watch the DOM. Did something visibly change — new element, removed element, state transition (loading spinner, toast, route change)? 4. If nothing changed in (2) or (3), that's a bug. The control LOOKS alive but isn't doing its job. Log it.
This is a Hard Gate during Phase 3 walkthrough — silent-failure controls produce findings automatically, no severity downgrade.
Known control categories that silently fail
| Control category | Silent-failure mode | Required check |
|---|---|---|
| Approve / Deny buttons on tool-call cards | Handler fires but server never hears about it (SDK needs a separate "send on state change" callback). See rules/ai-sdk-tool-approval-autosubmit.md. | Network: POST to approval endpoint with the decision payload. |
| "Connect X" OAuth buttons inside dialogs | window.open() silently popup-blocked when click originates in a modal. Must use window.location.href. See rules/oauth-popup-blocked-in-dialogs.md. | Verify navigation actually starts (URL bar changes or new tab opens). |
| Save / Update buttons on forms with async validation | Button disables during mutation but the mutation itself silently 5xx'd. No toast, no error state, form just sits there. | Network: capture response. DOM: success or error toast within 5s. |
| Delete / Archive actions | Optimistic UI removes the row but server rejected — after refresh, the row is back. | Refresh page, verify item still gone. |
| Pagination / "Load more" buttons | Fires request but response empty due to off-by-one offset. | Verify item count actually grows. |
| Filter chips on list views | Query param updates but query key doesn't — TanStack Query / SWR serves stale cached results. | Verify list contents change after filter applied. |
| "Reply" / "Forward" in email-style UIs | Opens compose pane but Message-ID headers not set — reply threads orphan in recipient's inbox. | Inspect outgoing payload for thread headers. |
| Search box | Updates URL but no debounced fetch fires. | Type query, verify network request sent within 500ms. |
| File upload | Drop zone accepts file but upload silently fails on size / type / auth. | Capture upload network call, verify 2xx. |
| Mention / autocomplete pickers | Selection inserts the value twice (once from typing, once from picker click). | After picking, count mention-pills in input — should be 1. |
| Send / Submit on chat-style inputs | Server accepts but client doesn't clear the input. | After submit, verify input value === ''. |
| Theme switcher / dark mode toggle | Updates state but doesn't persist; reload reverts. | Toggle, refresh, verify state survived. |
| Notification dismiss | Hides locally but server doesn't mark as read. | Refresh, verify notification stays dismissed. |
| "Try again" / Retry on error states | Re-fires but with same stale params (the error params, not fresh). | Capture retry call payload. |
| Cross-page mutation (A→B mutates, return to A) | Mutation on B invalidates only B's query key; A's parent query is stale on return. Looks like data loss to the user — they retry or give up. See references/round-trip-workflows.md. | Walk the round-trip; verify A reflects new state without reload. |
| Header badge / unread count after action elsewhere | Bell-pip / pending-tab-count consumed by ['notifications'] etc., but the action that should decrement only invalidates its local list. | After every list mutation, verify the relevant header badge updates. |
SDK contract checks
When the page uses a third-party SDK with its own state model, verify the SDK's required options are passed. Silent failures usually trace to an undocumented-but-required option.
The catalogue below is React + TanStack + Radix + better-auth flavoured — that's one stack of many. Vue, Svelte, Angular, vanilla, native browser, WordPress / PHP, mobile (iOS / SwiftUI) all have equivalent "undocumented option that silently breaks" patterns. See project-adaptation.md "SDK contract examples by ecosystem" for the equivalents in each. The discipline is universal: any ecosystem's SDKs have a class of "if you don't pass this option, it silently does the wrong thing".
| SDK | Option that silently breaks behaviour if missing |
|---|---|
@ai-sdk/react useChat with needsApproval: true tools | sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses |
@ai-sdk/react useChat with custom transport | prepareSendMessagesRequest reading latest refs (otherwise pinned to initial values) |
better-auth createAuthClient | sessionOptions.refetchOnWindowFocus: false for SPAs that route on session state |
TanStack Query QueryClient | refetchOnWindowFocus: false if your app redirects on empty query results |
TanStack Query useQuery | queryKey includes ALL filter params (otherwise stale results) |
TanStack Query useMutation | onSuccess / onSettled invalidates EVERY query key that consumes the mutated data (parent lists, header badges, related views — not just the local list). The most common silent failure across pages. |
React Router v7 createBrowserRouter | loader / action defined for routes that need data (not just component) |
| Radix Dialog | modal: true + onEscapeKeyDown handler if Escape should do more than close |
| Radix Select | value prop wired to controlled state (uncontrolled + controlled mix breaks) |
| zodResolver | as any around schema if using Zod v4 and resolver is older — silent validation miss |
| Stripe Elements | mode: 'payment' (or 'setup' / 'subscription') — wrong mode silently mints wrong intents |
react-hook-form | mode: 'onBlur' for blur-validation; default 'onSubmit' won't show inline errors |
If the page uses an SDK not on this list, spend 2 minutes reading its useX export's options. Anything named *On*Change, *On*Finish, *SendAutomatically*, *RefetchOn*, or *Configure* is a prime suspect for "silent failure because it's undefined."
Optimistic-UI rollback verification (mandatory for list mutations)
The catalogue above mentions "Optimistic UI commits, server rejects, no rollback" as a Critical severity. The protocol below forces the failure scenario so the audit actually exercises the rollback path — not just the happy path.
For every mutation that uses optimistic UI (TanStack Query onMutate setQueryData / setState in a handler / etc), force a server failure and verify the rollback:
1. Identify the optimistic mutation (look for setQueryData in onMutate, or
immediate state updates in click handlers).
2. Block the underlying network request — Playwright route override:
await page.route('**/api/inbox/*', route =>
route.fulfill({ status: 500, body: 'Forced 500 for audit' })
)
3. Trigger the action.
4. Verify:
- UI updates optimistically (row removed / status changed / value updated)
- After ~1s, UI rolls back to the pre-mutation state (row reappears /
status reverts / value restored)
- Error toast / alert appears
- The user knows the action failed
If the UI rolls back BUT no error toast: Medium finding (silent failure).
If the UI does NOT roll back: Critical finding (looks succeeded, server says
no — re-loading reveals the lie).
If the UI rolls back AND toast appears: Pass.Apply this check to: bulk-mark-read in inbox, approve/reject in approvals, star/unstar lists, delete actions, drag-and-drop reorder, edit-in-place fields. Anywhere a mutation completes faster than network round-trip.
For the 429 / rate-limit variant — same pattern, return 429 + retryable error. Verify the UI surfaces "rate limited, try again in N seconds" cleanly.
Investigation workflow
When a click produces no observable change:
1. Capture network tab — did a request fire? If no → frontend issue. If yes → check status / payload. 2. Capture console — was there an error or warning? Often the SDK logs the missing option here. 3. Inspect the element — is onClick actually wired? React DevTools → check props. 4. Read the source for the handler — is it conditional on something? 5. Check for SDK contract — is the page using one of the SDKs above? Check options.
Log the finding with:
- Reproduction steps (click X, observe Y did not happen)
- Network capture (or absence of network call)
- Console output
- Suspected code location
- Suggested fix (typically: SDK option, callback wire-up, or state lift)
Severity guide
| Symptom | Severity |
|---|---|
| Click does nothing AND user has no feedback | Critical |
| Click does network call, server 5xx, no error UI | Critical |
| Optimistic UI commits, server rejects, no rollback | Critical |
| Click does something but logs warning | High (auto via console budget) |
| Click works but no success toast | Medium (feedback gap) |
| Click works but slight delay before feedback | Low (polish) |
The Hard Gate stays: any console error or warning during a click handler = High minimum.
Coverage publication
Publish at the end of Phase 3:
LIVE INTERACTION SMOKE COVERAGE
Controls tested: 47 of 47 inventoried
Silent failures found: 2 (both High — see findings H-3, H-4)
SDK contracts verified: @ai-sdk/react ✓, better-auth ✓, TanStack Query ✓Long-Running Check-In Pattern
For audits and other autonomous work expected to run >30 minutes, set up a companion /loop that fires every 15 minutes alongside the main session. The loop is not firing the audit itself — the main session is doing that continuously. The loop is a supervisor prompt that records findings, confirms activity, and gives the long-running work a durable home.
Why it helps
1. Grounding. The session can't drift silently for an hour. Every 15 minutes it has to articulate what it's found, which forces coherence. 2. Progress preservation. Findings land in a file every 15 minutes, not just at the end. If the session dies or compacts, the record is current. 3. Self-termination signal. When a check-in returns "no new findings, nothing actionable left", that's the natural stopping condition. 4. Readable trail. You (or a later reviewer) can read the progression of findings in chronological chunks, not as one monolithic end-of-run dump.
When it's worth it
| Task duration | Check-in loop? |
|---|---|
| < 15 min | No — finishes before the first check-in fires |
| 15–30 min | Optional — probably not worth the setup overhead |
| 30–60 min | Recommended — catches drift, preserves partial progress |
| 1+ hours | Strongly recommended — without it, context fragility is real |
Not a replacement for the skill's own internal loop-to-exhaustion. A complement that adds journalling and supervision on top.
Setup
Use /loop infrastructure with a 15-minute interval and a supervisor prompt. Example:
/loop 15m Progress check on the UX audit in progress.
1. What page or area are you currently on?
2. What findings have you recorded since the last check-in? (new bugs, friction, edge cases)
3. Any state worth noting — blocked, waiting on input, exploring a side thread?
4. Append progress to the report file. Then continue where you were.Key shape: the supervisor prompt is short, structured, asks for recording, and explicitly says "continue where you were" so the main session doesn't restart or switch contexts.
When to tear down
Stop the check-in loop when:
- The audit produces a "no new findings" check-in twice in a row (exhaustion)
- The audit hits the natural end of the scenario battery and fix-and-verify
- The user signals the audit is complete
CronDelete <id> — session-scoped, no persistence needed.
Connection to heartbeats
Structurally identical to specialist heartbeat patterns, just at a finer scale:
- Nightly learnings (22:03 cron) — end-of-day consolidation, writes to journal/persona/discoveries
- Hourly heartbeat (every :17) — operational presence, scans channels, records signals
- 15-min check-in (this pattern) — task-scoped supervision during long autonomous work
Same mechanism (scheduled prompt), same benefit (keep long-running work grounded and recorded), applied at three different periodicities for three different scopes.
Gotcha
Don't mistake this for a work-doing loop. If the /loop fires and the main session isn't already running the audit, the check-in prompt will land in an idle session and probably start a new audit — wrong shape. This pattern only works when the main session is actively working when the check-in fires. Set the loop up after starting the audit, not before.
Multi-Pane Stress
For apps with collapsible UI — sidebars, members panels, thread asides, drawers, sheets, info rails — the worst layout bugs hide in pane-overlap zones.
The 2026-04-29 vite-flare-starter vertical-text-in-spaces bug only manifested at viewport width 1024-1280px with all three panes open simultaneously. A single-viewport audit (even at the standard 1440px) saw nothing. Multi-pane stress is the only phase that catches this class.
The matrix
For every app with > 1 collapsible pane, run this matrix:
| Viewport | Panes | What to capture | Common bug class |
|---|---|---|---|
| 1920 | All open | Baseline screenshot | Should always work |
| 1440 | All open | Common dev resolution | Slight squeeze visible here |
| 1280 | All open | Where layout collapses start | High-bug zone |
| 1024 | All open | Tablet landscape | Catastrophic-bug zone |
| 1024 | 2-pane (drop one) | Verify graceful degradation | Should fold cleanly |
| 1024 | 1-pane (mobile-style) | Should fold cleanly | Edge: orphan controls |
| 768 | Default (most collapsed) | Tablet portrait | Touch targets, stacking |
| 375 | Mobile baseline | Mobile | Hamburger nav, sheet UX |
For each combination:
1. Resize to the target viewport (use the browser tool's resize, not just CSS media queries — real resize triggers layout recalculation) 2. Open the panes per the matrix (use the actual UI controls — opening via dev-tools state injection misses the real animation race) 3. Scroll the longest content in the main pane (long message, long form, long table) 4. Capture a screenshot with the pane combination labelled in the filename: 1280-all-3-panes.png 5. Run the layout-detection JS (snippets below) to flag overflow / clipping / vertical-text-stacks 6. Read console — multi-pane resize often surfaces ResizeObserver loop errors
Layout-detection JS (paste into console)
Vertical text stack detector
This catches the catastrophic case where text wraps one character per line — only happens when a flex/grid container collapses to single-character width.
// Detect text containers that have collapsed to single-character widths
(function detectVerticalTextStacks() {
const findings = [];
const all = document.querySelectorAll('p, span, div, td, li, article, section, [role="article"]');
for (const el of all) {
const r = el.getBoundingClientRect();
if (r.width === 0 || r.height === 0) continue;
const text = (el.innerText || '').trim();
if (text.length < 5) continue;
// Heuristic: very narrow column AND multi-line. Tune ratio: chars vs lines.
if (r.width < 30 && r.height > r.width * 3) {
findings.push({
selector: el.tagName.toLowerCase() + (el.id ? `#${el.id}` : '') +
(el.className && typeof el.className === 'string' ? '.' + el.className.split(/\s+/).slice(0, 2).join('.') : ''),
width: r.width.toFixed(1),
height: r.height.toFixed(1),
textPreview: text.slice(0, 30) + (text.length > 30 ? '…' : ''),
});
}
}
console.warn('Vertical-text-stack candidates:', findings);
return findings;
})();Overflow / clip detector
// Detect children that overflow their parents in horizontal direction
(function detectHorizontalOverflow() {
const findings = [];
const all = document.querySelectorAll('*');
for (const el of all) {
if (el.scrollWidth - el.clientWidth > 1) {
const r = el.getBoundingClientRect();
if (r.width === 0) continue;
findings.push({
selector: el.tagName.toLowerCase() + (el.id ? `#${el.id}` : ''),
scrollWidth: el.scrollWidth,
clientWidth: el.clientWidth,
overflow: el.scrollWidth - el.clientWidth,
});
}
}
console.warn('Horizontal overflow candidates:', findings.slice(0, 20));
return findings;
})();Min-content collapse detector
// Detect elements whose actual width is below their min-content
// (signals a missing min-w-* / min-w-0 misuse)
(function detectMinContentCollapse() {
const findings = [];
const candidates = document.querySelectorAll('main, [role="main"], .timeline, .messages, [data-pane]');
for (const el of candidates) {
const r = el.getBoundingClientRect();
// Heuristic: pane primary content with width < 200px is almost always wrong on desktop
if (r.width < 200 && r.height > 100) {
findings.push({
selector: el.tagName.toLowerCase() + (el.id ? `#${el.id}` : ''),
width: r.width.toFixed(1),
height: r.height.toFixed(1),
suspect: 'pane width < 200px on what looks like primary content — verify min-width or fold-trigger',
});
}
}
console.warn('Min-content collapse candidates:', findings);
return findings;
})();Pane discovery — find what to test
If you don't know which panes the app has, discover them:
// Find probable panes (collapsible / dismissible regions)
(function discoverPanes() {
const found = [];
// Look for explicit pane markers
document.querySelectorAll('[data-pane], aside, [role="complementary"], .sidebar, .drawer, .sheet, [data-sidebar], [data-drawer]').forEach(el => {
const r = el.getBoundingClientRect();
if (r.width === 0) return;
found.push({
selector: el.tagName.toLowerCase() + (el.id ? `#${el.id}` : '') + (el.dataset.pane ? `[data-pane="${el.dataset.pane}"]` : ''),
width: r.width.toFixed(1),
role: el.getAttribute('role') || el.dataset.pane || 'unlabeled',
});
});
console.log('Probable panes:', found);
return found;
})();Look for the controls that toggle them — usually a hamburger button, a chevron, an X close button, or a keyboard shortcut. Test each combination.
Common bug patterns by combination
1280px, all panes open
- Vertical text stacking — main column squeezed below word-min-width. Cause:
min-w-0on a flex container without setting amin-w-[260px]on the children. Always log a finding here. - Truncated headings without ellipsis —
text-truncatenot applied to a flex child. - Overlapping panes — z-index conflicts at the boundary.
1024px, all panes open
- Catastrophic squeeze — main pane drops below readable width. Should auto-fold one of the panes.
- Buttons clipped or wrapped weirdly — toolbar items push to a second line.
- Modal trap — opening a modal at this size makes it unfit-the-screen, no escape.
1024px, 1 pane (mobile-style)
- Orphan controls — buttons that only existed in the closed-pane don't reappear in the simplified layout.
- Filter / sort lost — interactions wired only to the desktop layout.
768px, default
- Touch targets — < 48×48px. Apple HIG / Material both require this.
- Stacked layouts that look broken — components stacked but not gap'd.
- Text adjacent to actions — copy that wraps under a button it was supposed to label.
375px, mobile
- Sheet UX broken — bottom sheets that don't dismiss / don't render full-height.
- Hamburger nav — opens but can't close, or closes when it shouldn't.
- Forms below the fold — Send / Save buttons require scroll to reach.
Findings format
Every multi-pane stress finding should record:
ID: H-3
Layer: Visual / Interaction
Severity: High
Surface: /dashboard/spaces/:id
Viewport: 1280×800
Panes open: sidebar, members, thread (3 / 3)
Persona: SME owner
Reproduce:
1. Resize browser to 1280×800
2. Sign in, navigate to any space
3. Open the members panel (click the 👥 icon in the top-right)
4. Click any message → opens thread aside
5. Look at the message timeline column
Observed: message text wraps one character per line — vertical column ~24px wide.
Expected: message text wraps at word boundaries within the available column width (≥ 260px).
Evidence:
- .jez/audit-evidence/2026-04-29/spaces-1280-3panes.png
- .jez/audit-evidence/2026-04-29/spaces-1280-3panes-devtools.png
Suspected location: src/client/modules/spaces/pages/SpacePage.tsx:200 — `<main className="flex-1 min-w-0">`
Suggested fix: Replace `min-w-0` with `min-w-[260px]` to enforce minimum readable width, OR auto-fold the members pane at lg breakpoint.Automation hint — capture the matrix in one pass
If running Playwright, this loop captures the full matrix in ~2 minutes per route:
const viewports = [1920, 1440, 1280, 1024, 768, 375];
const paneCombos = ['all-open', '2-open', '1-open', 'all-closed'];
for (const vw of viewports) {
for (const combo of paneCombos) {
await page.setViewportSize({ width: vw, height: 800 });
await openPanes(page, combo); // helper specific to the app
await page.evaluate(detectVerticalTextStacks);
await page.screenshot({ path: `evidence/${route}-${vw}-${combo}.png` });
}
}For Chrome MCP, the loop is run interactively — same protocol, manual viewport changes.
When to skip multi-pane stress
Only skip if the app has zero collapsible panes (a single-column form-only app, a CRUD admin with no aside).
For everything else, this phase is mandatory. The bugs that live here are by definition invisible to single-viewport audits.
Component Perfection Checklist
Component-level audit that page-level audits miss. Adapted from the v2 audit-skill-gemini methodology with enforcement: every checkbox cites a proof artefact (screenshot, console line, DOM selector, code reference). No proof = doesn't count.
No vibe-PASS
Every [✓] PASS row needs: 1. The check name 2. *One sentence on why it passes*, with concrete evidence 3. A proof artefact (screenshot path / DOM selector / computed style readout / code reference)
[✓] Hover delta perceptible is a vibe-PASS — rejected by the audit-the-audit meta-check. The same row with sentence proof: [✓] Hover delta perceptible — primary button background shifts hsl(220 100% 45%) → hsl(220 100% 38%), ~7% lightness drop. Proof: evidence/btn-hover.png. That counts.
The discipline applies symmetrically to FAIL rows — they get reproduction + evidence + suspected location (the standard findings format). See audit-output-discipline.md Rule 4 for the full pattern.
Five-Layer Hierarchy
Every finding in the perfection checklist is tagged with one of:
1. Architecture (the bones) — navigation, hierarchy, information architecture 2. Interaction (the joints) — buttons, forms, toggles behave exactly as expected 3. Visual Logic (the skin) — design system consistency, layout, rhythm 4. Feedback (the voice) — system communicates state (loading, success, error) 5. Delight (the soul) — micro-interactions, polish, premium feel
Plus the standard severity (Critical / High / Medium / Low). Together they drive the Perfection Roadmap (Quick Wins / Structural / Advanced Polish).
1. Buttons & Triggers
| Check | What's verified | Common failure |
|---|---|---|
| State Clarity | Distinct hover, active, focus, and disabled states | All states identical, or focus invisible |
| Intent Matching | Button colour matches action weight (Primary / Ghost / Danger) | Destructive button styled the same as safe button |
| Micro-Copy | Text starts with a verb (Export CSV not Data) | Noun-only labels ("Settings", "Files") on action buttons |
| Loading State | Spinner or "Sending..." text on click; button disabled during action | Button stays enabled, user double-clicks |
| Visual Hierarchy | One primary CTA per screen | 3+ buttons competing for attention |
| Click target ≥ 44×44 | Mobile thumb-friendly | Tiny "x" close buttons |
| Hover delta perceptible | ~10% brightness shift on hover | 5% imperceptible delta |
Proof per check: screenshot of each state, computed CSS, before/after click screenshot.
2. Inputs & Forms
| Check | What's verified | Common failure |
|---|---|---|
| Persistent Labels | Labels visible after the user starts typing | Placeholder-as-label disappears |
| Input Masks | Phone, date, currency formatted as the user types | User has to know format |
| Inline Validation | Errors after the user leaves the field, OR "strength" check during typing | Errors only on submit (lost context) |
| Error Clarity | Specific guidance: "Enter a valid email (e.g. name@domain.com)" | "Invalid input" |
| Defaulting | Common fields pre-filled (country by IP, most-common selection) | User has to fill every field every time |
| Autocomplete attributes | autocomplete="email", name, tel set correctly | Browser can't auto-fill |
| Tab order logical | Tab moves through fields in reading order | Tab jumps to a button mid-form |
| Required-field marking | * or "(required)" before fields, not as failure message | User submits, then learns what's required |
| Keyboard type on mobile | inputmode="numeric" for phone, type="email" for email | iOS/Android shows wrong keyboard |
| Persistent state on navigate-away | Drafts saved or restored on return | Data lost when user clicks back |
Proof per check: screenshot showing each label + input pair, type into invalid value (capture error), navigate away + back (verify state).
3. Navigation & Hierarchy
| Check | What's verified | Common failure |
|---|---|---|
| "Where am I?" test | Look at any screen, immediately know module + sub-section | No active state in nav |
| Click depth | Primary user task ≤ 3 clicks from dashboard | Common task buried 5 clicks deep |
| Search logic | Handles typos; "No results found for [Query]" is clear | Empty results page with no hint |
| Sticky headers | Nav stays accessible on long pages OR hides/reveals intelligently | Nav scrolls away, no way back to top |
| Breadcrumbs | Present on detail pages, accurate hierarchy | Detail pages with no parent context |
| Back button preserves state | Back to a list returns to the same scroll, filter, sort | Back resets to top, filters cleared |
| Deep links work | Sharing a link to a specific record opens it correctly | Deep link 404s or shows blank |
Proof per check: screenshot of nav at every level, click count for primary task, search test with typo, deep link test.
4. Visual Coherence
| Check | What's verified | Common failure |
|---|---|---|
| Icon consistency | All icons from the same family (Lucide, Heroicons, etc.) | Mixed sharp + rounded icons |
| Empty states | Helpful illustration / icon, copy explaining the feature, primary CTA | Blank table with column headers |
| Border radii | Identical across buttons / cards / modals (consistent 4px / 8px / 12px scale) | Random radii: 3px, 7px, 10px |
| Contrast ratio | All text passes WCAG AA (4.5:1 body, 3:1 large text) | Light grey text on white |
| Spacing scale | All paddings / margins / gaps land on a documented scale (e.g. 4 / 8 / 12 / 16 / 24 / 32) | 22px padding where 24px belongs |
| Colour tokens | Body / borders / dividers from var(--*) tokens, not raw values | text-gray-450 energy |
| Typography scale | 3-5 sizes globally; consistent line heights | Random font sizes: 15px, 17px, 22px |
Proof per check: eyedropper sample, computed-styles inspection, contrast-ratio readout, side-by-side icon comparison.
Cross-reference visual-polish.md for the full AI-tell catalogue.
5. Mobile & Touch
| Check | What's verified | Common failure |
|---|---|---|
| Tappable surface ≥ 48×48px | Links and buttons large enough for thumb without hitting neighbours | Tiny pagination / close buttons |
| Keyboard optimisation | inputmode="numeric" for numbers, type="email" for email | Wrong keyboard pops up |
| Swipe gestures discoverable | Swipe-to-delete is hinted (icon, edge nudge) OR has an explicit alt | Hidden swipe with no fallback |
| Bottom-sheet UX | Drag handle, gesture-dismissable, full-height when needed | Sheet won't dismiss, content scrollable behind |
| Thumb zone respected | Primary actions reachable in lower 1/3 of viewport | Save button at top of long scroll |
| No hover-only affordances | Everything works without a hover state | Menus that only open on hover |
Proof per check: 375px screenshot with finger overlay, viewport check, gesture replay.
6. Performance & Feedback
| Check | What's verified | Common failure |
|---|---|---|
| Skeleton screens | Skeleton loaders during fetch, NOT a blank white page | Blank page → sudden content |
| Skeleton shape parity | Skeleton matches loaded layout (same column widths, row heights) | Spinner-on-blank disguised as skeleton |
| Success toasts | Non-intrusive "Success" appears after background tasks | No confirmation, user wonders if it worked |
| Confirmation modals | Used ONLY for high-stakes irreversible actions | Confirmation on every save |
| Optimistic UI | List updates immediately; rolls back on server reject | Optimistic update with no rollback handling |
| Time to first interaction | < 1.5s on a fast connection | Loading spinner > 3s with no indication |
| Long action progress | "Sending email to 12 recipients..." with specific count | "Please wait..." (wait for what?) |
| Error recovery | Retry button on failed requests | Generic "Something went wrong" with no path forward |
Proof per check: filmstrip from action to feedback, network throttled to Slow 3G, screenshot of error state with retry visible.
Component States Matrix (six per major component)
For every list, card, table, dashboard widget, form, detail panel, capture all six visual states:
| State | What to verify | Common findings |
|---|---|---|
| Default | Loaded, idle | Baseline |
| Loading (skeleton) | Shape parity with loaded layout | Spinner-on-blank, layout shift on load |
| Empty | Helpful copy + CTA | Blank void; "no data" without next-action |
| Partial loaded | First batch usable while later batches arrive | Misleading "complete" framing while still streaming |
| Error | Recoverable? Retry available? | Generic "Something went wrong"; raw stack trace |
| Disabled | Why disabled is visible (tooltip, helper text) | Greyed-out button with no path to enable |
Skeleton loaders specifically — a spinner on top of a blank container is not a skeleton. The skeleton's job is to prevent layout shift and signal what's coming. Verify both: throttle the network to keep the skeleton visible long enough to inspect.
Proof requirements
Every checkbox in the report must cite at least one of:
- Screenshot at
.jez/audit-evidence/<date>/<surface>-<state>.png - Console line with the warning text and timestamp
- DOM selector that reproduces the issue
- Code reference (
src/components/Button.tsx:42) - Computed style readout (specific CSS values)
- Network request (URL, method, status, latency)
A check marked "PASS" without a proof artefact is rejected. Forces the agent to actually look, not vibe.
Severity guide for component findings
| Severity | When to assign | Example |
|---|---|---|
| Critical | User cannot complete primary task | Save button no-op |
| High | Confusion, friction, trust damage; OR console warning; OR layout collapse | Loading spinner with no end; placeholder-as-label |
| Medium | Suboptimal but workable | Inconsistent border radii; weak hover delta |
| Low | Polish | 1-2px alignment; underline offset |
How to run the perfection checklist
The checklist is a per-component scan, not a per-page scan. After thread traversal and element exhaustion, walk every distinct component class on the app:
1. List every type of component (buttons primary / ghost / destructive, inputs text / select / textarea, cards, modals, toasts, etc.) 2. For each type, find one canonical instance to audit 3. Walk that instance through all relevant rows of the checklist 4. Capture proof for each row 5. If the finding is shared across many instances of the same type, log it once with a count: "Affects 47 instances of <Button variant='primary'>"
This makes the audit's findings systemic-first, instance-second. Fixing one root cause lifts many surfaces.
Layer × Severity → Roadmap
The Perfection Roadmap groups findings by layer + severity:
- Quick Wins (24-48h) — Low / Medium polish in Visual Logic + Delight layers
- Structural Updates (1-2 weeks) — High / Critical in Architecture + Interaction layers
- Advanced Polish (post-launch) — Low in all layers, plus Delight enhancements
Critical Architecture issues block ship. Low Delight issues wait for a design pass. The roadmap framing makes prioritisation obvious instead of being one ranked list.
Performance Budget (pragmatic, not Google CWV)
"Perfect app" means nothing if it's slow. v2's screenshot sweep can't see slow.
But chasing Google's Core Web Vitals "Good" thresholds is for marketing teams optimising landing pages. For app interiors that real users sit inside, the bar is user-perceivable: does it feel snappy enough that nobody mentions it?
This sets a pragmatic floor — well above broken, well below CWV-strict.
Thresholds (hard gates)
| Metric | Pragmatic threshold | CWV "Good" reference | Severity if exceeded |
|---|---|---|---|
| LCP (Largest Contentful Paint) | < 4.0s | < 2.5s | High |
| CLS (Cumulative Layout Shift) | < 0.25 | < 0.1 | High |
| INP (Interaction to Next Paint) | < 500ms | < 200ms | High |
| TTI-equivalent (page interactive) | < 5.0s | < 3.8s | Medium |
If all four are within the pragmatic threshold, the app feels fine. Tighter thresholds are noise unless the user explicitly cares about CWV (SEO landing pages, e-commerce checkout, paid-acquisition funnels).
Measurement — Performance API in the browser
Don't run Lighthouse per page (30-60s each, network-throttling-dependent, noisy). Use the browser's PerformanceObserver to capture metrics inline. ~1 second per page.
// In the browser eval, after the page has loaded
await page.evaluate(() => {
return new Promise((resolve) => {
let lcp = 0, cls = 0, inp = 0
const lcpObs = new PerformanceObserver((list) => {
for (const e of list.getEntries()) lcp = e.startTime
})
lcpObs.observe({ type: 'largest-contentful-paint', buffered: true })
const clsObs = new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
if (!e.hadRecentInput) cls += e.value
}
})
clsObs.observe({ type: 'layout-shift', buffered: true })
// INP only fires after a user interaction; capture the worst seen
const inpObs = new PerformanceObserver((list) => {
for (const e of list.getEntries()) inp = Math.max(inp, e.duration)
})
inpObs.observe({ type: 'event', buffered: true, durationThreshold: 16 })
setTimeout(() => {
const nav = performance.getEntriesByType('navigation')[0]
resolve({
lcp: Math.round(lcp),
cls: Math.round(cls * 1000) / 1000,
inp: Math.round(inp),
ttfb: nav ? Math.round(nav.responseStart - nav.requestStart) : null,
domContentLoaded: nav ? Math.round(nav.domContentLoadedEventEnd) : null,
loadComplete: nav ? Math.round(nav.loadEventEnd) : null,
})
}, 1500) // give LCP + CLS time to settle
})
})When to measure
Once per audit, on the most representative page. For a typical app, that's the page real users hit most often — usually the dashboard or the main work surface (chat, inbox, or list view).
Add to the audit run-time budget: ~3-5 seconds for the perf check. Do NOT run on every page — that triples audit time for diminishing returns. Spot-check 1-2 routes.
If the spot check exceeds threshold, expand to all primary routes.
Throttling
The Performance API in headless Playwright runs unthrottled, which paints LCP/INP optimistically. For a realistic check:
// Playwright: emulate slow 4G + 4× CPU
await page.context().route('**/*', (route) => {
setTimeout(() => route.continue(), 50) // crude delay
})Or use Chrome DevTools MCP's performance_start_trace which has built-in throttling profiles.
If you measure unthrottled, document it in the report. A site that LCP=3.5s unthrottled is probably 6+s on real 4G — flag as a likely real-world fail.
Hard-gate scorecard addition
Verdict block extension:
Performance (pragmatic budget, /dashboard sample):
LCP: [N]s [GREEN/RED] (threshold 4.0s)
CLS: [N] [GREEN/RED] (threshold 0.25)
INP: [N]ms [GREEN/RED] (threshold 500ms)
TTI: [N]s [GREEN/RED] (threshold 5.0s)If any is RED, audit auto-Fails until fixed. Findings logged with the specific metric and the suspected source (large hero image, late-loading font, render-blocking script, hydration cost).
Common offenders
| Symptom | Likely cause |
|---|---|
| LCP > 4s | Hero image not optimised; main script chunks too large; bundled font loaded synchronously |
| CLS > 0.25 | Web fonts swapping in late; ads/embeds loading without reserved space; client-rendered content above the fold |
| INP > 500ms | Heavy click handlers (re-renders on every keystroke); sync JSON parse; uncovered hydration mismatch |
| TTI > 5s | Bundle too big; too many synchronous module imports; SSR rendering on every nav |
Diagnose with Chrome DevTools Performance tab (or performance_start_trace via MCP) — capture a 5-second trace, look for long tasks > 50ms, render-blocking resources, layout-shift events.
When to relax
Tighter thresholds (CWV-strict) apply when:
- The page is in an acquisition funnel (landing, signup) — bounce rate punishes slow
- The user pays for the page (e-commerce checkout)
- There's a specific contractual/legal uptime/perf SLA
For internal tools, AI agent UIs, dogfood apps, builder-mode pages — pragmatic threshold is the right bar. Don't over-optimise to win a Google score that nobody asked for.
What this skill doesn't measure
- Time to first byte at the CDN edge (use Cloudflare / Vercel analytics)
- Server-side response time (use the worker / app's own logs)
- Real-user CWV (use Google Search Console for production)
Those are operational metrics. This audit measures the rendering experience for a real user pulling the page now.
Last Updated: 2026-04-30
Related skills
How it compares
Pick ux-audit for fast automated structural a11y scans; pair with manual keyboard walks for focus-trap and tab-order issues axe does not cover.
FAQ
What is ux-audit?
Walk through a live web app AS a real user to find usability + behavioural bugs that static reviews miss. REQUIRES proof of interaction (typing, clicking, sending, observing) befor
When should I use ux-audit?
Walk through a live web app AS a real user to find usability + behavioural bugs that static reviews miss. REQUIRES proof of interaction (typing, clicking, sending, observing) befor
Is ux-audit safe to install?
Review the Security Audits panel on this page before production use.