references/a11y-automation.md
# 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.
```js
// 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`:
```yaml
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, intentional
```
Default 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
references/audit-config.md
# 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 401` on 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
```yaml
# .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):
```json
{
"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:
```yaml
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:
```yaml
# .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.
references/audit-output-discipline.md
# 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: Add `aria-label="Edit message"` to `src/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%)` to `hsl(220 100% 38%)`, ~7% lightness drop. Eyedropper proof: `evidence/btn-hover.png`.
- [✓] Focus state visible — 2px ring in `--ring` token, 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](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.
references/auth-expired-handling.md
# 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 `Incomplete` report
- 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
references/browser-tools.md
# 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](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):
```bash
# 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-load
```
Implementation 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**:
```bash
sips -Z 1440 path/to/screenshot.png --out path/to/screenshot.png
```
Or batch:
```bash
for f in .jez/audit-evidence/2026-04-29/*.png; do
sips -Z 1440 "$f" --out "$f"
done
```
Capture-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: 900
```
This 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
```bash
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 close
```
Use `-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 812
```
references/data-seasoning.md
# Data 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](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 |
| Email | 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.
references/interaction-manifest.md
# 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 `zzzzz` to 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_messages` or Playwright `page.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: 0
```
If 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.
references/live-interaction-smoke.md
# 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](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 ✓
```
references/long-running-check-in-pattern.md
# 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.
references/multi-pane-stress.md
# 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.
```js
// 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
```js
// 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
```js
// 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:
```js
// 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-0` on a flex container without setting a `min-w-[260px]` on the children. **Always log a finding here.**
- **Truncated headings without ellipsis** — `text-truncate` not 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:
```ts
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.
references/perfection-checklist.md
# 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](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](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.
references/performance-budget.md
# 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.
```js
// 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:
```js
// 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
references/persona-lock.md
# Persona Lock
The audit cannot start without a locked persona. Without one, every finding drifts toward "looks fine" and the audit produces a verdict that's defensible from no one's perspective.
A locked persona is a single named user, with goals, constraints, pain triggers, and wins. Once locked, every finding must be defensible from this persona's perspective. If a finding only makes sense for a power user when the locked persona is an SME owner, the finding is wrong-persona — log it as a different persona's concern or drop it.
## How to lock the persona
Source the persona in this order:
1. **Argument** — if the user provided one in the trigger ("ux audit as a busy insurance broker"). Use as-is.
2. **Project personas** — read these in order, take the first that matches:
- `.jez/audit-personas/<slug>.md`
- `.jez/personas/default.md`
- `.jez/personas/<app-name>.md`
- `.jez/personas/<role>.md`
3. **Ask once, then write a file** — *"Who uses this app and what are they trying to get done?"* Capture the answer, write it to `.jez/audit-personas/<slug>.md` for the next audit, then proceed.
Lock the persona by writing the chosen persona at the top of the audit report:
```markdown
**Persona Lock**: SME owner — runs a 4-person fitness studio
**Source**: .jez/audit-personas/sme-owner.md
**Locked at**: 2026-04-29 14:30 AEST
```
## Persona file format
Each persona file at `.jez/audit-personas/<slug>.md`:
```markdown
---
slug: sme-owner
name: SME Owner
locked: 2026-04-29
---
# SME Owner
## Who they are
Owns a 4-person fitness studio. Books clients, takes payments, runs marketing,
all herself. Knows her business cold; isn't a software person.
## Goals (what they want to do)
- Get a class scheduled in under a minute
- See who paid this week without scrolling
- Send a "next class" reminder without crafting a message from scratch
## Constraints
- 30-60s windows between physical tasks
- Phone in pocket, laptop sometimes
- Tolerates 2 clicks max for routine tasks; 3 makes her swear
## Pain triggers (what makes them close the tab)
- Acronyms or jargon (CRM, SaaS, "campaign" instead of "newsletter")
- Forms with > 5 fields
- "Sign up for a free trial" gates between her and the thing she's doing
- Loading spinners that last > 1.5s with no indication of what's happening
- Anything that asks her to learn a concept ("workspaces", "instances", "agents")
## Wins (what makes them come back)
- The thing she just did is visibly there at the top of the screen
- A teammate gets a notification she didn't have to opt into
- She gets through a routine task in fewer steps than last time
- One-tap sharing of receipts / schedules / class lists
## How to audit AS this persona
- Skim every screen for 2 seconds before reading anything carefully
- Read every label out loud: would she say this word in conversation?
- For every form: would she fill it correctly on her first try?
- For every list: can she find one specific record by name in < 5 seconds?
- For every button: does the verb match what she's about to do?
```
## Persona library examples
Common patterns the audit should adopt depending on the product. The personas below skew B2B-SaaS / dev-tooling — for consumer / e-commerce / CMS / mobile-first / WordPress / agency project types, see [project-adaptation.md](project-adaptation.md) "Persona library by app type".
### SME owner
Time-pressed, low tech comfort, mobile-first, hates jargon. Wants tasks completed in 1-2 clicks. Triggers at acronyms.
### Power user
Knows the product cold, lives in keyboard shortcuts, wants density. Will use Advanced settings. Triggers at hand-holding tooltips.
### First-time developer
Read the docs. Trying to wire up an integration. Tolerates jargon if it's accurate, hates jargon that's wrong. Will look at the network tab. Triggers at silent failures.
### Casual user (consumer-app)
Came via a marketing link. No commitment yet. Will close the tab if anything is unclear. Triggers at sign-up gates before value, at long forms, at "select a plan" before "show me what this does".
### Internal staff (B2B tools)
Onboarded by a colleague, trained verbally. Knows the workflow, doesn't know the product. Triggers at UI changes that contradict what they were taught.
### Client (read-only / restricted role)
Was given access by a staff member. Doesn't know what they have access to or why. Triggers at "you don't have permission" without explanation.
## Always-also: first-time-user lens
In addition to the locked persona, every multi-page feature must also pass the **first-time-user lens** (mandatory, see SKILL.md Phase 3). This is non-negotiable. Engineers writing screens know what every field means; brand-new users don't. The lens catches that gap regardless of the locked persona.
## Persona drift detection
Mid-audit, watch for these signs of drift:
- Findings that say "a developer would..." (you've drifted to dev persona)
- Findings that say "an admin should..." (drifted to admin if locked persona was SME)
- Justifying density or jargon as "professional" (drifted toward power user)
- Skipping multi-pane stress because "the persona is mobile-only" (drift via skip)
When drift is detected: stop, re-read the persona file, re-walk the affected slice with the locked persona's eyes.
## Multi-persona audits (persona-overload pattern)
For apps with materially different user types (B2B SaaS with admin + staff + client), run separate audits per persona. One report per persona file, then a synthesis pass at the end. Don't try to audit "as a generic user" — that produces no findings worth shipping.
**This is the persona-overload pattern, validated in production**: 2026-05-04 vite-flare-starter overnight audit ran 4 persona-locked passes (P1-P4), produced 56 distinct findings with implicit cross-pass deduplication, and 21/21 Critical+High fixed inline. ~14 findings/pass converged toward consistent quality across personas without inflating to 4 × single-pass count.
For thorough audits on substantial apps, this is the recommended default — not an alternative to single-persona audits but a stronger version of them. Run order:
1. Lock persona A → run audit → file report at `.jez/artifacts/ux-audit-<date>-<persona-slug>.md`
2. Reset state (clear cookies, fresh test-auth user, fresh seed if needed)
3. Lock persona B → run audit → file report
4. Repeat for personas C, D, ...
5. **Synthesis pass**: pass all per-persona reports to a fresh sub-agent with this prompt:
> *"Read these N persona-locked audit reports. For each finding across all reports, mark KEEP / DUPLICATE / GENERIC. KEEP findings list which personas surfaced them. DUPLICATE findings (same root cause, different persona) get merged with a 'Surfaced by:' list. GENERIC findings get dropped. Output a single consolidated findings list."*
The synthesis pass is the same shape as the single-audit self-critique pass (see [audit-output-discipline.md](audit-output-discipline.md) Rule 2) — applied across reports instead of within one. Output: one consolidated report with merged Top 5, deduplicated findings, and a "Surfaced by" annotation per finding showing which personas hit it.
Findings surfaced by multiple personas are higher-confidence — they're not persona-specific quirks. Findings surfaced by only one persona are signals that persona has different needs (often the highest-judgement findings).
Cost: roughly N × single-audit time minus dedup savings. For an app with 3-4 distinct user types, this is the right level of investment.
## Persona vs role vs lifecycle position
Three orthogonal axes:
- **Persona**: who they are (SME owner, power user, first-time developer)
- **Role**: what they're allowed to do (admin, viewer, billing)
- **Lifecycle position**: when they joined (founder, first invitee, later joiner)
Same persona × different role = different audit (Scenario 8, Second User by Role).
Same persona × different lifecycle = different audit (Scenario 9, Lifecycle Position).
Different persona = different audit, full stop.
The locked persona pins the first axis. Scenarios 8 and 9 stress the other two.
references/playwright-killer-flows.md
# Playwright Killer-Flow Tests
Audits find what's broken now. Tests prevent it from breaking again. After a v2 walkthrough surfaces the killer flows, write Playwright tests for them so regressions never ship silently.
This file is starter examples + patterns. Adapt to the project's stack (TanStack, Next.js, etc.) and CI setup.
## Why kill flow tests beat broader test suites
Most apps don't have time to write comprehensive E2E suites. The 80/20 is: write tests for the bugs that *would have hurt most* if they shipped. The 6 bugs found in vite-flare-starter on 2026-04-29 are exactly that class — each has a one-line Playwright assertion that would have blocked the deploy.
**Aim**: 10-15 tests, each ~10-30 lines, one per killer flow. Run on every deploy. Total runtime < 2 minutes.
## Where these tests live
Recommended file structure:
```
e2e/
├── critical-flows.spec.ts # the killer flows
├── regression-tests.spec.ts # tests added per regression caught
├── fixtures.ts # auth helpers, seed helpers
└── playwright.config.ts # config with retry, baseUrl, viewport
```
Run via `pnpm test:e2e` (or `npm`). CI runs on every PR and on every main push.
## Test patterns by bug class
### Pattern 1: "Action clears the trigger state"
Catches: send button doesn't clear input, save button doesn't reset form, submit-then-redirect leaves stale form data.
```ts
import { test, expect } from '@playwright/test';
import { signIn } from './fixtures';
test('spaces: send clears input', async ({ page }) => {
await signIn(page);
await page.goto('/dashboard/spaces/test-space');
const input = page.locator('textarea[placeholder*="message"]');
await input.fill('hello world');
await page.locator('button[aria-label="Send"]').click();
// Critical assertion: input cleared within 1s
await expect(input).toHaveValue('', { timeout: 1000 });
});
```
### Pattern 2: "Layout doesn't collapse"
Catches: vertical-text stacking, panel squeeze, min-content failures. Reproduces the multi-pane bug.
```ts
test('spaces: thread does not collapse timeline below readable width', async ({ page }) => {
await signIn(page);
await page.setViewportSize({ width: 1280, height: 800 });
await page.goto('/dashboard/spaces/test-space');
// Open the members panel + the thread aside
await page.locator('[data-pane-trigger="members"]').click();
await page.locator('[data-message-id]').first().click();
// The main column should never be < 200px wide
const main = page.locator('main');
const width = await main.evaluate(el => el.getBoundingClientRect().width);
expect(width).toBeGreaterThan(200);
});
```
Run this at multiple viewports to cover the full multi-pane stress matrix:
```ts
const viewports = [1024, 1280, 1440, 1920];
for (const vw of viewports) {
test(`spaces: thread keeps timeline width at ${vw}px`, async ({ page }) => {
await signIn(page);
await page.setViewportSize({ width: vw, height: 800 });
// ... same as above
expect(width).toBeGreaterThan(200);
});
}
```
### Pattern 3: "No console output during action"
Catches: lurking warnings, deprecation notices, protocol mismatches. The 2026-04-29 VoiceClient warning was visible weeks before the audit caught it.
```ts
test('chat: console emits no warnings on mount', async ({ page }) => {
const messages: { type: string; text: string }[] = [];
page.on('console', msg => {
if (['warning', 'error'].includes(msg.type())) {
messages.push({ type: msg.type(), text: msg.text() });
}
});
await signIn(page);
await page.goto('/dashboard/chat');
await page.waitForLoadState('networkidle');
// Allowlist known unfixable warnings (e.g. third-party tracker)
const filtered = messages.filter(m => !m.text.includes('[allowed-warning]'));
expect(filtered, JSON.stringify(filtered, null, 2)).toEqual([]);
});
```
### Pattern 4: "Single state transition counts correctly"
Catches: @-mention double-insert, optimistic UI duplicating items, double-click submission.
```ts
test('spaces: @-mention does not duplicate', async ({ page }) => {
await signIn(page);
await page.goto('/dashboard/spaces/test-space');
await page.locator('textarea').type('@ass');
await page.locator('text=@assistant').first().click();
// After picking, exactly one mention pill in the input parts
const pills = page.locator('[data-slot="mention-pill"]');
await expect(pills).toHaveCount(1);
});
test('spaces: send does not duplicate on double-click', async ({ page }) => {
await signIn(page);
await page.goto('/dashboard/spaces/test-space');
await page.locator('textarea').fill('test message');
const send = page.locator('button[aria-label="Send"]');
// Double-click in rapid succession
await Promise.all([send.click(), send.click()]);
// Only one new message appears
await expect(page.locator('[data-message-id]:has-text("test message")')).toHaveCount(1, { timeout: 2000 });
});
```
### Pattern 5: "Network call succeeds with expected payload"
Catches: silent SDK contract violations, missing options, wrong endpoints.
```ts
test('approve action sends decision to server', async ({ page }) => {
await signIn(page);
let approvalRequest: any = null;
page.on('request', req => {
if (req.url().includes('/api/tool-approvals')) {
approvalRequest = { method: req.method(), payload: req.postDataJSON() };
}
});
await page.goto('/dashboard/chat');
await page.locator('button:has-text("Approve")').first().click();
// Verify request actually fired with the decision
await expect.poll(() => approvalRequest).toBeTruthy();
expect(approvalRequest.method).toBe('POST');
expect(approvalRequest.payload).toMatchObject({ decision: 'approved' });
});
```
### Pattern 6: "First-time user can complete primary task"
Catches: onboarding gaps, mandatory fields without defaults, jargon labels. Directly maps to the first-time-user lens.
```ts
test('first-time user: can create their first space in 3 clicks', async ({ page }) => {
// Use a fresh signup, not a seeded test account
await page.goto('/signup');
await page.locator('input[name="email"]').fill('audit-test@example.com');
await page.locator('input[name="password"]').fill('audit-test-123!');
await page.locator('button:has-text("Sign up")').click();
await page.waitForURL(/\/dashboard/);
// From the dashboard, count clicks to space creation
let clickCount = 0;
page.on('click', () => clickCount++);
// The expected path
await page.locator('button:has-text("Create space")').click();
await page.locator('input[name="name"]').fill('My first space');
await page.locator('button:has-text("Create")').click();
// Verify the space exists
await expect(page.locator('h1:has-text("My first space")')).toBeVisible({ timeout: 5000 });
expect(clickCount).toBeLessThanOrEqual(3);
});
```
### Pattern 7: "Network response is observable to user"
Catches: 5xx on the wire that the user never sees.
```ts
test('save shows error toast on server failure', async ({ page }) => {
await signIn(page);
await page.route('**/api/settings', route => route.fulfill({ status: 500, body: 'server error' }));
await page.goto('/dashboard/settings');
await page.locator('button:has-text("Save")').click();
// User MUST see the error
await expect(page.locator('[role="alert"]')).toContainText(/error|failed|try again/i, { timeout: 3000 });
});
```
## Auth helpers (fixtures.ts)
```ts
import type { Page } from '@playwright/test';
export async function signIn(page: Page) {
await page.goto('/signin');
await page.locator('input[name="email"]').fill(process.env.TEST_USER_EMAIL ?? 'test@example.com');
await page.locator('input[name="password"]').fill(process.env.TEST_USER_PASSWORD ?? 'test-password');
await page.locator('button[type="submit"]').click();
await page.waitForURL(/\/dashboard/);
}
export async function seedSpace(page: Page, name = 'test-space') {
// POST to test-only seed endpoint, OR use UI flow.
// ...
}
```
## Playwright config (playwright.config.ts)
```ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
reporter: 'html',
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:5173',
trace: 'on-first-retry',
video: 'retain-on-failure',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium-desktop', use: { ...devices['Desktop Chrome'], viewport: { width: 1440, height: 900 } } },
{ name: 'chromium-laptop', use: { ...devices['Desktop Chrome'], viewport: { width: 1280, height: 800 } } },
{ name: 'mobile', use: { ...devices['iPhone 13'] } },
],
});
```
## Seed-data strategy
Killer-flow tests need predictable state. Three strategies:
1. **Per-test setup via API** — call POST `/api/test/seed` (server-only test endpoint) to create the state needed
2. **Per-test setup via UI** — slower but tests the actual flow
3. **Snapshot DB** — restore a known DB state before each suite (heaviest, most reliable)
For Cloudflare Workers + D1, the `/api/test/seed` route gated behind a `TEST_MODE` env is usually fastest.
## CI integration
Recommended GitHub Actions setup:
```yaml
- name: Install Playwright
run: pnpm exec playwright install --with-deps chromium
- name: Run E2E tests
run: pnpm test:e2e
env:
PLAYWRIGHT_BASE_URL: ${{ secrets.STAGING_URL }}
TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }}
TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
```
Run on every PR. Block merge on failure.
## Cadence — what to write tests for
Write a test the moment you find a bug that:
- Wasn't caught by the audit (audit failure → new test)
- Is "obvious in hindsight" (vertical text, send-not-clearing — these surfaces deserve tests forever)
- Touches money / data integrity / auth
- Has shipped twice in regression form (third regression must become a test)
Don't write tests for:
- Visual polish that's not load-bearing (use visual regression instead)
- Features that change weekly (test would churn faster than it adds value)
- Things that are obvious from a unit test (component API, function output)
## The audit-test virtuous cycle
The pattern that ships:
1. Audit (v2) finds a bug
2. Fix the bug
3. Write a Playwright killer-flow test that catches the bug
4. CI runs the test on every deploy
5. Bug never returns silently
This is the methodology shift the 2026-04-29 post-mortem advocates: from "ship, audit, find, fix" to "find before shipping". The audit produces the audit catalogue; the tests turn each entry into a regression gate.
After 6-12 months of disciplined application, the killer-flow suite is the most-trusted artifact in the project — it encodes every painful lesson the team has learned.
references/project-adaptation.md
# Project Adaptation
The audit skill ships with examples drawn from a specific stack (Cloudflare Workers + React 19 + Vite + TanStack Query + better-auth + Radix UI + Jezweb's `.jez/` convention). Those examples are illustrative — the discipline is the same across stacks.
This file is the adaptation guide for non-default stacks.
## Path conventions — fallback chain
The skill defaults to `.jez/` (Jezweb's convention) but uses a fallback chain. The audit reads existing directories first; falls back to creating one only if nothing matches.
| Convention | Path | Used by |
|------------|------|---------|
| Jezweb | `.jez/audit-evidence/<date>/` | jezweb.com.au internal projects |
| Common docs | `docs/audits/<date>/` | many open-source projects |
| Project-root | `audits/<date>/` | minimal projects |
| Hidden-folder | `.audit/<date>/` | conventional fallback if nothing else exists |
Same fallback applies to:
- **Personas**: `.jez/audit-personas/` → `docs/personas/` → `personas/` → `.audit/personas/`
- **Audit config (allowlist)**: `.jez/audit-config.yml` → `audit-config.yml` → `.audit/config.yml`
- **Audit reports**: `.jez/artifacts/ux-audit-YYYY-MM-DD.md` → `docs/audits/ux-audit-YYYY-MM-DD.md` → `audits/<date>.md`
The audit reads project conventions before defaulting. If the project's CLAUDE.md says *"audit reports go in `quality-reports/`"*, use that.
## URL discovery by stack
Phase 1 step 3 (URL discovery) needs adaptation per stack. The skill default checks `wrangler.jsonc`, falls back to `lsof -i :5173 -i :3000 -i :8787`. Stacks below have different conventions:
| Stack | Where to look | Common ports |
|-------|---------------|--------------|
| **Cloudflare Workers** | `wrangler.jsonc` `pattern` / `custom_domain` | 8787, 5173 (Vite dev) |
| **Vite (any framework)** | `vite.config.ts` `server.port` | 5173 default |
| **Next.js** | `package.json` scripts; `.env.local` `NEXT_PUBLIC_*` | 3000 default |
| **Remix** | `vite.config.ts` or `remix.config.js` | 3000 default |
| **Rails** | `config/database.yml`, `bin/rails server` | 3000 default |
| **Django** | `manage.py runserver`, `settings.py` `ALLOWED_HOSTS` | 8000 default |
| **Laravel** | `.env` `APP_URL`, `php artisan serve` | 8000 default |
| **PHP (Apache/Nginx)** | virtualhost config; check `localhost` or `*.test` | 80, 443, 8080 |
| **WordPress** | `wp-config.php` `WP_HOME` / `WP_SITEURL`; Local-by-Flywheel sites at `*.local` | 80, 443, custom |
| **Symfony** | `bin/console server:start` | 8000 default |
| **Static / Astro / Hugo** | `astro.config.mjs` / `hugo.toml`; deployment preview URL | 4321 (Astro), 1313 (Hugo) |
**Discovery order regardless of stack**:
1. Check the project's CLAUDE.md / README for "URL" or "dev server"
2. Check stack-specific config file (per table above)
3. Run `lsof -i :PORT_RANGE` or `ss -tlnp | grep LISTEN` for matching dev ports
4. Ask the user
Always prefer the deployed URL over local dev — local misses CDN, CORS, latency, real auth.
## Test-auth alternatives (headless authentication)
The skill's headless test-auth pattern is **better-auth-specific** (`/api/test-auth/cookies` endpoint with X-Test-Auth secret). Other auth stacks need different approaches:
### better-auth (default in skill)
```bash
curl -sX POST "$URL/api/test-auth/cookies" \
-H "X-Test-Auth: $TEST_AUTH_TOKEN" \
-d '{"email":"alice@test.app.local"}' > cookies.json
```
### NextAuth / Auth.js
Two patterns:
1. **Test-only credentials provider** — register a `Credentials` provider gated on `NODE_ENV !== 'production'` that accepts a test token, returns a session for any email
2. **Direct session injection** — write a session row to the DB with a known token, set the `next-auth.session-token` cookie in Playwright
```ts
// Playwright before-each: bypass the auth flow
await page.context().addCookies([{
name: 'next-auth.session-token',
value: process.env.TEST_SESSION_TOKEN,
domain: 'localhost',
path: '/',
}]);
```
### Lucia
Use `lucia.createSession(userId, attributes)` server-side, write the session cookie manually. Wrap in a test-only `/api/test-auth` route gated on env.
### Rails Devise
Use Devise's `sign_in` helper in a test-only controller action:
```ruby
# Only mounted in test/dev
class TestAuthController < ApplicationController
def sign_in_as
user = User.find_or_create_by!(email: params[:email])
sign_in user
head :ok
end
end
```
### Django auth
Test view using `force_login`:
```python
# urls.py — only mounted when DEBUG = True
def test_login(request):
user, _ = User.objects.get_or_create(email=request.GET['email'])
login(request, user)
return HttpResponse(status=200)
```
### Custom JWT / API tokens
Mint a test JWT with a known secret, set in `Authorization` header for Playwright:
```ts
await page.setExtraHTTPHeaders({ Authorization: `Bearer ${process.env.TEST_JWT}` });
```
### Magic-link bypass
For magic-link auth (Clerk, Resend Auth, Supabase Auth): expose a test-only endpoint that mints a magic-link token and returns it, skipping email delivery.
### WordPress
Use WP-CLI or `wp_set_current_user()` / `wp_set_auth_cookie()` in a test mu-plugin gated on a constant. Or use a session-stealing test plugin.
**Pattern across all stacks**: gate test-auth on a server-side environment variable (`TEST_AUTH_ENABLED=true` only in dev/staging), use a token / shared secret to authorise, lock to email patterns like `*@test.<app>.local` to prevent real-user takeover.
## Seed-script architectures by ORM
The data-seasoning scenario assumes the project provides Day 0 / 1 / 7 / 30 seed scripts. The skill's example uses Drizzle/D1; here's the pattern across ORMs:
### Drizzle (Cloudflare D1, PostgreSQL, MySQL, SQLite)
```
scripts/seed/horizons/day-7.ts
import { db } from '../../../src/db';
import { faker } from '@faker-js/faker';
import { inboxItems, conversations } from '../../../src/db/schema';
await db.insert(inboxItems).values(
Array.from({ length: 75 }, () => ({...})),
);
```
### Prisma
```
prisma/seeds/horizons/day-7.ts
import { PrismaClient } from '@prisma/client';
import { faker } from '@faker-js/faker';
const prisma = new PrismaClient();
await prisma.inboxItem.createMany({ data: [...] });
```
### TypeORM / MikroORM
Equivalent: use the entity manager / repository, batch insert.
### Sequelize
```
scripts/seed/day-7.js
const { Inbox } = require('../../models');
await Inbox.bulkCreate(items);
```
### Rails ActiveRecord
```
db/seeds/horizons/day_7.rb
require 'faker'
75.times { Inbox.create!(...) }
```
CLI: `bundle exec rails runner db/seeds/horizons/day_7.rb`
### Django ORM
```
audit/management/commands/seed_horizon.py
from django.core.management.base import BaseCommand
from inbox.models import Item
class Command(BaseCommand):
def add_arguments(self, parser):
parser.add_argument('horizon', choices=['day-0', 'day-1', 'day-7', 'day-30'])
def handle(self, *args, **opts):
# ...
```
CLI: `python manage.py seed_horizon day-7`
### Laravel Eloquent
```
database/seeders/HorizonsSeeder.php
class HorizonsSeeder extends Seeder { /* ... */ }
```
CLI: `php artisan db:seed --class=HorizonsSeeder`
### WordPress
Custom WP-CLI command:
```
wp eval-file scripts/seed-horizon.php day-7
```
### Raw SQL
For projects without an ORM: SQL files at `scripts/seed/horizons/day-7.sql`, run via `psql -f` / `sqlite3 < file` / `wrangler d1 execute --file=`.
**Pattern**: each horizon = one script that resets state and seeds horizon-appropriate data with realistic time distribution. Use a faker-equivalent for realistic content. CLI per horizon: `<runner> seed:day-N`.
## Build verify by stack
Phase 1 capability tests + Phase 7 fix-and-verify both need stack-aware build commands:
| Stack | Type-check | Build | Run tests |
|-------|------------|-------|-----------|
| Node + TS | `tsc --noEmit` or `pnpm typecheck` | `pnpm build` | `pnpm test` / `pnpm vitest` |
| Next.js | included in `next build` | `next build` | `next test` / `vitest` |
| Cloudflare Workers | `tsc --noEmit` + Vite build | `wrangler deploy --dry-run` | `vitest` |
| Rails | n/a (Ruby) | `bundle exec rails assets:precompile` | `bundle exec rspec` / `rails test` |
| Django | `python manage.py check` | `collectstatic` | `pytest` / `python manage.py test` |
| Laravel | n/a (PHP) | `php artisan optimize` | `php artisan test` |
| WordPress | n/a | n/a | optional plugin test suite |
| Static (Astro/Hugo) | `astro check` / n/a | `astro build` / `hugo --minify` | optional |
| Mobile (React Native) | `tsc --noEmit` | `expo build` / native build | `jest` |
Detect by reading project files: `package.json` scripts, `Gemfile`, `requirements.txt`, `composer.json`, `manage.py`, `Cargo.toml`, `go.mod`, etc.
## SDK contract examples by ecosystem
The `live-interaction-smoke.md` SDK contract table is heavily React + TanStack + Radix biased. Equivalents in other ecosystems:
### Vue 3
- **Pinia stores** with `$reset()` not called in cleanup → state leaks between tests
- **vue-router** `beforeEach` guards must `next()` or navigation hangs
- **VueQuery** — same `queryKey` stale-results pattern as TanStack Query (it's a port)
- **Headless UI** — same Dialog focus-trap requirements as Radix
### Svelte / SvelteKit
- **Stores** without `derived` for computed state → re-render storms
- **Actions** that don't return `{ destroy }` → leaks
- **`+page.server.ts`** without proper `redirect()` semantics
### Angular
- **RxJS subscriptions** without `takeUntil(destroy$)` → memory leaks
- **Angular Material `<mat-dialog>`** — focus-trap vs Esc-key handling
- **Forms reactive vs template-driven mix** — silent validation loss
### Native browser / vanilla JS
- **Form validation** without `novalidate` → browser default messages override custom
- **`<dialog>` element** without `showModal()` — backdrop missing
### WordPress / PHP
- **Nonce checks** missing on AJAX endpoints → CSRF + silent failure
- **`wp_die()` vs `wp_send_json_error()`** — wrong error response shape breaks JS handlers
- **Block editor (Gutenberg)** — `useBlockProps()` not spread → missing styling
### iOS / SwiftUI (mobile)
- **`@Published` not on main actor** → UI updates from background thread
- **Sheets without `.presentationDetents`** → broken sizing on iOS 16+
- **List with `.id()`** missing — animations break
The general principle: any ecosystem has a class of "undocumented option that silently breaks behaviour if missing". The audit catches them by clicking + watching network + watching DOM. The catalogue in `live-interaction-smoke.md` is the React/TanStack flavour — read your stack's docs for the same shape.
## Reference apps by category
For visual-polish calibration, the skill defaults to Linear / Stripe / Vercel / Notion (B2B SaaS / dev-tooling). Different app categories want different references:
| Category | Reference apps |
|----------|---------------|
| **B2B SaaS / dev tools** | Linear, Stripe, Vercel Dashboard, Notion, Cron, Raycast |
| **B2C consumer** | Airbnb, Notion (consumer plan), Things 3, Dropbox |
| **E-commerce** | Stripe Checkout, Shopify storefront examples, Apple Store, Amazon |
| **CMS / content** | Ghost editor, Sanity Studio, Contentful, Webflow |
| **Mobile-first** | Cron mobile, Notion Calendar, Linear mobile, Things 3 |
| **Marketing / landing** | Stripe.com, Linear.app, Vercel.com, Apple product pages |
| **Editor / creative** | Figma, Canva, Linear's editor, Notion |
| **Email / messaging** | Superhuman, Hey, claude.ai, ChatGPT |
| **Internal admin / dashboard** | Stripe Dashboard, Vercel Dashboard, Cloudflare Dashboard, AWS Console (as anti-example) |
| **Public sector / accessibility-first** | gov.uk, healthcare.gov (post-redesign) |
| **News / publishing** | NYT, Stratechery, FT.com |
| **WordPress sites** | a polished agency reference site, the project's reference site, a competitor |
Pick 3 references per audit *from the right category*. Cross-category references confuse the calibration ("Linear is good and we're not Linear" is true but unhelpful for an e-commerce checkout).
## Persona library by app type
The default personas in `persona-lock.md` (SME owner, power user, first-time developer) are biased to B2B SaaS. Different app types want different personas:
### Consumer apps
- Curious newcomer (came via marketing, no commitment)
- Returning casual (uses 2-3 features)
- Devoted power user (knows every shortcut)
### E-commerce
- Browsing visitor (no intent to buy)
- Comparison shopper (multiple tabs open)
- Repeat buyer (knows the product)
- Gift purchaser (buying for someone else)
### CMS / publishing
- Writer (focuses on content)
- Editor (focuses on flow + queue)
- Designer (focuses on layout)
- Reader (the published audience)
### Internal admin tools
- Operator (does the daily work)
- Manager (reviews + approves)
- Auditor (compliance / external review)
- New hire (onboarding into the tool)
### Mobile-first
- One-thumb commuter
- At-the-desk multitasker
- Quick check between meetings
### WordPress / agency
- Site owner (non-technical, manages content)
- Content editor (writes posts, uploads media)
- Developer (PHP/theme work)
- Visitor (the published site's audience)
Each persona file follows the same format as `persona-lock.md` — Goals / Constraints / Pain triggers / Wins / How to audit AS them.
## When the project is brand-new (no prior conventions)
If the project has none of the above conventions established (no audit folder, no personas, no test-auth, no seed scripts), the audit's first run is partly *bootstrapping*:
1. **Pick a fallback path** for audit artefacts (`.audit/<date>/` is the safest hidden default)
2. **Ask + write a persona file** — capture in `.audit/personas/<slug>.md` for next time
3. **Note in the report** that the project lacks test-auth / seed scripts; recommend adopting them
4. **Verdict can still be Pass / Conditional Pass** — bootstrap-state isn't an audit failure
5. **First audit takes longer** than subsequent ones because it's establishing the conventions
Subsequent audits inherit the conventions and run faster.
## What stays universal
The discipline above all this:
- **Interaction-first methodology** — works on any UI, any stack, any framework
- **Hard gates** (console errors / warnings, network errors, layout collapse) — every web stack has these
- **Persona Lock + first-time-user lens** — always applies
- **Multi-pane stress matrix** — applies to any app with collapsible UI
- **Visual polish + AI-tells** — stack-agnostic; pattern recognition on rendered pixels
- **Component perfection checklist** (6 categories + 6 states) — categories are universal
- **Scenario battery** — all 11 scenarios apply to any web app (skip Data Seasoning if no time-distributed data)
- **Audit-the-audit meta-check** — universal
- **Top 5 + self-critique + smallest-patch + hold-this discipline** — universal
The skill's core is stack-agnostic. The examples in references illustrate one stack — this file shows how to translate them.
references/report-template.md
# UX Audit Report Template (v2)
Write the report to `docs/ux-audit-YYYY-MM-DD.md`. If `.jez/artifacts/` exists in the project, write to `.jez/artifacts/ux-audit-YYYY-MM-DD.md` instead.
Write incrementally — open the file at the start of the audit, append as you go. Don't batch at the end.
The report MUST include the verdict block at the top. If any required section is missing, the report is "Incomplete" not "Pass" — even if every observation looked fine.
## Required structure
```markdown
# UX Audit: [App Name]
═══════════════════════════════════════════════════════════
VERDICT: [Pass / Conditional Pass / Fail / Incomplete]
Persona Lock: [persona slug + path to persona file]
Locked at: YYYY-MM-DD HH:MM TZ
Surfaces audited: N / M routes
Interaction Manifest: [complete / incomplete: X of Y required entries]
Hard Gates:
Console errors: [count] [GREEN ✓ / RED ✗] ([N] allowlisted)
Console warnings: [count] [GREEN ✓ / RED ✗] ([N] allowlisted)
Network 5xx: [count] [GREEN ✓ / RED ✗]
Network 403/404 auth: [count] [GREEN ✓ / RED ✗] ([N] allowlisted)
Layout collapse: [count] [GREEN ✓ / RED ✗]
axe-core Critical: [count] [GREEN ✓ / RED ✗] ([N] allowlisted)
axe-core Serious: [count] [GREEN ✓ / RED ✗] ([N] allowlisted)
Performance (pragmatic, sampled on /[representative-route]):
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)
Findings:
Critical: [count]
High: [count]
Medium: [count]
Low: [count]
Time per phase (audit-the-audit meta-check):
Phase 1 (pre-flight): [N]m
Phase 2 (discovery): [N]m
Phase 3 (walkthrough): [N]m ← exhaustive must be ≥ 5m
Phase 4 (polish): [N]m
Phase 5 (stress): [N]m
Total: [N]m
Manifest plausibility:
Manifest entries: [N]
Time span first→last: [N]m
Median gap between: [N]s ← if < 0.5s, audit didn't actually
interact; verdict → Incomplete
Screenshots captured: [N] ← ≥ 2 per audited route
Console reads: [N] ← ≥ 1 per audited route
Network probes: [N] ← ≥ 1 per audited route
Self-critique pass (sub-agent):
Drafted: [N] Kept: [N] Generic dropped: [N] Duplicate merged: [N]
TOP 5 (ranked by impact × ease, senior-designer pick):
1. [F-id] Title — one-sentence reason this edges out the others
2. [F-id] Title — one-sentence reason
3. [F-id] Title — one-sentence reason
4. [F-id] Title — one-sentence reason
5. [F-id] Title — one-sentence reason
═══════════════════════════════════════════════════════════
**Date**: YYYY-MM-DD
**URL**: https://app.example.com
**Persona**: [persona name + 1-line summary]
**Browser**: Chrome MCP / Playwright MCP
**Viewport**: 1440×900 baseline + multi-pane stress + dark mode
**Reference extract used**: [path or "none"]
## Executive Summary
[2–3 sentences: overall impression, biggest concerns, the North Star fix]
## Coverage
| Dimension | Tested | Total | % | Notes |
|-----------|--------|-------|---|-------|
| Routes | 14 | 14 | 100% | |
| Interactive elements | 187 | 203 | 92% | 16 untested on /app/billing |
| Threads walked | 4 | 4 | 100% | |
| Scenarios completed (of 9) | 8 | 9 | 89% | Destructive Confidence skipped — no test account |
| Multi-pane stress combos | 24 | 24 | 100% | 6 viewports × 4 pane combos |
| Component states sampled (of 6) | 4 | 6 | 67% | Partial loaded + Disabled not reproducible on all components |
| Stress recipes | 13 | 15 | 87% | RTL + Print skipped (don't apply) |
| Live Interaction Smoke controls | 47 | 47 | 100% | |
**Elements not tested** (and why):
- /app/billing — 16 elements — requires paid plan, not available on test account
- Pagination on /app/clients — insufficient test data
## Findings
Each finding follows this format. **A finding without reproduction + evidence + suspected location is rejected.**
### Critical (blocks user task; data loss; security)
#### [C-1] Vertical-stacking message text in spaces makes Spaces unusable
- **Layer**: Visual / Interaction
- **Severity**: Critical
- **Surface**: /dashboard/spaces/:id
- **Viewport**: 1280×800, all 3 panes open (sidebar + members + thread aside)
- **Persona**: SME owner
**Reproduce**:
1. Sign in
2. Open any existing space
3. Open the members panel (👥 icon top-right, default on md+)
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**: 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`
- Console: 0 errors at point of capture
- Layout-detection JS output: `[{ selector: "main.flex-1.min-w-0", width: "184.5", height: "612.0", textPreview: "h\ne\nl\nl\no\n…" }]`
**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.
---
### High (confusion, friction, trust damage; console warning; layout collapse)
#### [H-1] VoiceClient protocol-mismatch warning logs on every chat-page mount
- **Layer**: Feedback
- **Severity**: High (auto via console budget Hard Gate)
- **Surface**: any /dashboard/chat/* route
- **Persona**: any
**Reproduce**:
1. Sign in
2. Navigate to /dashboard/chat
3. Open browser console
**Observed**: `VoiceClient: protocol mismatch (1.2 vs 1.4)` warning logs on every mount.
**Expected**: clean console.
**Evidence**: `.jez/audit-evidence/2026-04-29/chat-console.txt` (12 occurrences in 2 page loads)
**Suspected location**: `src/client/modules/voice/voiceClient.ts:42`
**Suggested fix**: bump VoiceClient SDK to 1.4-compatible version OR pin protocol to 1.2 in init.
---
### Medium (suboptimal but workable)
#### [M-1] [Short title]
- **Layer**: [Architecture / Interaction / Visual / Feedback / Delight]
- **Severity**: Medium
- **Surface**: [page]
- **Persona**: [persona]
- **Reproduce**: [steps]
- **Observed**: [what happened]
- **Expected**: [what should happen]
- **Evidence**: [paths]
- **Suspected location**: [file:line]
- **Suggested fix**: [recommendation]
---
### Low (polish)
#### [L-1] [Short title]
[Same format as above, abbreviated for polish-class findings]
## Thread Results
### Thread 1: [e.g. "Send a message in a space"]
- **Completable end-to-end**: Yes / No / Partially — [detail]
- **Click count**: [N] (estimated optimal: [N])
- **Decision points**: [N] — [list the moments]
- **Dead ends**: [N] — [list]
- **Interrupt recovery**: [closed tab at step 3 — state preserved? ✓ / ✗]
- **Did it end clearly?**: [Yes/No — detail]
- **Would I come back?**: [Yes/Maybe/No — why]
- **One thing to make it twice as easy**: [single highest-impact suggestion]
- **Screenshots**: [folder path or numbered list]
### Thread 2: [...]
[repeat]
## Interaction Manifest summary
```
INTERACTION MANIFEST — Coverage
Total entries logged: 84
Required minimum: 84 (14 pages × 6 entries)
Pages with complete manifest: 14 / 14
Pages with incomplete manifest: 0
```
Detailed manifest per page is at `.jez/audit-evidence/2026-04-29/manifests/`. Verdict cannot be Pass without complete coverage.
## Multi-Pane Stress Matrix
| Route | 1920 all open | 1440 all | 1280 all | 1024 all | 1024 2-pane | 1024 1-pane | 768 default | 375 mobile |
|-------|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| / | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| /dashboard/spaces/:id | ✓ | ✓ | ✗ (C-1 vertical text) | ✗ (C-1) | ✓ | ✓ | ✓ | ✓ |
| /dashboard/inbox | ✓ | ✓ | ✓ | ✓ | n/a | n/a | ✓ | ✓ |
## Scenario Battery Results
### Scenario 1 — First Contact
- Time to first value: [clicks/pages]
- Self-explanatory score: [1–5]
- Terminology barriers: [list]
- Threads easy to explain: [list]
- Threads requiring caveats: [list + the caveats]
- Threads unexplainable: [list]
- 2-minute guides written:
- *Thread 1*: "[actual guide written during the test]"
- *Thread 2*: "[...]"
### Scenario 2 — Interrupted Workflow
| Interruption | Data preserved? | Could resume? | Notes |
|-------------|----------------|---------------|-------|
| Navigate away mid-form | Y/N | Y/N | |
| Close tab + reopen | Y/N | Y/N | |
| Page refresh | Y/N | Y/N | |
| Back button | Y/N | Y/N | |
### Scenario 3 — Wrong Turn Recovery
| Wrong turn | Recovery method | Steps | Context lost? |
|-----------|----------------|-------|---------------|
| Wrong nav section | [method] | [N] | [what] |
| Wrong record | [method] | [N] | [what] |
| Wrong filter | [method] | [N] | [what] |
### Scenario 4 — Returning User
| Thread | First time | Second time | Improvement | Shortcuts found |
|--------|-----------|-------------|-------------|-----------------|
| Thread 1 | [N clicks] | [N clicks] | [faster/same/slower] | [list] |
- Awareness score (what changed): [1–5]
- Notification quality: [assessment]
- Missing "what's new" signals: [list]
### Scenario 5 — Keyboard Only
| Thread | Completable keyboard-only? | Blockers |
|--------|---------------------------|----------|
| Thread 1 | Y/N/Partially | [what stopped you] |
- Focus visibility: [always visible / sometimes / missing]
- Tab order: [logical / jumps around]
- Focus traps: [work correctly / broken on modal X]
- Documented shortcuts: [list or "none"]
### Scenario 6 — Heavy Data
| Area | At 500 records | At 1000+ |
|------|----------------|----------|
| List load time | [sec] | [sec] |
| Scroll smoothness | Smooth/Janky | |
| Search | Instant/Slow/Broken | |
| Filter | Useful/Overwhelming | |
| Pagination | Works/Slow/Broken | |
### Scenario 7 — Destructive Confidence
| Action | Confirmation | Destructive styling | Undo | Severity |
|--------|-------------|---------------------|------|----------|
| Delete client | [copy quality] | Y/N | Y/N | |
| Send invoice | [copy] | Y/N | Y/N | |
### Scenario 8 — Second User (Role)
| Thread | Completable as [role] | Broken pages | Leaked data | Error quality |
|--------|----------------------|--------------|-------------|--------------|
| Thread 1 | Y/N | [list] | [list] | [assessment] |
### Scenario 9 — Lifecycle Position
| Position | Setup flow shown? | Onboarding gap | Wayfinding works? | Empty/partial UI helpful? |
|----------|-------------------|----------------|-------------------|--------------------------|
| User #1 (founder) | Y/N + quality | [list] | Y/N + detail | Y/N + detail |
| User #2 (first invitee) | Y/N + quality | [list] | Y/N + detail | Y/N + detail |
| User #N (later joiner) | Y/N + quality | [list] | Y/N + detail | Y/N + detail |
- **Same-screen-three-faces coherence**: [pages where empty / partial / full all looked considered]
- **Onboarding scope creep**: [setup UI bleeding into screens it shouldn't]
- **Peer-feature dignity** (mentions, assignments, activity feeds with 1 / 2 / 50 users): [findings]
## Stress Recipe Results
| Recipe | Run? | Findings | Severity |
|--------|------|----------|----------|
| Race conditions (double-click submit) | ✓ | 0 | — |
| Slow network (3G throttle) | ✓ | 1 | M-2 missing skeleton |
| Reduced motion | ✓ | 0 | — |
| i18n long German | ✓ | 1 | H-3 button overflow |
| RTL Arabic | n/a | — | (no i18n yet) |
| CJK widths | n/a | — | (no i18n yet) |
| Empty / saturated states | ✓ | 2 | M-3 + L-1 |
| Offline mode | ✓ | 1 | C-2 silent data loss |
| Print stylesheet | n/a | — | (chat app) |
| Forced colors / high contrast | ✓ | 1 | H-4 buttons disappear |
| Keyboard only | ✓ | 0 | — |
| Screen reader | partial | 2 | H-5 + M-4 |
| Long-press / right-click | ✓ | 0 | — |
| Browser-back during in-flight | ✓ | 0 | — |
| Tab-restore | ✓ | 0 | — |
## Component States Coverage
For each major component sampled, capture which of the six states were verified:
| Component | Default | Skeleton | Empty | Partial | Error | Disabled | Notes |
|-----------|:---:|:---:|:---:|:---:|:---:|:---:|-------|
| Spaces list | ✓ | ✓ | ✓ | n/a | ✓ | n/a | |
| Message list (in space) | ✓ | ✗ (spinner) | ✗ (blank) | ✗ | ✗ | n/a | Skeleton + Empty + Partial + Error all missing |
| Send button | ✓ | n/a | n/a | n/a | ✓ | ✓ (no tooltip) | Disabled state has no "why" |
| Settings form | ✓ | n/a | n/a | n/a | ✓ | ✓ | |
## Visual Polish (AI-tell sweep)
| Route | Optical centring | Nested radius | Off-scale spacing | Vibe greys | Border drift | Shadow direction | Anim timings | Hover delta | Underline / uppercase | Symmetry / pacing |
|-------|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| / | ✓ | ✓ | ✗ (22px gap) | ✗ (#5a6168 raw) | ✓ | ✓ | ✗ (187ms drop) | ✓ | ✓ | ✓ |
| /dashboard/spaces/:id | ✗ (button text 2px high) | ✓ | ✓ | ✗ (3 raw greys) | ✓ | ✓ | ✓ | ✗ (3% delta) | ✓ | ✓ |
**Per-component findings**:
- **Buttons (primary)** — text mathematically centred across all uses; needs ~1px upward optical correction
- **Sidebar dividers** — 2 raw grey values mixed (`#d4d4d8` and `#dfe1e6`); should consolidate to `--border` token
- **Modal shadow direction** — `-2px x-offset` on settings modal disagrees with app-wide `0 x-offset` light source
**Severity tally**:
| Severity | Count | Pattern type |
|----------|-------|-------------|
| High | 2 | Stacked AI-tells on primary CTA component |
| Medium | 4 | Pattern-level (raw greys app-wide, animation timings off canonical) |
| Low | 7 | Single-instance |
## Component Perfection Checklist (Phase 4)
- [x] Buttons & Triggers — see [perfection-checklist.md](perfection-checklist.md) for the 7 checks per category
- [x] Inputs & Forms — proof: ../audit-evidence/inputs-2026-04-29/
- [ ] Navigation & Hierarchy — incomplete (didn't test mobile sticky behaviour)
- [x] Visual Coherence — proof captured
- [x] Mobile & Touch — proof: 375px walkthrough
- [x] Performance & Feedback — proof: throttled-3G recordings
## Network Errors (detected during browsing)
| Endpoint | Status | Page | Severity | Notes |
|----------|--------|------|----------|-------|
| `GET /api/boards/users` | 403 | /app/boards/123 | High | Likely route collision with /:boardId |
| `POST /api/settings/theme` | 403 | /app/settings | High | Permission check failing |
| `GET /api/reports/summary` | 500 | /app/dashboard | Critical | Server error |
## Console Errors
| Type | Count | Page | Severity | Notes |
|------|------|------|----------|-------|
| `VoiceClient: protocol mismatch` warning | 12 | /dashboard/chat | High | See H-1 |
| `Warning: Each child in a list should have a unique "key" prop.` | 3 | /dashboard/spaces | High | React key warning |
## What Works Well
Positive findings — patterns to preserve and replicate:
- [e.g. "Empty state on /app/clients has a clear CTA and explanation"]
- [e.g. "Create flow lands on the new record, not back at the list"]
- [e.g. "Keyboard shortcut palette (Cmd+K) works from every page"]
## Perfection Roadmap
### Quick Wins (24-48 hours)
1. **Fix VoiceClient warning** — addresses H-1. Bump SDK or pin protocol. Est. effort: S.
2. **Standardise sidebar grey to `--border` token** — addresses M-1. Single CSS var change. Est. effort: S.
3. **Add aria-label to dismiss buttons** — addresses M-4 screen reader. Est. effort: S.
### Structural Updates (1-2 weeks)
4. **Add `min-w-[260px]` to spaces main column** — addresses C-1 vertical text. Refactor SpacePage layout. Est. effort: M.
5. **Add error toast on settings save 5xx** — addresses C-2 offline silent loss. Wire up `onError` to toast layer. Est. effort: M.
6. **Migrate to design-token greys app-wide** — addresses M-1 + 4 other pattern findings. Touches ~30 files. Est. effort: L.
### Advanced Polish (post-launch)
7. **Add skeleton screens to message list, spaces list, inbox** — addresses Component States gap.
8. **Animation timings to canonical 150/200/300/500ms across the app**.
9. **Visual regression baselines via Playwright + git LFS**.
## Fix-and-Verify (if run)
| Finding | Severity | Fix applied | Verified |
|---------|----------|------------|----------|
| C-1 Vertical text in spaces | Critical | commit 987525f | ✓ |
| H-1 VoiceClient warning | High | commit a1b2c3d | ✓ |
| M-1 Raw grey divider | Medium | — | ⏳ deferred |
**Summary of this session's fixes**: 2 Critical + 1 High fixed and verified. 4 deferred to next sprint.
## Hold this in your hands
[One paragraph, ~150 words, plain prose. No bullets. Persona's voice allowed if it sharpens the assessment. Says what the app is, what it nearly is, and what's between the two. 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. Format + worked examples in [audit-output-discipline.md](audit-output-discipline.md).]
> *Example*: "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."
## Killer-flow tests recommended
After this audit, write these Playwright tests to prevent regression:
1. `spaces: send clears input within 1s` — covers send-button-doesn't-clear regressions
2. `spaces: thread keeps timeline width ≥ 200px at 1024-1280` — covers C-1 class
3. `chat: console emits no warnings on mount` — covers H-1 class
4. `spaces: @-mention does not duplicate` — covers any picker-double-insert
5. `auth: error toast appears on settings save 5xx` — covers C-2 class
See [playwright-killer-flows.md](playwright-killer-flows.md) for starter test code.
```
## Guidelines
- **Verdict block goes at the top.** First thing the reader sees. No exceptions.
- **Hard gates can't be downgraded.** A console warning is High minimum. A 5xx is Critical automatically.
- **Every finding cites proof.** Screenshot path + console line / DOM selector / network response. No proof = rejected.
- **Reproduction steps are mandatory.** A finding without "1. Sign in 2. Click X 3. Observe Y" is rejected.
- **Suspected location** must be `file:line` not "the chat module somewhere".
- **Coverage publishes ratios.** Inventoried ÷ tested. Readers can see what wasn't tested.
- **Roadmap groups by effort + impact.** Not one ranked list — three buckets.
- **What Works Well matters.** Prevents the report reading as pure criticism. Preserves good patterns.
- **Append as you go.** Don't try to remember findings to write them at the end.
## Severity definitions (sharper)
- **Critical** — User CANNOT complete a primary task. OR data loss. OR security exposure. OR Network 5xx that the user doesn't see. OR optimistic UI commits and silently fails to roll back.
- **High** — User gets confused or takes the wrong path. OR console error / warning during walkthrough. OR layout collapse at any tested viewport. OR network 403/404 on authenticated pages. OR placeholder-as-label. OR loading state that never ends.
- **Medium** — Friction; user succeeds with extra effort. OR weak hover delta. OR inconsistent border radii / icon families. OR pattern-level token violations.
- **Low** — Polish. 1-2px alignment. Single-instance off-scale spacing. Letter-spacing on uppercase missing.
A console error or layout collapse is automatically High *minimum*. The category "Medium console warning" does not exist in this skill.
references/round-trip-workflows.md
# Round-Trip Workflow Integrity
## Why this exists
Code reviews catch the *outbound* leg of a workflow — "click X on page A, navigate to page B, complete action". They miss the *return* leg — "go back to page A, did the new state appear?"
The bug pattern: a mutation on page B creates/changes data tied to a parent context A, but B's mutation only invalidates B's own query. A's cached state is stale on return. The user sees an empty list, thinks the action failed, retries, creates a duplicate (or worse — gives up and assumes the system is broken).
Static analysis can't catch this — it's a TanStack Query / SWR / Apollo cache-invalidation gap that only surfaces under interaction.
## The smoking-gun example (2026-04-30, vite-flare-starter)
```
1. Open project /dashboard/projects/X — empty conversation list
2. Type prompt + click "Start chat" — navigates to /dashboard/chat?projectId=X
3. Conversation creates, agent replies — works fine on the chat side
4. Click "Back to project" → /dashboard/projects/X
5. Conversation list STILL shows empty state
6. Hard reload → conversation appears
```
Server side was fine. Conversation persisted. But the project-detail TanStack query (`['projects', id]`) didn't refetch because the chat page only invalidated `['conversations']`.
User-facing symptom: "I'm not sure how I get back, the project is just empty if I go back, maybe I can stay in the project when I'm working in a project?" — three problems stacking (weak breadcrumb, sidebar disorientation, stale cache) that all compound the perception of a broken workflow.
## The protocol — required during Phase 3 walkthrough
For every workflow that traverses pages (A → B → A pattern), exercise the full round-trip:
```
1. Open page A. Capture: state, count of items, latest timestamp, current URL.
2. Trigger an action on A that navigates to B (a button, a link, a form-submit).
3. Complete a mutation on B (send a message, create a thing, approve an item).
4. Navigate back to A. Use the discoverable back affordance (breadcrumb,
browser back, sidebar item) — NOT a hard reload.
5. Verify A's state reflects the new data:
- Item count incremented?
- New row visible?
- Latest timestamp updated?
- Unread badge updated?
6. ALSO verify the back affordance was discoverable:
- Visible without hover at first paint
- Labelled clearly ("Back to {parent}", not just "←")
- Sized like a control, not decoration
- Sidebar still highlights the parent context (or has a sub-state)
```
If A is stale or the back affordance is hidden, log a finding.
## Where this lives in the audit
This is **mandatory** for every multi-page workflow. The Interaction Manifest must include a round-trip entry for every cross-page mutation observed. Without it, the verdict is `Incomplete`.
```
ROUND-TRIP MANIFEST — project → chat → project
[✓] 14:32:01 Open /dashboard/projects/X — capture: 0 conversations
[✓] 14:32:05 Type prompt "Quote a bathroom reno" + click Start chat
[✓] 14:32:06 Navigated to /dashboard/chat?projectId=X
[✓] 14:32:08 Verify breadcrumb visible at first paint:
"← Quoting / New chat" — yes, ~14em wide, font-medium
[✓] 14:32:10 Wait for AI reply (Workers AI Kimi K2.6) — completed
[✓] 14:32:14 Click breadcrumb back-link → /dashboard/projects/X
[✓] 14:32:15 Verify conversation appears WITHOUT reload:
expected 1 conversation, got 1 ("Quote a bathroom reno · just now")
[✓] 14:32:15 Verify sidebar highlight:
Projects highlighted (not "AI Chat"), no flicker
```
## Surface inventory — every round-trip in the app
Build this list during Phase 2 (Discovery). Each entry is a candidate for the Phase 3 round-trip protocol.
Examples for vite-flare-starter:
| Outbound (A → B) | Mutation on B | Inbound (B → A) | What A must reflect |
|---|---|---|---|
| Project page → Start chat | Conversation created with projectId | Breadcrumb back | New conversation in project's list |
| Spaces list → enter space → send message | Message persisted | Browser back | Last-message timestamp updated |
| Inbox → click finding (opens chat) | Finding marked read | Browser back | Unread count decreases on bell |
| Approvals → approve | Approval status → decided | Sidebar Approvals | Item gone from "Pending" tab |
| Approvals page approve | Approval status → decided | Sidebar Inbox | Item also gone from Inbox unified view |
| Inbox bulk-approve | Approval(s) status → decided | Header bell | Unread count decreases |
| Skills → save edit (with diff approval) | Skill body updated | Skills list page | Updated body shows on next select |
| Connections → connect Google | OAuth + connection persisted | Browser back to /connections | New connection appears in list |
| Routines /new → submit | Routine created | Sidebar Routines | New routine appears in list |
| Settings → API tokens → create | Token created | Same page | List updates without reload |
If any of these is NOT exercised in Phase 3, the audit is `Incomplete` for that surface.
## Detection heuristics
When you can't manually walk every workflow, these heuristics surface candidates fast:
### Heuristic 1 — Cross-key invalidation
Grep the codebase for `useMutation` and check what it invalidates. If the mutation can affect data shown on a *different* page that uses a *different* query key, the mutation MUST invalidate both.
```bash
# Find every mutation
grep -rn 'useMutation' src/client/modules/*/hooks/*.ts
# For each, check invalidations
grep -A 10 'useMutation' src/client/modules/X/hooks/Y.ts | grep invalidateQueries
```
For each mutation, ask:
- "What query keys consume the data this mutation affects?"
- "Are ALL of those keys invalidated?"
The classic miss: mutation invalidates `['inbox']` only, but the same data also appears under `['notifications']` (unread count) and `['approvals']` (approvals tab) — invalidating one leaves the others stale.
### Heuristic 2 — Header badges that depend on list state
Audit every UI badge / unread-count / pip indicator at the top of the layout. Each one is consumer of a query that ANY mutation across the app might invalidate.
Common badges that drift:
- Notification bell unread count
- Inbox unread tab count
- Approvals pending count
- Sidebar item dot ("you have something here")
For each badge, list every mutation that should affect it. Verify each mutation invalidates the badge's query key.
### Heuristic 3 — Breadcrumb size + placement
For every page reachable from a parent, the back-to-parent affordance must satisfy:
- Visible without hover at first paint (not opacity-0 or in an overflow menu)
- Labelled with the parent's name (not generic "Back")
- Sized like a primary control, not decoration
- In a consistent location across pages (top-left or in a breadcrumb row)
- Click target ≥ 32×32px (touch-friendly)
If the back affordance is a small pill near the page title, that's a smell — users don't read pills as wayfinding. Use breadcrumb-style links: `[← Parent name] / Current page`.
### Heuristic 4 — Sidebar context disorientation
When a workflow navigates from page A (sidebar item X highlighted) to page B (sidebar item Y highlighted), but A's context is still active (the user IS still working in X conceptually):
- Sidebar should ideally keep X highlighted (showing the parent context)
- OR the chrome should clearly show the parent context as a breadcrumb / pill / header strip
- WITHOUT one of those, the user feels like they switched apps
This is the single biggest source of "I'm not sure how I got there" UX feedback.
## Findings — how to log
A round-trip finding has two halves:
```
ID: H-N
Layer: Interaction
Severity: High
Surface: /dashboard/projects/:id (round-trip from /dashboard/chat)
Persona: SME owner
Reproduce:
1. Open project /dashboard/projects/X
2. Type a prompt, click Start chat
3. Wait for AI reply
4. Click "Back to project" breadcrumb (or browser back)
Observed: Project page shows empty conversations list — "Start a chat to keep
conversations organised and re-use project knowledge." User sees no evidence
the chat just happened.
Expected: Project page shows the new conversation as a row with title +
timestamp ("just now").
Reload-confirms-server-fine: hitting Cmd+R on the project page reveals
the conversation. Server-side persistence is fine. The bug is client-cache.
Evidence: .jez/audit-evidence/2026-04-30/01-project-stale.png
Suspected location: src/client/modules/chat/pages/ChatPage.tsx — the
useChat completion effect invalidates ['conversations'] but not
['projects', urlProjectId].
Suggested fix: when conversationId becomes set + urlProjectId is non-null,
invalidate ['projects', urlProjectId] alongside ['conversations'].
```
Keep severity `High` for round-trip cache misses — they look like data loss to the user.
## Companion checks
After fixing one round-trip bug, sweep for the same pattern across the codebase:
```bash
# Find every mutation that touches a child resource of a parent
grep -rn 'invalidateQueries' src/client/modules/ | grep -v 'queryKey:.*\[\(parent_key\)\]'
```
Then for each mutation that touches a relationship (e.g. conversation has projectId, finding has agentRunId, approval has conversationId, skill has agent), verify ALL parent query keys are invalidated.
## Anti-patterns
- **"Reload always works"** is not a fix. Users don't reload by reflex; they assume the system is broken.
- **`refetchOnWindowFocus: true` as a panacea** — relies on the user actually leaving the tab and coming back. Doesn't help when they navigate within the SPA.
- **Showing a stale list with a manual "Refresh" button** — the user has to know to click it. Better to invalidate on the mutation.
- **Polling intervals "every 30s"** — masks the bug, doesn't fix it. Wastes bandwidth + battery.
## Related skill rules
- `rules/api-client-alignment.md` — server-side returns the data correctly; the client just doesn't ask again.
- `rules/react-vite.md` — TanStack Query patterns + key alignment.
- `rules/think-in-contracts-not-code.md` — the right contract is "every mutation declares which parent query keys it affects". Implementations like `inbox-keys.ts` / `query-keys/index.ts` reduce drift by centralising the relationships.
**Last Updated**: 2026-04-30
references/scenario-tests.md
# Scenario Tests
Eleven structured tests that go beyond page-by-page evaluation. Each simulates a real-world situation that exposes problems traditional QA misses. Run all eleven during an exhaustive audit (skip only ones that genuinely don't apply — e.g. Data Seasoning on a one-shot calculator).
---
## 1. First Contact
**Premise**: You just started this job. Nobody has shown you the app. Figure it out — then prove you understand it by writing a 2-minute plain-English guide a colleague could follow.
**How to run**:
1. Navigate to the app's entry point (login page or dashboard).
2. Do NOT read any CLAUDE.md, README, or source code. No insider knowledge.
3. Try to answer these questions purely from using the app:
- What is this app for?
- What are the main things I can do?
- Where do I start?
- How do I do the most common task?
4. For each of the main threads, write a short plain-English guide:
```
"To [accomplish task], go to [section], click [button], fill in [fields],
then click [action]. You'll see [confirmation]. The [thing] will appear
in [location]."
```
5. Evaluate what you just wrote.
**What to report**:
- **Time to first value**: How many clicks/pages before you accomplished something real?
- **Self-explanatory score**: Could you figure out the app without help? (1–5 scale)
- **Missing onboarding**: Is there a welcome screen, guided tour, or empty-state guidance? Or just a blank dashboard?
- **Terminology barriers**: Any labels or section names you didn't understand?
- **Dead ends**: Places where you got stuck with no indication of what to do next?
- **Threads that were easy to explain**: These are well-designed workflows — preserve them.
- **Threads that required caveats**: "But first you need to..." or "Make sure you don't..." signals friction.
- **Threads you couldn't explain**: Where you had to say "then you kind of need to figure out..." — that's a comprehension failure.
- **Jargon you had to translate**: Words in the app that you replaced with plain language in your explanation.
- **Steps you had to invent**: Things the app doesn't guide you through.
**Output**: Include the actual written explanations in the report. They double as draft user documentation — and the gaps map directly to UX improvements.
**Severity guide**: If a new user can't figure out the primary task within 5 minutes of clicking around, that's **High**. If a thread can't be explained in under 5 steps without caveats, that's **Medium**. If a thread can't be explained at all, **High**.
---
## 2. Interrupted Workflow
**Premise**: Real users don't work in uninterrupted sessions. They start something, get a phone call, close the laptop, come back hours later.
**How to run**:
1. Start a multi-step task (creating a record, filling a long form, configuring settings).
2. Get halfway through — don't finish.
3. Simulate interruption:
- Navigate away to a completely different section
- Close the tab and reopen the app from the URL bar
- Refresh the page mid-form
- Open a different page in the same tab, then hit back
4. Try to resume the task.
**What to report**:
| Interruption | Data preserved? | Could resume? | Notes |
|-------------|----------------|---------------|-------|
| Navigate away mid-form | Y/N | Y/N | |
| Close tab + reopen | Y/N | Y/N | |
| Page refresh | Y/N | Y/N | |
| Back button | Y/N | Y/N | |
Also check:
- **Draft/autosave**: Does the app save partial work? Is there a "drafts" or "recent" section?
- **Return navigation**: Is there a "continue where you left off" or "recently viewed" feature?
- **Warning on abandon**: Does it warn before losing unsaved work?
- **Session persistence**: After reopening, are you still logged in? Does the app remember your last location?
**Severity guide**: Losing form data silently is **Critical**. No way to find where you left off is **High**. No warning when abandoning work is **High**.
---
## 3. Wrong Turn Recovery
**Premise**: Users click the wrong thing constantly. How forgiving is the app?
**How to run**:
1. For each main section, deliberately go to the wrong place:
- Click Policies when you meant Clients
- Open the wrong record from a list
- Start creating the wrong type of item
- Apply wrong filters to a list
2. Try to recover — get back to what you actually wanted to do.
**What to report**:
| Wrong turn | Recovery method | Steps to recover | Context lost? |
|-----------|----------------|-----------------|---------------|
| Wrong nav section | back/nav click/breadcrumb | [count] | Y/N — what was lost |
| Wrong record opened | back to list/browser back | [count] | filters lost? scroll position? |
| Wrong form started | cancel/back/close | [count] | any data lost from previous work? |
| Wrong filter applied | clear/reset/undo | [count] | other filters preserved? |
Also evaluate:
- **Cancel safety**: Does cancelling a form always work without side effects?
- **Undo availability**: Can you undo any action? Which ones are irreversible?
- **Navigation memory**: When you go back to a list, are filters/sort/scroll preserved?
- **Confirmation dialogs**: Do destructive actions have confirmations? Are they clear about what will happen?
**Severity guide**: Losing work because of a wrong turn is **Critical**. More than 3 clicks to recover from a wrong nav click is **High**. Lost filter state is **Medium**.
---
## 4. Returning User
**Premise**: Day one is about discovery. Day two is about efficiency. The user is back — is the app faster this time? Can they see what needs attention?
**How to run**:
*Part A — efficiency*
1. After completing the full walkthrough (you've used the app extensively), repeat the main workflow threads from the beginning.
2. Measure the difference:
- Is it faster the second time?
- Are there shortcuts you can now use?
- Does the app remember your preferences?
- Can you skip steps that were necessary the first time?
*Part B — what changed*
3. Now look at the dashboard or landing page after the walkthrough has created/modified data.
4. Try to answer: what happened since the last time I was here?
5. Check for these signals:
| Signal | Present? | Useful? |
|--------|----------|---------|
| Activity feed / recent activity | Y/N | Helpful / Noise / Missing |
| Notification badges/counts | Y/N | Accurate / Stale / Missing |
| "New since last visit" indicators | Y/N | |
| Modified/updated timestamps | Y/N | Visible or buried? |
| Assigned-to-me / needs-attention view | Y/N | |
**What to report**:
| Thread | First time (clicks) | Second time (clicks) | Improvement | Shortcuts found |
|--------|-------------------|---------------------|-------------|-----------------|
| [Thread 1] | [count] | [count] | faster/same/slower | [list] |
Plus:
- **Recent items**: Does the app surface recently viewed/edited items?
- **Defaults**: Does it remember last choices (filters, sort order, selected view)?
- **Keyboard shortcuts**: Are there any? Are they discoverable?
- **Bulk operations**: For repeat tasks, can you do them in batch?
- **Search**: Can you jump directly instead of navigating?
- **Bookmarkable deep links**: Can you save links to specific records/views?
- **Awareness score**: On a 1–5 scale, how quickly could a returning user understand what needs attention?
- **Notification quality**: Do notifications tell you what to do, or just that something happened?
- **Staleness risk**: Could a user miss something important because there's no alert or indicator?
- **Team awareness**: In a multi-user app, can you see what colleagues did?
**Power user friction** — things that are fine on day one but maddening on day 100:
- Confirmation dialogs you can't skip ("Are you sure?" for routine operations)
- Mandatory fields that are always the same value
- No way to duplicate a record (have to re-enter everything)
- Pagination that resets when you navigate back
**Severity guide**: No improvement between first and second use is **High** — the app doesn't learn from the user. Missing search on a data-heavy app is **High**. No way to tell what changed since last visit on a team app is **High**. Dashboard showing static content instead of actionable status is **Medium**.
---
## 5. Keyboard Only
**Premise**: Unplug the mouse. A power user, an accessibility user, or anyone on a long flight with a dead trackpad — can they still use the app?
**How to run**:
1. Do not touch the mouse/trackpad. At all. Don't use it to scroll, don't hover. Pure keyboard.
2. Attempt each main thread end to end using only:
- `Tab` / `Shift+Tab` — focus navigation
- `Enter` / `Space` — activate
- Arrow keys — menus, selects, lists
- `Escape` — close dialogs
- Any documented app-specific shortcuts
3. For each thread, note whether it can be completed and what breaks.
**What to report**:
| Thread | Completable? | Blockers |
|--------|--------------|----------|
| [Thread 1] | Y/N/Partially | [what stopped you] |
Also evaluate:
- **Focus visibility**: Is the focused element always clearly visible? Or is the focus ring invisible, or clipped, or missing entirely?
- **Tab order**: Is it logical (top-to-bottom, left-to-right, or matches visual grouping)? Or does it jump around?
- **Focus traps**: Do modals trap focus correctly? Does Escape close them?
- **Skip links**: Is there a "skip to main content" for keyboard users bypassing nav?
- **Click-only elements**: Are there buttons or controls that only respond to mouse click, not keyboard?
- **Custom components**: Custom dropdowns, date pickers, drag-and-drop — do they have keyboard equivalents?
- **Shortcut discovery**: Is there a `?` shortcut sheet? Or are shortcuts undocumented?
- **Shortcut conflicts**: Do app shortcuts conflict with browser defaults (Cmd+K, Cmd+S, etc.)?
**Severity guide**: Any thread that can't complete keyboard-only is **High** (higher if accessibility is a stated requirement). Missing focus indicators is **High**. Focus traps that don't release on Escape is **High**. No keyboard equivalent for a common action (drag-to-reorder, etc.) is **Medium**.
---
## 6. Heavy Data
**Premise**: The app works fine with 5 demo records. Real users have hundreds or thousands. Does it still work?
**How to run**:
1. If you can seed the database with 500+ records, do it. If not, use the heaviest real account available.
2. For each list/table view:
- Does it load in under 2 seconds?
- Does it virtualise or render all rows?
- Does scrolling stay smooth (60fps feel) or does it jank?
- Does the page memory balloon?
3. Test search:
- Search for something that matches 1 result — found quickly?
- Search for something that matches 100 results — is the result set useful?
- Search for something that matches 0 results — is the empty state helpful?
- Search with typos — is there fuzzy matching or did it fail silently?
4. Test filters:
- Apply a filter that matches many records — does the count update? Does the URL reflect the filter?
- Apply a filter that matches nothing — helpful empty state?
- Apply multiple filters — are they combined (AND) intuitively? Can you see which are active?
5. Test pagination:
- Navigate to page 50 directly (if page-jumping is supported)
- Change items-per-page — does the selection persist?
- Deep link to page 50 — does it load directly, or redirect to page 1?
**What to report**:
| Area | At 500 records | At 1000+ records |
|------|----------------|------------------|
| List load time | [sec] | [sec] |
| Scroll smoothness | Smooth / Janky | Smooth / Janky |
| Search responsiveness | Instant / Slow / Broken | — |
| Filter narrowing | Useful / Overwhelming | — |
| Pagination | Works / Slow / Broken | — |
Plus:
- **Sort stability**: When sorting by a column, does the order survive navigation and refresh?
- **Bulk selection**: Can you select all filtered records, or only the current page? Is that clear?
- **Export**: Can you export the current filtered view? All records? Does it time out or fail silently?
- **Infinite scroll**: If used, can you get back to where you were after clicking into a detail? Or does it reset?
**Severity guide**: List that doesn't virtualise at 1000+ rows is **High** (performance degrades, memory issues). Search returning irrelevant top results on common terms is **High**. Pagination that resets selection/state on every page change is **Medium**. Filter UI that doesn't show which filters are active is **Medium**.
---
## 7. Destructive Confidence
**Premise**: The high-stakes moments — delete, send, publish, pay, share. Does the app keep the user safe?
**How to run**:
1. Inventory every destructive or irreversible action in the app:
- Delete (record, file, comment, account)
- Send (email, invitation, notification)
- Publish / make public
- Pay / charge
- Share (external link, give access)
- Archive / deactivate
- Bulk operations on any of the above
2. For each one:
- Is there a confirmation dialog? What does it say?
- Does the confirmation specify *what* will happen ("Delete Jenny O'Brien. 3 policies will also be removed. This cannot be undone.") or is it vague ("Are you sure?")?
- Is the destructive button visually distinguished (red, warning icon)?
- Is there an undo option after the action?
- If no undo, is there a time window ("Deleted. Restore within 30 days.")?
3. For the bulk variants:
- Does the confirmation say how many items will be affected?
- Can you see which items you're about to act on?
- Is there a way to undo a bulk action?
**Ask the user before running** this scenario — destructive testing on a real account can cause damage. Use a test account if possible.
**What to report**:
| Action | Confirmation quality | Destructive styling | Undo available | Severity |
|--------|---------------------|---------------------|----------------|----------|
| Delete client | [copy / specific? / count?] | Y/N | Y/N (time window) | |
| Send invoice | [copy] | Y/N | Y/N | |
| Publish page | [copy] | Y/N | Y/N | |
| Bulk delete | [copy / count shown?] | Y/N | Y/N | |
Plus:
- **Side-effect disclosure**: Does the app warn about cascading deletes, linked records, revoked access?
- **Fatigue risk**: If you trigger a destructive action 10 times in a row, do you stop reading the confirmation? Is the confirmation pattern the same every time, making it easy to click through mindlessly?
- **Accidental trigger surface**: How easy is it to hit a destructive action by mistake? Is it next to a similar-looking safe action?
**Severity guide**: Destructive action with no confirmation is **Critical**. Confirmation without specifics ("Are you sure?" with no context) is **High**. Destructive button styled identically to safe buttons is **High**. No undo and no time window for irreversible actions is **High**. Bulk delete without count/preview is **High**.
---
## 8. Second User (Role)
**Premise**: Most apps have roles — admin vs viewer, staff vs client, owner vs guest. The primary user experience is tested exhaustively. What about the others?
**How to run**:
1. Identify every role/permission level the app supports.
2. Log in as a non-primary role (viewer, client, read-only, restricted).
3. Walk the main threads as that role.
4. Specifically check:
- **Denied pages**: Are they gracefully blocked (meaningful error, redirect to a page you can access) or do you see a broken page, blank screen, or raw error?
- **Denied actions**: Are destructive/edit buttons hidden? Disabled with tooltip? Or visible-and-clickable but failing with a cryptic error?
- **Denied data**: Can you see records you shouldn't? Search results leaking internal records?
- **Error messages**: "You don't have permission to do that" — does it say *why* or *who to ask*?
- **Empty states**: Do they differ by role? A client sees "No invoices yet" but an admin sees "No invoices yet — add one"?
**What to report**:
| Role | Threads completable | Broken pages | Leaked data | Error message quality |
|------|---------------------|--------------|-------------|----------------------|
| [Viewer] | [list] | [list] | [list] | [assessment] |
| [Client] | [list] | [list] | [list] | [assessment] |
Plus:
- **Wayfinding for restricted users**: Does the nav show items they can't access? Does it clearly mark them, hide them, or tease them?
- **Permissions UX**: If a user has mixed permissions (can view some records but not others), does the UI communicate that cleanly?
- **Request-access flows**: If a user hits a permission wall, is there a "request access from [owner]" option?
- **Impersonation**: If staff can act on behalf of clients, is the switch visually obvious so they don't accidentally act as the wrong user?
**Severity guide**: Broken page on permission denied (blank, raw stack trace, 500) is **Critical**. Leaked data visible to wrong role is **Critical**. Destructive button visible-and-clickable-but-failing for a restricted user is **High**. Confusing or absent error messages on denial is **High**. Nav showing items the user can't use without indication is **Medium**.
---
## 9. Lifecycle Position
**Premise**: Same role can experience the app radically differently depending on *when* in the org's lifecycle the user joins. The primary user gets exhaustive testing from a fully-onboarded perspective. What about the founder on day one, the first invitee on day three, and the colleague who joined a workspace that already has a year of history?
These are different products to different users. Empty-state copy that says "Get started by adding your first user" is correct for user #1 and broken for user #2. Activity feeds that look great with 50 contributors may break or feel hollow with one. Defaults that filter to "your work" are great for user #N and meaningless for user #1.
**How to run**:
Test three lifecycle positions, each with the same role (e.g. admin):
### User #1 — Founder (fresh org)
1. Sign up a fresh account / create a new org / start a new workspace.
2. Walk the *initial setup* and *first meaningful task* as the persona.
3. Specifically check:
- **Onboarding flow**: Was there one? Was it skippable? Did it explain the app's concepts or assume you knew them?
- **Empty workspace**: Helpful or hostile? Did the dashboard guide the next action or just show "no data" everywhere?
- **First-task time**: Could you complete one meaningful piece of work in 5 minutes?
- **Concept introduction**: Did the app teach its own vocabulary as you encountered it, or did you have to figure it out?
### User #2 — First invitee (partial state)
1. From user #1's account, invite user #2 with the same role.
2. Accept the invite as user #2 and walk the same threads as user #1.
3. Specifically check:
- **Invite welcome**: Did the welcome copy address you specifically, or did it dump you into the founder's setup wizard?
- **Setup-wizard misfire**: Were you forced through "set up your org" UI when the org already exists?
- **Wayfinding in partial state**: Could you find your way around an org someone else shaped, with limited data and one peer?
- **Peer-feature edge cases**: Mentions, assignments, comments, activity feeds, sharing — do they work meaningfully when there's only one other user?
- **Empty-with-context copy**: Does empty-state copy still say "Add your first user" when *you are* the first user being added?
### User #N — Later joiner (full workspace)
1. Either use a real workspace with established data, or seed a fresh org with rich state (50+ records, 5+ users, established saved views) before running this segment.
2. Join as user #N (or simulate by seeding then signing up).
3. Specifically check:
- **Onboarding stays out of the way**: You're catching up, not setting up. Does the app skip founder-flavoured onboarding?
- **Find your work**: Could you find what's *yours* amongst others' content? Defaults filtered to your context, or dumped into "all data"?
- **Convention learning**: Did the app teach the org's saved views, custom fields, naming conventions — or did you have to ask a teammate?
- **Joining-the-party UI**: Is there a "what you missed" / "what's relevant to you" affordance, or do you land in a generic dashboard?
**What to report**:
| Position | Setup flow shown? | Onboarding gap | Wayfinding works? | Empty/partial UI helpful? |
|----------|-------------------|----------------|-------------------|--------------------------|
| User #1 (founder) | Y/N + quality | [list gaps] | Y/N + detail | Y/N + detail |
| User #2 (first invitee) | Y/N + quality | [list gaps] | Y/N + detail | Y/N + detail |
| User #N (later joiner) | Y/N + quality | [list gaps] | Y/N + detail | Y/N + detail |
Plus:
- **Same-screen-three-faces inventory**: For each major page, is the empty / partial / full state coherent? Or does one of the three look unfinished?
- **Onboarding scope creep**: Does setup UI bleed into screens user #2 and #N see?
- **Activity feed dignity**: Does the app present activity sensibly when only one user has acted, or does it look hollow?
- **Assignment / mention UX**: Test mentioning user #1 → user #2 with two-user setup. Does the @mention picker work? Are assignment dropdowns useful with 1, 2, 50 users?
- **Default landing**: Does the default dashboard / home view differ usefully by lifecycle position, or is it identical-and-suboptimal-for-most?
**Severity guide**: User #2 force-routed through founder setup wizard is **High**. Empty-state copy that's wrong for the user's lifecycle position ("Add your first user" shown to user #2) is **High**. Activity feed that breaks or shows raw nulls when only one user has acted is **High**. User #N dumped into all-data with no filter / scoping hint is **Medium**. Concept vocabulary never introduced anywhere (must learn from a teammate) is **Medium**. Onboarding shown to user #N when they don't need it (and unskippable) is **Medium**.
**Why this beats the standard "second user" test**: Roles and lifecycle position are orthogonal axes. A viewer who joined on day one and a viewer who joined on day three hundred see different products. Scenario 8 covers the role axis. This covers the time axis.
---
## 10. Round-Trip Workflow Integrity
**Premise**: Real users navigate A → B → A constantly. Open a project, type a prompt, get dropped into chat, agent replies, back to project — does the project show what just happened? When mutations on B affect data shown on A, the cache invalidation often gets missed and A serves stale state. Looks like data loss.
**How to run**:
For every workflow that traverses pages:
1. Capture A's state — count of items, latest timestamp, badge values.
2. Trigger an action on A that navigates to B.
3. Complete a mutation on B (send a message, create a thing, approve an item, save a draft).
4. Navigate back to A using the discoverable back affordance — NOT a hard reload.
5. Verify A reflects the new state (incremented count, new row, updated timestamp, decremented badge).
6. Verify the back affordance was discoverable (visible without hover, labelled with parent name, sized like a control, sidebar still highlights parent).
If the round-trip leaves A stale, log a finding (severity High — looks like data loss). Reload that "fixes" the staleness is the smoking gun.
**Cross-page mutation surfaces to test**:
| Outbound (A → B) | Mutation on B | What A must reflect on return |
|---|---|---|
| Project page → Start chat | Conversation created | New conversation in project's list |
| Item list → detail page edit | Item updated | List shows new title / status |
| Bulk-action page → batch process | Items processed | List + count + header badge update |
| Inbox → click finding | Finding marked-read | Unread count on bell decrements |
| Approvals page → approve | Approval decided | Pending tab + Inbox unified view both update |
| Settings → API tokens → create | Token created | List populates |
| Connections → OAuth → callback | Connection persisted | Connection list shows new entry |
| Routines /new → submit | Routine created | Sidebar + routines list show new row |
**What to report**:
| Round-trip | Stale on return? | Reload reveals data? | Back affordance discoverable? |
|---|---|---|---|
| project → chat → project | Y/N | Y/N | Y/N — describe (size, label, location) |
| (etc) | | | |
**Severity guide**: Stale parent on return is **High** (looks like data loss). Missing/hidden back affordance is **Medium** (recoverable but disorienting). Header badge that doesn't update is **High** (lies to the user about pending work).
**Code-level companion check**: grep for every `useMutation` and check the keys it invalidates against the keys consumed by parent / sibling views. The classic miss: mutation invalidates `['inbox']` only, but the same data also appears under `['notifications']` (badge) + `['approvals']` (sibling tab). Audit each mutation: "what query keys consume the data this mutation affects?" — invalidate them all.
Full protocol, surface inventory, detection heuristics, and findings template in [round-trip-workflows.md](round-trip-workflows.md).
**Why this exists as its own scenario**: it doesn't fit "Wrong Turn Recovery" (user goes correctly, not wrongly), it doesn't fit "Returning User" (test runs in one sitting), and it's invisible to static analysis. It's the single biggest source of "I'm not sure how I got there / the project is just empty when I go back" UX feedback in real apps.
---
## 11. Data Seasoning
**Premise**: 100 fresh-seeded rows behave differently from 100 rows accumulated over 30 days. Time-shaped data tests UI patterns that quantity-only seeding misses: time dividers ("Today" / "Yesterday" / "Last week"), recency-based sort order, cron-driven side effects, notification badge overflow, signal-vs-noise pressure, chart bucketing.
This catches the bugs that surface "after using the app for a while" — invisible to fresh-seeded audits.
**How to run**: seed the test-auth user with synthetic data at four horizons (Day 0 / 1 / 7 / 30). Walk seasoning-sensitive surfaces at each horizon, capturing screenshots labelled by horizon.
| Horizon | What it stresses |
|---------|------------------|
| **Day 0** (empty) | Empty states, onboarding affordances, "Open X" CTAs, dimmed placeholders |
| **Day 1** (light) | Today/Yesterday dividers, single-item pluralisation, returning-user feel |
| **Day 7** (modest) | Pagination breakpoints, "older than X" boundaries, cron-fired data (~50-150 routine runs), weekly summaries |
| **Day 30** (seasoned) | Notification badges at 47/99+, sort order signal-vs-noise, TanStack Query invalidation cascade depth, chart bucketing (per-day → per-week), search performance with history |
**Surfaces this applies to**: lists, queues (Inbox / Approvals / Pending), observability (agent_runs, audit logs), history (Memory, conversations), notification surfaces (header bell, tab counters), charts, dashboards, mention/assignee pickers.
**What to report** per surface × horizon:
| Surface | Day 0 | Day 1 | Day 7 | Day 30 |
|---------|:---:|:---:|:---:|:---:|
| Inbox | ✓ | ✓ | ✗ pagination breaks | ✗ badge overflow at 47 |
| Approvals | ✓ | ✓ | ✓ | ✗ "snooze" affordance missing |
| agent_runs observability | ✓ | ✓ | ✗ 168 entries melt scroll | (skipped — too slow) |
| Memory search | ✓ | ✓ | ✓ | ✗ 750ms latency at 500 items |
**Severity guide**:
- Notification badge overflows / breaks visually at 47+ unread → **High**
- Pagination breaks at Day 7 horizon → **High**
- Sort order becomes meaningless at Day 30 (recent buried) → **High**
- Charts unreadable at Day 30 (no per-week / per-month bucketing) → **High**
- TanStack Query invalidation cascades > 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**
- Search latency >500ms at 500+ saved items → **Medium**
- Activity feed feels hollow at Day 1 (one entry) → **Low** (hard to fix without diluting signal)
**If the project has no seasoning seed scripts** (`pnpm seed:day-{0,1,7,30}` or equivalent): note that Scenario 11 was attempted but couldn't run; recommend the project write seeds (cite [data-seasoning.md](data-seasoning.md) for the architecture). Verdict can still be Pass on other grounds with Scenario 11 coverage = 0. Don't try to manually create 250 inbox items via the UI — that's not the audit's job, and it produces unrealistic time distributions anyway.
Full protocol, project-side seed-script architecture (`scripts/seed/horizons/day-N.ts` pattern, factory layout, CLI entrypoints), surface-by-surface what to look for at each horizon, and composition with other scenarios in [data-seasoning.md](data-seasoning.md).
**Why this exists as its own scenario**: Heavy Data (Scenario 6) is pure quantity. Lifecycle Position (Scenario 9) is *person* time (founder / invitee / joiner). Data Seasoning is *data* time — the app's content has a temporal distribution that a fresh seed can't simulate. Three orthogonal axes; you need all three.
---
## Running the Battery
Recommended order — **judgement-density first** (do the highest-signal scenarios while attention is freshest, low-judgement scenarios last):
**High-judgement scenarios** (do these first — most senior-designer-shaped findings come from here):
1. **Returning User** — repeat threads, measure improvement, check the dashboard. "The app doesn't get easier the second time" is a high-judgement finding.
2. **Lifecycle Position** — user #1 / #2 / #N. "This empty state is wrong for the second user" class.
3. **Round-Trip Workflow Integrity** — A → B → A. "I'm not sure how I got here / project is empty when I go back" class.
4. **Data Seasoning** — Day 0 / 1 / 7 / 30 horizons. "App feels different after a few weeks" findings.
**Mid-judgement scenarios**:
5. **Wrong Turn Recovery** — deliberately click wrong, time the recovery.
6. **Interrupted Workflow** — test mid-workflow.
7. **Heavy Data** — seed volume, rerun list/search/filter.
8. **Destructive Confidence** — ask the user before running.
**Lower-judgement / structural scenarios** (run as completeness backstop — largely covered by other phases):
9. **First Contact** — covered substantially by persona lock + first-time-user lens in Phase 3.
10. **Keyboard Only** — covered substantially by axe-core a11y phase.
11. **Second User (Role)** — covered if you do a persona-overload audit.
**Why reordered**: in the prior chronological order, agents tended to phone in scenarios 4-11 when the first three felt productive. Front-loading judgement-density means the audit never runs out of attention before reaching the high-signal scenarios.
The scenario *numbering* in the sections above (1 First Contact through 11 Data Seasoning) is the canonical scenario library — reports cite scenarios by their library number. The order above is the *running* order. Different concept, same scenarios.
references/stress-test-recipes.md
# Extended Stress Test Recipes
Beyond the 9 scenarios in [scenario-tests.md](scenario-tests.md), these recipes catch the failure modes that scenarios miss. Each recipe has a how-to, what-it-catches, and severity guide.
Run all relevant recipes during Phase 5 of the audit. Skip recipes that genuinely don't apply (no print stylesheet on a chat app).
## 1. Race conditions
### Double-click submit
**How**: Click any Send / Save / Submit button twice in rapid succession (< 200ms apart).
**What it catches**:
- Duplicate creation (two records, two messages, two payments)
- Optimistic UI desync (one click goes through optimistically, second click finds the optimistic state and acts on it)
- Disabled-state race (button disables but second click already queued)
**Severity if it produces duplicates**: **Critical**.
### Fast-type then immediate blur
**How**: Type into an input, then `Tab` or click outside without waiting for autocomplete / validation.
**What it catches**:
- Validation that runs after blur but the form already submitted via Enter
- Autocomplete that completes after blur, leaving stale value
- onChange handlers firing after onSubmit
### Rapid navigation
**How**: Click a list item, then before it loads, click a different list item.
**What it catches**:
- Stale data shown (old item's data leaks into new item's view)
- Cancelled-fetch handling (TanStack Query / SWR — abort missed)
- Loading spinner stuck
## 2. Slow network (3G throttle)
**How**: Browser DevTools → Network → Slow 3G (or set request throttling to 100kbps download / 400ms latency).
**What it catches**:
- Loading states that disappear after 200ms (real users see them)
- Skeleton screens never showing (data arrives too fast in dev)
- Timeouts (button-disable for 60s with no progress hint)
- Retry UX (does it auto-retry? Should it?)
- Optimistic UI (does it work when the server takes 5s to confirm?)
**Severity guide**: missing skeleton on a list view that takes > 1s = High. Stuck spinner with no escape > 5s = Critical.
## 3. Reduced motion
**How**: Browser DevTools → Rendering → Emulate CSS media `(prefers-reduced-motion: reduce)` → matches.
**What it catches**:
- Animations that ignore the preference (still slide, still fade)
- Spring physics on scroll (causes motion sickness for users who set the pref)
- Auto-playing videos
**Severity if any animation runs when reduced**: High (accessibility violation).
## 4. i18n stress
### Long German nouns
**How**: Inspect the longest copy on each page and replace with a synthetic 50+ character string. Or, if i18n exists, switch to `de`.
**What it catches**:
- Truncation without tooltip (`overflow: hidden` cuts off important info)
- Wrap into 4+ lines breaking layout
- Buttons that grow beyond their parent container
### RTL Arabic
**How**: Set `dir="rtl"` on `<html>` (or switch to `ar` / `he` if i18n exists).
**What it catches**:
- Icons that should mirror but don't (chevron-right pointing wrong way)
- Layouts assuming left-to-right (sidebar on wrong side)
- Padding-left vs margin-inline-start drift
### CJK character widths
**How**: Replace copy with CJK strings (Chinese / Japanese / Korean) for the longest fields.
**What it catches**:
- Line-height insufficient for character ascenders
- Font-family fallback ugly in CJK
- Text rendering as squares (missing font subset)
## 5. Empty / saturated states
### Empty
- Lists with 0 items
- Conversations with 0 messages
- Projects with 0 files / tasks
- Search with 0 results
- New user with 0 history
### Saturated
- Lists with 1000+ items (pagination, virtualisation, performance)
- Conversation with 200+ messages (scroll-to-bottom, performance)
- Project with 50+ tagged categories (filter UX)
### Long content edges
- Single field with 100+ characters (overflow, tooltip, truncate)
- Single field with 5000-word paste (textarea behaviour, save semantics)
- Filename with special characters (`O'Brien.pdf`, `café.csv`, emoji, `<script>` injection check)
## 6. Offline mode
**How**: Browser DevTools → Network → Offline. Then perform an action.
**What it catches**:
- Silent failures (no error shown)
- Optimistic UI without rollback
- Queue / retry UX (Tauri / Notion / Linear show "queued" — does this app?)
- Lost work on subsequent reload
**Severity if data is lost silently**: Critical.
## 7. Print stylesheet
**How**: Browser DevTools → Rendering → Emulate CSS media → `print`. OR `Ctrl+P` to open the print preview.
**What it catches**:
- Invisible content (white text on white background after print rules apply)
- Sidebar / nav still showing (should typically be hidden)
- Page breaks landing mid-content
- Print-specific layout never tested
**Severity if no print rules and printing is plausible**: Medium (e.g. invoices, reports).
## 8. High-contrast / forced-colors mode
**How**: Browser DevTools → Rendering → Emulate CSS media → `forced-colors: active`. OR Windows High Contrast.
**What it catches**:
- Elements that disappear (background-only highlights without borders)
- Icons rendering as squares
- Focus rings invisible in forced colours
- Form inputs unstyleable
**Severity**: High accessibility violation.
## 9. Keyboard-only
**How**: Unplug the mouse. Walk the entire app with Tab / Shift+Tab / Enter / Space / Arrow keys / Escape.
**What it catches**:
- Focus traps in modals (can't Tab out)
- Hidden focus states (can't see where you are)
- Skipped focusable elements
- Custom dropdowns that don't respond to arrow keys
- Drag-and-drop with no keyboard alternative
- Modals that don't return focus to trigger on close
**Coverage check**: every interactive element must be reachable via Tab and operable via Enter / Space / Arrow.
## 10. Screen reader test (NVDA / VoiceOver)
**How**: Mac: VoiceOver (Cmd+F5). Windows: NVDA (free download).
**What it catches**:
- Unlabeled buttons ("Click here" landmarks, icons without aria-label)
- Decorative images announced
- Form fields with no associated label
- Dynamic content that doesn't announce changes (`aria-live`)
- Headings out of order (h1 → h3 skipping h2)
**Severity**: Major accessibility violation. Often Critical for compliance-required apps.
## 11. Long-press / right-click variations
**How**: Right-click on every interactive element. Long-press (touch device emulation).
**What it catches**:
- Context menus not implemented where they should be
- Default browser context menu showing on a custom widget
- Long-press selecting text on a card meant to be tappable
- Drag handles activating a context menu
## 12. Browser-back during in-flight action
**How**: Click Submit, then immediately click browser back before the action completes.
**What it catches**:
- Server still processes the action; user lands on previous screen with no idea
- Optimistic UI rolled back but server confirmed (state inconsistency)
- Navigation guards not present on dirty forms
**Severity**: High if data is created server-side without user confirmation.
## 13. Tab-restore (browser crash sim)
**How**: Close the browser tab mid-task. Reopen. Use browser's "Reopen Closed Tab" or navigate to the URL.
**What it catches**:
- Drafts not saved (autosave gap)
- "Continue where you left off" missing
- Local storage cleared on tab close
- Session lost (re-login required for an authenticated state)
## 14. Multiple-tab consistency
**How**: Open the app in two tabs side by side. Modify a record in tab A. Switch to tab B. Modify the same record.
**What it catches**:
- Stale state in tab B (no sync)
- Conflict resolution UX (last-write-wins silently)
- Real-time updates (or absence)
- Auth state divergence (logout in A, B doesn't notice)
## 15. Stale localStorage / IndexedDB
**How**: Modify a known localStorage / IndexedDB key to an old version's format. Reload.
**What it catches**:
- App crashes on schema mismatch
- Silent reset of user state
- Missing migration logic
**Especially relevant for**: apps that have been deployed for > 6 months with persistent client-state.
## 16. Real-flavour data battery
AI-built UIs are notoriously dev-data clean. Every form accepts "Test User" and "test@example.com" but breaks on real names, real lengths, real edge cases. Use the fixture below across every form and every list during element exhaustion.
### Fixture — paste each into appropriate fields
```
NAMES (test in name fields, mention pickers, member lists):
O'Brien // apostrophe
María // diacritic
François // diacritic + cedilla
朴기철 // CJK + Hangul
محمد بن سلمان // RTL Arabic
Schöngeist-Holzhäusermeisterin // 30+ char German compound
X Æ A-12 // unusual punctuation
' OR 1=1; -- // SQL injection canary (must escape)
<script>alert(1)</script> // XSS canary (must escape)
(200 chars of "a") // length stress
EMAIL ADDRESSES (sign-up, invite, sharing):
user+filter@example.com // plus-addressing
user.name@sub.domain.example.co.uk
"user with space"@example.com // quoted local-part (RFC 5321 valid)
user@127.0.0.1 // IP literal
user@xn--80akhbyknj4f.xn--p1ai // punycode
PHONE NUMBERS (contact forms):
+61 4 1234 5678 // AU mobile
(02) 1234 5678 // AU landline w/ area code
04-1234-5678 // AU mobile w/ dashes
+1-555-555-5555 ext 1234 // US with extension
+44 20 7946 0958 // UK landline
ADDRESSES (shipping, billing):
1/12 O'Connell St, Newcastle NSW 2300 // unit prefix + apostrophe
Apt 4B "Rear Cottage", 123 Main St // double-quote in line
PO Box 1234 // PO box variant
Lot 7 RMB 142, Williamtown NSW 2318 // rural mail bag
PASTE BOMBS (textareas, message fields):
- 5,000-character paragraph (stress textarea height + serialisation)
- 200-line paste with mixed indentation
- Tab characters (preserves vs collapses?)
- RTF clipboard paste from Word (does the editor keep formatting it didn't ask for?)
- Smart quotes "" ‹›『』 (does the form normalise?)
FILES (uploads):
- 8000×8000 PNG (memory blow-out test)
- 50 MB PDF (size limit test)
- "résumé (final)(2).pdf" (filename with spaces, parens, accent)
- .heic photo (iOS native — does the app convert or reject cleanly?)
- 0-byte file (edge: does upload code crash on empty buffer?)
- .exe / .dmg (security: must reject)
NUMBERS (currency, quantities):
$1,234.56 // formatted
1,234.56 // ambiguous (1234.56 or 1.23456?)
€1.234,56 // EU format
¥1234 // no decimals
1.5e6 // scientific notation
-0 // signed zero
Infinity // edge
DATES (date pickers, timestamps):
29 Feb 2024 // valid leap year
29 Feb 2025 // INVALID — must reject
31 Apr 2025 // INVALID — only 30 days in April
1 Jan 1900 // far past
31 Dec 2099 // far future
```
### Where to use it
For every form discovered in element exhaustion:
1. Find the field type (name, email, address, etc).
2. Paste the most relevant fixture string.
3. Submit + verify:
- Render after submit displays the input intact (no character loss / corruption)
- Stored value round-trips on edit (open the saved record, re-edit, verify the field still contains the input exactly)
- Validation errors are CLEAR (not "Invalid input" — say what's wrong: "Apostrophes not allowed" if that's the rule)
- SQL/XSS canaries are escaped, not executed
### What it catches
- Validation that strips characters silently (apostrophe → `?`, accents → blank)
- Length truncation without warning (200-char name → first 50)
- Server-side encoding mismatches (utf8 → utf8mb4 issues)
- Search/sort that doesn't normalise diacritics ("Maria" doesn't find "María")
- File-type filters that reject legitimate formats (.heic, .webp)
- Clipboard paste that injects HTML / inline styles when not wanted
- Number/date parsers that silently coerce wrong values
### Severity guide
| Failure mode | Severity |
|---|---|
| User input silently lost / corrupted on save | Critical |
| User input rejected without clear error message | High |
| Display renders mangled (HTML entities visible, encoding garbage) | High |
| Search misses normalised matches (Maria/María) | Medium |
| File upload rejects legitimate format | Medium |
| Filename special chars displayed weirdly but file otherwise works | Low |
This recipe is **mandatory for any app accepting user content**. Skip only for builder-mode reference pages and read-only displays.
## Severity reference
A stress test produces findings whose severity follows these defaults:
| Recipe failure | Default severity |
|---|---|
| Double-click produces duplicates | Critical |
| Slow-network shows no skeleton | High |
| Reduced-motion ignored | High |
| Long German breaks layout | High |
| RTL mirroring missing on icons | Medium |
| Offline silently loses data | Critical |
| Print stylesheet absent (where plausible) | Medium |
| Forced-colors elements disappear | High |
| Focus trap in modal | Critical |
| Screen reader: unlabeled primary action | Critical |
| Tab-restore loses draft | High |
| Multi-tab: stale state | Medium |
Promote findings with stacked failures (e.g. slow-network AND no skeleton AND no progress text → Critical).
## When to skip a recipe
Skip recipes that don't apply:
- Print stylesheet on a chat app — skip
- RTL on an English-only app — skip but note that future i18n will need it
- Offline mode on a tool that legitimately requires connectivity — note the connection requirement instead
Don't skip recipes that *should* apply but the team hasn't implemented yet — those produce findings.
## Coverage publication
At the end of the stress phase, publish:
```
STRESS COVERAGE
Recipes run: 13 / 15
Skipped: 2 (RTL — no i18n yet; Print — chat-only app)
Findings produced: 7 (1 Critical, 3 High, 2 Medium, 1 Low)
```
Coverage transparency lets the next agent know what wasn't tested.
references/visual-polish.md
# Visual Polish Sweep
A page-by-page micro-polish pass. Run after the Responsive Sweep, before the Scenario Battery. Goal: would a design-conscious person look at the page and think *"this is well made"* or *"a developer / AI built this"*?
This is the layer that separates a UI that's *technically correct* from one that *feels considered*. Most of the patterns below are invisible to functional testing and to most humans on first glance — but they pile up and form the unmistakable "machine-generated" smell.
## Pick three, walk deeply
The catalogue has 10 categories. **Don't enumerate completeness theatre** ("scanned all 10, found nothing on most"). On any given run, pick the **3 categories that produce the highest-impact findings on this app**, walk those 3 deeply with proof per finding, and note the others as `scanned, no findings`. Empty checks aren't findings; they're filler. The audit's self-critique pass (see [audit-output-discipline.md](audit-output-discipline.md)) drops them anyway.
How to pick the 3:
- Run a fast eyedropper sweep on the most-used page. Whichever category produces the first 2-3 visible findings is your winner.
- Common winners: vibe greys (raw values mixed with tokens), off-scale spacing, hover-delta calibration. Common losers: drop shadow direction (most apps get this right by accident), uppercase letter-spacing.
- The 3 categories can change run-to-run. Don't pre-commit.
## How to run
1. Pick one major page at a time. Work in this order:
- Most-used screen (dashboard, primary list view)
- Highest-stakes screen (settings, billing, anything destructive)
- Marketing surfaces (login, signup, public pages)
- Edit / detail screens
2. For each page, run through the **AI-tell catalogue** below in order. Don't skip the ones that feel subtle — those are exactly the ones the catalogue exists for.
3. Use Browser DevTools actively:
- **Eyedropper** on greys, blues, borders — verify they trace back to design tokens, not raw values
- **Computed styles** on suspect spacing values — verify they land on the design system's scale
- **Inspect → Layout panel** — overlays show exact box dimensions and padding/margin
- **Throttle network → Slow 3G** — forces loading and skeleton states to stay visible long enough to evaluate
4. Capture screenshots of every finding with the relevant DOM inspector overlay visible — the overlay is the proof.
## AI-tell catalogue
### 1. Optical vs mathematical centring
`align-items: center` puts the text-box centre at the container's centre. But text has descenders that shift the *perceptual* centre upward — what looks centred is not the same as what is mathematically centred.
**Buttons / chips / badges with text**:
- Take a screenshot. Draw a horizontal line through the visible text x-height (the top of the lowercase letters and bottom excluding descenders).
- The button's vertical centre should be 1-2px *above* the text-box centre — text should sit slightly low if mathematically centred.
- If text looks high-floating in the button, it's mathematically centred (broken). Should appear visually anchored.
- Numbers in badges (no descenders) need a different correction — they often need to be nudged *down* 1px from text default.
**Icon next to label**:
- Icon should align to the **cap height** of the text (the top of capital letters), not to the line-height box.
- Walk every "icon + text" pair: nav items, button labels with icons, list-item adornments, breadcrumb separators.
- If the icon looks low-slung (sitting on the descender line), it's wrong. Verify with: select the text + icon, check whether the icon centres above the baseline of the lowercase text, not on it.
**How to test**:
- Zoom screenshot to 400% and squint
- Use a horizontal ruler / DevTools horizontal guide
- Compare to a reference component from a polished design system (Radix UI primitives, Linear, Vercel docs)
**Findings shape**: *"Submit button on /signup — text mathematically centred (computed: y-offset 0px from container midline), should be optically corrected (~1px upward nudge). Compare to Linear's primary button styling."*
### 2. Nested border-radius rule
When a rounded container holds a rounded inner element, the inner radius should equal the outer radius minus the padding. `inner = outer - padding`.
**Why**: visually concentric corners trace parallel arcs. Mismatched radii create a wedge of awkward space at every corner.
**How to check**:
- Card with internal media (image, code block, embedded list): outer radius (e.g. 12px), inner padding (e.g. 8px), inner radius should be **4px**.
- Modal dialog with header/footer that have their own backgrounds: same rule applies recursively.
- Buttons with internal icon backgrounds, chips with adornments — same rule.
**Common AI failure**: outer 12px, padding 8px, inner ALSO 12px (or worse, 8px or 4px arbitrary). Inner corner looks pinched or floating depending on direction.
**Findings shape**: *"Card on /dashboard has 12px outer radius and 16px padding, but internal preview image has 12px radius — should be ~0px (clamped) or 4px for a subtle inner corner."*
### 3. Off-scale spacing
The design system defines a spacing scale (e.g. 4 / 8 / 12 / 16 / 24 / 32 / 48 / 64). Every margin, padding, and gap should be a step on that scale. AI-generated CSS often produces *almost-right* values: 22px where 24px belongs, 7px where 8px belongs.
**How to check**:
- DevTools → Computed styles → check every `margin`, `padding`, `gap` on visible elements
- Look for raw pixel values like 13, 15, 17, 22, 27, 31 — these are tells
- The scale's gaps reveal themselves under squint test as inconsistent rhythm: rows that should feel equally spaced look subtly off
**The squint test**: blur your eyes at a list / grid / form. Distances should feel like a small, regular ladder. If anything jumps, that's the off-scale value.
**Findings shape**: *"Form fields on /settings have 14px gap between groups (computed). Design tokens use 12 / 16 / 24 step scale. Should be either 16 or 24."*
### 4. Vibe greys (and other off-token colours)
Body text, borders, dividers, muted text — these should all come from design tokens (`text-foreground`, `text-muted-foreground`, `border`, etc.). AI often introduces raw greys: `text-gray-450`, `#5a6168`, hand-picked hex values.
The eye notices because:
- Multiple greys with slightly different temperature (warm vs cool) within one component looks careless
- Greys that don't trace to the system fail in dark mode (no inverted equivalent)
- Borders that read as 1.5 different greys at the same weight = visual debt
**How to check**:
- DevTools → Styles → look for inline colour values vs `var(--*)` references
- Eyedropper → sample text, borders, dividers, muted UI
- Verify each sampled colour matches a token in the design system's CSS variables
**Findings shape**: *"Sidebar dividers on /dashboard use #d4d4d8 (raw). Border tokens are `--border: hsl(220 13% 91%)` ≈ #e3e6eb. Two different values rendering at similar weights produces visual drift."*
### 5. Border-weight drift
Borders within the same logical category (inputs, cards, modals) should use the same weight. AI often uses 1px / 1.5px / 2px interchangeably with no rationale.
**How to check**:
- DevTools → Computed → `border-width` on every input field, card, modal
- Inputs: typically all 1px
- Cards: 1px or no border + shadow
- Modals: typically no border + larger shadow
- Hover/focus states may bump weight (1px → 2px focus ring), but consistently across the same category
**Findings shape**: *"On /clients, list cards use 1px border, action buttons use 1.5px, search input uses 2px focus ring while text inputs in modals use 3px. No clear pattern — pick one weight per state per category."*
### 6. Drop shadow direction inconsistency
Shadows imply a light source. The light source should be consistent across the entire app — typically top-left or top, casting shadows down or down-right.
**How to check**:
- For each component with a shadow (cards, modals, dropdowns, tooltips), inspect `box-shadow`
- Shadows should all share the same x-offset sign (positive or zero) and a positive y-offset
- Shadows that point in opposite directions (one shadow down-right, another shadow down-left) is the immediate tell
**Common AI failure**: dropdown shadow `0 4px 6px rgba(0,0,0,0.1)` (good — points down), modal shadow `-2px 4px 12px rgba(0,0,0,0.15)` (bad — points down-left). Same app, different light sources.
**Findings shape**: *"Modal shadow on /settings has -2px x-offset (light from upper-right). Dropdown shadows app-wide have 0 x-offset (light from above). Pick one and apply consistently."*
### 7. Animation timings off canonical
Canonical UX motion durations:
- 100-150ms — micro-interactions (hover, focus)
- 200ms — small transitions (toggle, accordion)
- 300ms — modal/sheet open
- 500ms+ — full-page route transitions
**AI failure**: arbitrary values like 157ms, 233ms, 412ms — reads as "let me just type a number". Off-canonical durations don't *feel* worse than canonical ones in isolation, but they signal lack of care.
**How to check**:
- DevTools → Inspect → Computed → `transition-duration`, `animation-duration`
- Check easing curves too: `ease`, `ease-in-out`, `cubic-bezier(0.4, 0, 0.2, 1)` are reasonable; `cubic-bezier(0.31, 0.07, 0.52, 0.99)` (or any random-looking curve) is a tell
**Findings shape**: *"Dropdown open animation duration is 187ms. Should be either 150 or 200 — pick canonical."*
### 8. Hover-delta too subtle (or too loud)
Default → hover should be a perceptible state change but not jarring.
- **Background-only hover**: typically ~10% darker (or lighter on dark backgrounds). 5% is subtle enough that users wonder if anything happened.
- **Border hover**: typically full colour shift (token to primary), not subtle weight change
- **Text hover**: typically full colour shift to primary or accent, with optional underline
- **Card hover**: shadow bump + 1-2px translate, not 0px bump or 8px translate
**How to check**:
- Hover every interactive element. Take a screenshot of default and hover state.
- Diff visually — would a user notice the change without actively looking for it?
- Accessibility consideration: pure colour changes need to also work in high-contrast / reduced-motion modes
**Findings shape**: *"Sidebar nav items: hover changes background from \`hsl(220 13% 96%)\` to \`hsl(220 13% 94%)\` — 2% delta, imperceptible. Should be ~10% or use a token like \`--accent\`."*
### 9. Text underline offset / letter-spacing on uppercase
**Underline offset**: default browser underlines render close to the descender line, often touching letters. Good design sets `text-underline-offset: 3-4px` or uses `text-decoration-thickness` for a tuned look.
**Uppercase letter-spacing**: ALL CAPS text without letter-spacing reads as cramped. Standard correction is +0.05em to +0.1em letter-spacing on uppercase headings, button labels, badges. AI often skips this.
**How to check**:
- Find every underlined link → DevTools Computed → check `text-underline-offset`. If `auto` or unset on long-form content, log it.
- Find every uppercase element → check `letter-spacing`. If 0 or unset, log it.
**Findings shape**: *"Inline links in body copy have default underline offset (browser ~1-2px) — descenders cross the underline line. Set \`text-underline-offset: 3px\`."*
### 10. Symmetry vs editorial pacing
App shells generated by AI tend toward perfect symmetry: forms perfectly centred in containers, padding equal on all sides, layouts horizontally balanced.
Polished apps often break symmetry intentionally:
- Forms aligned to a column grid, not centred in viewport
- Section padding asymmetric (more breathing room above headings than below)
- Sidebars sized to a meaningful unit (240px, 280px) not "33% / 67%"
- Dashboard cards on a 12-column grid, not all equal width
- Hero sections with asymmetric content/visual layout
**How to check**: hide content for a moment and look at structural composition.
- Does the page feel composed, or laid out by a templater?
- Is white space trapped (awkward) or shaped (intentional)?
- Compare to reference apps **in the same category**: Linear (asymmetric editorial), Stripe Dashboard (deliberate hierarchy), Notion (intentional density), Vercel Dashboard (informational density with whitespace). Different category = different references; see [project-adaptation.md](project-adaptation.md) "Reference apps by category" for B2C / e-commerce / CMS / mobile / WordPress equivalents.
**Findings shape**: *"Settings page form is centred in 768px container. Other pages use a left-aligned 640px column with right rail for help/docs. Centred form on settings reads as orphaned and template-generated."*
## Per-component optical pass
For every instance of these component categories on every page, verify the four-point optical check:
| Component | Verify |
|-----------|--------|
| **Buttons (primary, secondary, ghost, destructive)** | Text optically centred, icon (if any) on cap-height baseline, hover delta perceptible, focus ring visible and on token |
| **Badges / chips / tags** | Text or count optically centred (extra care for numbers), padding consistent with size variant |
| **Inputs (text, select, textarea)** | All same height per size variant, leading/trailing icons aligned to text cap height, placeholder colour from token, focus state distinct from default |
| **Dropdowns / comboboxes** | Trigger same height as inputs of same size, chevron icon optically aligned, popover follows nested-radius rule |
| **Cards** | Outer radius and inner content radius follow nested rule, padding consistent within category, shadow direction matches app-wide light source |
| **Tabs** | Active tab indicator height/colour consistent, hover state on inactive tabs distinct, alignment of tab text optically corrected |
| **Avatars** | Truly circular (border-radius: 50%, square aspect), text/initials optically centred, fallback colours from a defined palette |
| **Toasts / alerts** | Icon optically aligned with first line of text, dismiss button optically centred, severity colour from semantic token |
## Severity guide
| Severity | When to assign |
|----------|---------------|
| **High** | One component shows multiple AI-tells stacked (off-centre text + arbitrary radius + raw grey + off-scale padding). Indicates the component was generated end-to-end without polish. |
| **High** | A pattern repeats across the app (raw greys throughout, off-canonical animation timings everywhere) — signals systemic lack of design tokens. |
| **Medium** | Single recurring pattern affecting one component family (all buttons share an off-centring issue; all dropdowns have the same border-weight drift). One fix lifts many surfaces. |
| **Medium** | Light source inconsistency across more than two components. |
| **Low** | Single instance, single tell — one badge with optical-centring miss, one section with off-scale padding. |
| **Low** | Underline offset, uppercase letter-spacing — meaningful but easily forgiven by most users. |
## Tools
- **Browser DevTools eyedropper** — fastest way to verify "is this colour from the system?"
- **DevTools → Computed** — exposes the actual rendered values for every CSS property
- **DevTools → Layout overlay** — visualises grid, padding, margin
- **`getComputedStyle(el)` in console** — script to extract all computed values from a selection
- **Reference apps** for calibration: Linear, Stripe Dashboard, Vercel Dashboard, Notion, Cron / Notion Calendar, Raycast docs
## Tips
- **Run this AFTER the responsive sweep**, not before — responsive sweep often surfaces visual issues that visual polish was masking.
- **Don't stop at "it looks fine"** — the catalogue exists because most patterns require active checking, not vibes.
- **Use the eyedropper on every grey** — this single check catches more issues than any other.
- **The squint test is your friend** — for hierarchy, spacing rhythm, and overall composition.
- **Stack-rank the findings** — fix systemic patterns (token migration, light source consistency) before single-instance polish.
- **Photograph evidence** — every finding should have a screenshot with DOM inspector overlay or eyedropper readout visible. The overlay is the proof.
## What this catches that nothing else does
| Other phase | What it covers | What it misses |
|-------------|---------------|----------------|
| Thread Traversal | Friction, hesitation, dead ends, comprehension | Whether the friction is *partly* caused by visual confusion |
| Element Exhaustion | Functional behaviour of every control | Whether controls *look* trustworthy |
| Responsive Sweep | Layout breakage at different widths | Polish at any single width |
| Scenario Battery | Contextual issues (first contact, returning user, lifecycle) | Composition, optical correction, design token discipline |
Visual Polish Sweep is the only phase that asks: *"Does this look made by someone who cares, or made by an automated pipeline?"*
references/walkthrough-checklist.md
# UX Walkthrough Checklist
Evaluate each screen against these categories during a walkthrough. The goal is to **dogfood** the app — use it as a real person with a real job to do, not as a tester running through checkboxes.
## User Persona (Set Before Starting)
Before evaluating any screen, know who you are:
| Question | Why It Matters |
|----------|---------------|
| Who is the user? | A developer and a receptionist notice different things |
| How much time do they have? | Time-poor users won't explore — they need obvious paths |
| How tech-comfortable are they? | Determines tolerance for complexity |
| What device are they on? | Desktop at a desk vs phone between tasks |
| How often do they use this? | Daily power user vs monthly visitor |
| What's their emotional state? | Calm and focused vs stressed and rushing |
Default persona if not specified: "First-time user, moderate tech comfort, slightly distracted, wants to get this done quickly and move on."
## Per-Screen Evaluation
| Category | What to Check |
|----------|---------------|
| **First Impression** | Does the page orient me? Do I know what I can do here? |
| **Navigation** | Can I find what I need in 3 clicks or fewer? Is the current location clear? |
| **Labels & Icons** | Do they describe what they do? Would a first-time user understand them? |
| **Visual Hierarchy** | Is the most important information prominent? Is secondary info de-emphasised? |
| **Call to Action** | Is the primary action obvious? Are secondary actions visually distinct? |
| **Forms** | Are required fields marked? Is validation immediate and clear? Are error messages helpful? |
| **Feedback** | Does the app confirm my actions? Are there loading states? Success/error toasts? |
| **Error Recovery** | Can I undo mistakes? Is there a back button? Are destructive actions guarded? |
| **Consistency** | Same patterns used for similar features? Same terminology throughout? |
| **Data Display** | Tables sorted sensibly? Pagination? Empty states helpful? Long text truncated? |
| **Layout & Visual** | All text visible? Nothing clipped by sidebar or container? Spacing consistent? No overlapping elements? |
## Emotional Friction (Dogfooding Focus)
These go beyond "does it work?" to "how does it feel?"
| Category | What to Check |
|----------|---------------|
| **Trust** | Do I feel confident about what will happen when I click this? Or am I nervous? |
| **Certainty** | After an action, am I sure it worked? Or do I need to go check somewhere else? |
| **Momentum** | Does the app keep me moving forward? Or do I hit walls and have to backtrack? |
| **Cognitive load** | Am I thinking about my task, or thinking about the interface? |
| **Anxiety points** | Are there moments where I'm afraid I'll lose work or break something? |
| **Satisfaction** | After completing the task, do I feel accomplished or exhausted? |
### Trust Signals to Look For
- Confirmation before destructive actions (delete, overwrite, send)
- Clear indication of saved vs unsaved state
- Undo available for reversible actions
- Preview before commit (e.g. "This will email 50 people")
- No surprising side effects from simple actions
### Anxiety Red Flags
- No autosave on long forms
- Ambiguous button labels ("Process", "Submit", "Continue" — continue to WHERE?)
- Destructive actions styled the same as safe actions
- No way to see what just happened (no activity log, no confirmation)
- Modal dialogs with no escape route
## Flow Efficiency
Track the mechanical cost of completing the task:
| Metric | What to Measure |
|--------|----------------|
| **Click count** | Total clicks from start to task completion |
| **Decision points** | Moments where the user has to stop and think about what to do next |
| **Dead ends** | Times the user goes down the wrong path and has to backtrack |
| **Redundant steps** | Actions that feel unnecessary (re-entering info, confirming the obvious) |
| **Shortcuts available?** | For repeat users — keyboard shortcuts, bulk actions, defaults, recent items |
A good flow feels like sliding downhill. A bad flow feels like climbing stairs. Count the stairs.
## Resilience Testing
What happens when things go wrong or the user behaves unexpectedly?
| Scenario | What Should Happen |
|----------|-------------------|
| Navigate away mid-form | Data preserved, or clear warning before losing it |
| Submit with bad/missing data | Specific error messages on the right fields, form state preserved |
| Hit the back button | Sensible navigation, not a broken state |
| Refresh the page | State survives (especially filters, scroll position, form data) |
| Double-click a submit button | No duplicate submission |
| Slow/no network | Graceful degradation, not a white screen |
| Very long text input | Handled gracefully (truncated, scrolled, not overflowing) |
## Visual & Layout Inspection
Catch layout bugs that break the visual presentation. These are easy to miss because the app still "works" — but it looks broken.
**On every page, actively look for:**
| Issue | What to look for | Common cause |
|-------|-----------------|--------------|
| **Clipped/truncated text** | Headings, labels, or descriptions cut off by a container edge. First characters missing. | Content area missing left margin/padding, or sidebar overlapping main content |
| **Overlapping elements** | Sidebar covering main content. Modals under nav bars. Floating buttons over text. | Z-index conflicts, missing margin on main content, fixed positioning errors |
| **Overflow / scrollbars** | Unexpected horizontal scroll. Content wider than viewport. Rogue scrollbar on a container. | Element with fixed width exceeding parent, uncontained images or tables |
| **Broken grid/alignment** | Cards or columns not aligned. Uneven spacing. Items jumping when data loads. | CSS grid/flex issues, missing min-width constraints, layout shift from async data |
| **Text contrast** | Text hard to read against background. Light grey on white. Dark text on dark background. | Missing dark mode styles, low-contrast colour choices, text over images without overlay |
| **Misaligned sidebar/content** | Main content starts behind or underneath the sidebar. Content pushed too far right with gap. | Sidebar width not accounted for in main content margin/padding |
| **Broken responsive transitions** | Layout looks fine at desktop and mobile but breaks at tablet widths. Nav items wrapping oddly. | Missing breakpoint styles for mid-range widths |
| **Image issues** | Broken image icons. Images stretched or squished. Oversized images causing slow load. | Missing src, no aspect-ratio/object-fit, unoptimised originals |
| **Invisible elements** | Buttons or links that exist but can't be seen (same colour as background). | Dark mode missing styles, transparent text, hidden by z-index |
| **Spacing inconsistency** | Some sections have generous padding, others are cramped. Cards with different internal spacing. | Inconsistent use of spacing utilities, missing design tokens |
| **Form input/button mismatch** | Input fields and their submit buttons are different heights, or misaligned horizontally. Search bar taller than its search button. Inline form elements not vertically centred. | Missing matching height utilities, inconsistent padding on inputs vs buttons |
| **Proportional imbalance** | A tiny action button next to a huge empty area. A narrow sidebar with a vast unused content zone. Header taking up half the viewport. | Missing max-width constraints, fixed sizes not scaling, poor use of available space |
| **Collapsible section issues** | Accordion/collapsible panels that don't indicate they're expandable. Sections that collapse and lose the user's scroll position. Nested collapsibles that fight each other. | Missing expand/collapse indicators (chevrons), broken state management |
| **Alignment across components** | Labels don't align with their inputs. Left edges of cards in a grid don't line up. Table columns have inconsistent text alignment (left vs centre vs right). | Mixed alignment rules, inconsistent text-align, grid items not aligned to baseline |
| **Touch target sizing** | Buttons, links, or checkboxes too small to tap on mobile. Icon-only buttons without adequate padding. Close/dismiss buttons that are tiny. | Missing min-height/min-width (44px minimum), padding too tight on interactive elements |
**How to check**: On each page, before evaluating usability, do a quick visual scan:
1. Does all text render fully? No clipped first/last characters?
2. Does the sidebar and main content sit side by side cleanly?
3. Are all elements inside their containers?
4. Is spacing consistent between similar elements?
5. In dark mode: can you read everything? Any elements disappear?
**Severity guide for visual issues**:
- **Critical**: Content is unreadable or unreachable (text fully hidden behind sidebar)
- **High**: Content is partially clipped or overlapping (first letters cut off, as in the screenshot issue)
- **Medium**: Visual inconsistency that doesn't block usage (uneven spacing, slight misalignment)
- **Low**: Minor polish (1-2px alignment, subtle colour inconsistency)
## Cross-Cutting Checks
| Category | What to Check |
|----------|---------------|
| **Mobile (375px)** | Touch targets at least 44px. No horizontal scroll. Text readable. Forms usable. |
| **Dark Mode** | All text readable. No invisible elements. Borders/separators visible. Images appropriate. |
| **Keyboard** | Tab order logical. Focus indicators visible. Modals trap focus. Escape closes dialogs. |
| **Loading States** | Skeleton screens or spinners. No layout shift when data loads. Buttons disabled during submit. |
| **Empty States** | Helpful message when no data. Clear call to action to add first item. |
## Friction Scoring
When you find an issue, classify it:
| Severity | Definition | Example |
|----------|-----------|---------|
| **Critical** | User cannot complete their task | Submit button does nothing, form data lost |
| **High** | User is confused or takes wrong path | Unclear labels cause wrong selection, no undo on delete |
| **Medium** | User succeeds but with unnecessary effort | Required field not marked, have to scroll to find action |
| **Low** | Minor polish issue | Inconsistent capitalisation, alignment off by a few pixels |
## Performance Perception
Not benchmarks — how fast does the app *feel*?
| What to check | Good | Bad |
|--------------|------|-----|
| **Button response** | Instant visual feedback on click (colour change, loading state) | Button does nothing for 1-2 seconds, user clicks again |
| **Page transitions** | Content appears within 300ms, skeleton if longer | Blank white screen while loading, layout jumps when data arrives |
| **List/table loading** | Skeleton rows, then data fills in smoothly | Empty table → sudden appearance of all rows (layout shift) |
| **Form submission** | Button shows spinner, disables, then confirms | "Submitting..." for 5 seconds with no progress indication |
| **Search** | Results appear as you type or within 500ms of pressing enter | Full page reload, or results take 3+ seconds with no feedback |
| **Saving** | Auto-save indicator or instant "Saved" confirmation | No indication whether changes are saved or lost |
| **Navigation** | Feels instant between pages (client-side routing) | Full page reload on every nav click, flash of white |
| **Optimistic UI** | Action appears to succeed immediately, rolls back if fails | Everything waits for server confirmation before showing the change |
**Key question**: At any point, did you wonder "did that work?" or "is it still loading?" — that's a perception problem.
## Copy and Microcopy
The words in the UI matter as much as the layout.
| What to check | Example of good | Example of bad |
|--------------|----------------|---------------|
| **Button labels** | "Save Client" — specific, tells you what happens | "Submit" — submit to where? "Process" — process what? |
| **Error messages** | "Email address is required" on the email field | "Validation error" at the top of the page with no field highlighted |
| **Empty states** | "No policies yet. Create your first policy." with a button | Blank white space, or just a table with column headers and zero rows |
| **Confirmation messages** | "Jenny O'Brien's policy has been saved" — names the thing | "Success" — success at what? "Done" — done with what? |
| **Tooltips and help text** | Short, specific, answers "what is this?" or "why is this here?" | Missing entirely, or repeating the label ("Email: Enter your email") |
| **Placeholder text** | "e.g. smith@company.com" — shows the expected format | "Enter value here" — tells you nothing useful |
| **Destructive action warnings** | "Delete Jenny O'Brien? This cannot be undone. 3 policies will also be removed." | "Are you sure?" — sure about what? What happens if I click yes? |
| **Loading/progress text** | "Sending email to 12 recipients..." — specific, shows progress | "Please wait..." — wait for what? How long? |
| **Navigation labels** | "Clients", "Policies", "Email Outbox" — matches the user's vocabulary | "Entities", "Records", "Queue" — developer vocabulary |
| **Tone consistency** | All messages use the same voice (friendly, professional, casual) | Mix of "Oops! Something went wrong!" and "Error: HTTP 500" |
**Spelling and grammar**: Check every page for typos, especially in error messages, empty states, and tooltips. These are often written once and never reviewed.
## Feedback Completeness
After every user action, the app should confirm what happened. Check the full action lifecycle:
| Action | Expected feedback | Common failure |
|--------|------------------|----------------|
| **Create** | "Created [thing]" + navigate to the new item or show it in the list | Silent success — thing is created but user doesn't know where it went |
| **Save/Update** | "Saved" indicator near the save button, or auto-save badge | No indication — user saves, nothing changes visually, they're unsure |
| **Delete** | Confirmation dialog with specifics, then "Deleted" toast, item removed from view | Item disappears with no confirmation or toast |
| **Send** (email, message) | "Sent to [recipient]" with details | "Message sent" — sent to whom? Did it really send? |
| **Bulk action** | "Updated 12 of 12 items" with count | "Done" — how many? Did they all succeed? |
| **Error** | Specific error on the specific field, form state preserved | Page-level error banner, form cleared, user has to re-enter everything |
| **Long-running action** | Progress bar or step indicator, estimated time | Spinner for 30 seconds with no indication of progress |
| **Background action** | Toast "Processing in background, we'll notify you when done" | Nothing — user doesn't know if it's still running or failed silently |
**The test**: After every action you take during the walkthrough, ask: "Do I know what just happened? Am I confident it worked? Do I know what to do next?"
## Data Edge Cases
Test how the app handles unusual but realistic data:
| Scenario | What to check |
|----------|--------------|
| **Very long text** | Paste a 500-char name, title, or description. Does it truncate, wrap, or overflow? |
| **Special characters** | O'Brien, café, Müller, emoji 🎉, `<script>alert(1)</script>` — does it render or break? |
| **Empty strings** | Submit forms with spaces-only input. Does validation catch it? |
| **Large numbers** | Enter $999,999.99 or 100,000 items. Does formatting hold? |
| **Many items** | If a list/table has 100+ rows, does pagination work? Does it lag? |
| **No data** | What does the page look like with zero records? Helpful empty state or blank void? |
| **Concurrent edits** | Open the same record in two tabs, edit both, save both. What happens? |
| **Rapid clicks** | Click a submit/action button 5 times fast. Does it fire once or create duplicates? |
## Navigation & Routing
| Scenario | What to check |
|----------|--------------|
| **Direct URL access** | Paste a deep link (e.g. `/app/clients/123`). Does it load or redirect to login then back? |
| **Browser back/forward** | After navigating 3 pages deep, does back button work correctly? |
| **Bookmark-able** | Does the URL change when you navigate? Can you bookmark and return? |
| **404 handling** | Navigate to a non-existent route (e.g. `/app/doesnotexist`). Graceful error or white screen? |
| **Stale URLs** | If a record is deleted, what happens when you visit its URL? |
## The Big Questions
Ask these after completing (or failing) the task. These are the dogfooding questions — they go beyond "does it work?" to "is it good?"
1. **Would I come back?** Or would I look for an alternative tool next time?
2. **Could I teach someone else?** In under 2 minutes, with no documentation?
3. **What's the one thing?** If I could change one thing to make this twice as easy, what would it be?
4. **Where did I hesitate?** Every hesitation is a design failure — the interface made me think instead of act.
5. **Did I trust it?** At any point, was I unsure whether my data was saved, my action was processed, or my input was correct?
6. **What would a busy person skip?** Which steps feel optional, tedious, or "I'll do that later"?
7. **Would I recommend this?** Not just "it works" but "you should use this, it's good."
references/workflow-comprehension.md
# Workflow Comprehension
Evaluate whether users can understand the system, follow their work through it, and build a mental model that makes them productive. This goes beyond individual screens to assess the **connective tissue** between pages, sections, and actions.
## The Core Question
> If someone sat down with this app on their first day at work, could they figure out how to do their job? Not just "click the right buttons" but actually understand what the system is for, how their work flows through it, and what they should do next at every point.
## Workflow Threads
A "thread" is a sequence of actions that makes up a real task in the user's day. Before evaluating, identify the main threads:
**Examples for an insurance app:**
- Thread 1: Client calls about a renewal → find their policy → review details → draft renewal email → send
- Thread 2: New enquiry comes in → create client record → add policy details → set follow-up
- Thread 3: Morning routine → check what needs attention today → work through the list
**Examples for a project management app:**
- Thread 1: Start the day → see what's assigned to me → pick a task → update progress → mark done
- Thread 2: Client asks for a status update → find the project → see overall progress → send summary
- Thread 3: New project comes in → create it → break into tasks → assign team → set deadlines
### How to Identify Threads
1. Look at the app's navigation structure — each top-level section usually maps to a thread
2. Think about the user's actual day, not the developer's route structure
3. Ask: "What would someone do first thing in the morning? What's the most common task? What's the most important task?"
### How to Test a Thread
Follow each thread end to end. At every step, evaluate:
| Question | What you're checking |
|----------|---------------------|
| **Do I know where to start?** | Is the entry point for this thread obvious from the dashboard or nav? |
| **Does the next step suggest itself?** | After each action, is the logical next step visible or do I have to hunt? |
| **Can I see my progress?** | Is there any indication of how far through the workflow I am? |
| **Can I leave and come back?** | If I get interrupted, can I find my place again tomorrow? |
| **Does it end clearly?** | When the thread is complete, do I know it's done? Is there a confirmation or just... nothing? |
## Wayfinding
Can the user orient themselves at any moment?
| Signal | What to check |
|--------|--------------|
| **Current location** | Does the nav/sidebar highlight where I am? Is there a breadcrumb? |
| **Context from URL** | Does the URL tell me where I am? (e.g. `/clients/jenny-obrien/policies` vs `/app/page/3`) |
| **Page title/heading** | Does the heading tell me what I can do here, not just what the page is called? |
| **Back navigation** | Can I always get back to where I came from? Is there a clear "up" direction? |
| **Section grouping** | Are related pages grouped together in the nav? Does the grouping match how I think about my work? |
### Wayfinding Red Flags
- Same page title on multiple different pages
- Nav doesn't highlight current page
- No way to get back to a parent page except the browser back button
- Breadcrumbs that show route structure instead of logical hierarchy
- Sidebar sections that don't match the user's mental model of their work
## Visual Cues and Nudges
The app should guide users through visual design, not just labels.
| Cue type | What to look for |
|----------|-----------------|
| **Primary action prominence** | Is the main thing I should do on this page visually dominant? (Size, colour, position) |
| **Status indicators** | Can I tell at a glance which items need attention? (Colour coding, badges, icons) |
| **Empty state guidance** | When a section has no data, does it tell me what to do? ("No policies yet — create one") |
| **Progressive disclosure** | Does the app show me what I need now and hide complexity until I need it? |
| **Affordances** | Do clickable things look clickable? Do input fields look like input fields? |
| **Grouping and proximity** | Are related actions near each other? Is there visual separation between unrelated things? |
| **Notification/attention magnets** | Are there badges, counts, or indicators that pull me toward things that need action? |
### Nudge Quality Check
For each page, ask:
1. If I squint at this page, what draws my eye first? Is that the right thing?
2. Are there visual "dead zones" where important actions are hidden?
3. Does the colour/weight hierarchy match the importance hierarchy?
4. Could someone who doesn't read English still roughly understand the page structure from visual cues alone?
## Page-to-Page Continuity
The transitions between pages are where comprehension breaks down most often.
| Transition | What to check |
|-----------|--------------|
| **After creating something** | Am I taken to the thing I just created? Or dumped back at a list? |
| **After saving/updating** | Is there confirmation? Can I see what changed? |
| **After deleting** | Where do I end up? Does the list update? Is the deleted item gone? |
| **Navigating from a list to a detail** | Does the detail page have context about where I came from? Can I get back to the filtered list? |
| **Moving between sections** | If I'm working on a client and switch to emails, does the email section know which client I was looking at? |
| **Deep linking** | If someone sends me a link to a specific record, do I have enough context when I land there? |
### Continuity Red Flags
- Creating a record and being sent to a different section entirely
- Losing filter/sort state when navigating back to a list
- Detail pages with no "back to list" or parent navigation
- Sections that feel disconnected — no cross-references between related data
- Having to re-enter context (re-select a client, re-apply a filter) when switching between related pages
## Mental Model Alignment
Does the app's structure match how the user thinks about their work?
### How to Test
1. **Name the sections**: Read the nav labels out loud. Would the user use these words to describe their work? Or are they developer terms?
2. **Draw the relationships**: If a client has policies, and policies have documents, and documents have versions — does the app's navigation reflect these relationships?
3. **Find the mismatch**: Where does the app organise by technical entity (Users, Records, Settings) instead of by task (Manage Clients, Handle Claims, Run Reports)?
4. **Check the verbs**: Are the action buttons labelled with what the user wants to achieve ("Send Renewal") or with what the system does ("Create Email")?
### Common Model Mismatches
| App says | User thinks |
|----------|------------|
| "Create Record" | "Add a new client" |
| "Entities" | "People" or "Companies" |
| "Workflow" | "What I need to do today" |
| "Submit" | "Save" or "Send" or "Done" — submit to where? |
| "Dashboard" | "My overview" or "What needs attention" |
## Productivity Assessment
After following all threads, ask:
1. **Time to value**: How long before a new user can do something useful? Minutes? Hours? Days?
2. **Repeat efficiency**: Does the second time doing a task feel faster than the first? Are there shortcuts for power users?
3. **Error recovery cost**: When something goes wrong, how many steps to get back on track?
4. **Context switching cost**: When the user moves between threads, how much re-orientation is needed?
5. **Completeness**: Can the user finish their real-world task entirely in this app, or do they need to jump to email/spreadsheets/another tool for some steps?
## Reporting Workflow Issues
Workflow comprehension issues are often **High** severity even though they're not bugs. A user who can't figure out how to use the app won't file a bug report — they'll just stop using it.
When reporting, frame the issue as what the user experiences:
```
BAD: "Nav labels don't match domain terminology"
GOOD: "A new insurance broker wouldn't recognise 'Entities' as the place to manage their clients.
They'd look for 'Clients' or 'Contacts'. The current label requires insider knowledge."
```
```
BAD: "No post-create redirect"
GOOD: "After creating a new policy, the user is sent back to the policy list instead of the
policy they just created. They have to hunt through the list to find it and continue
adding details. This breaks the natural workflow of 'create → configure → review'."
```
SKILL.md
---
name: ux-audit
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 verdict — a sweep that didn't interact terminates with verdict 'Incomplete'. Walks threads, exercises every element, runs the multi-pane stress matrix, visual polish sweep, component perfection checklist, automated a11y (axe-core), pragmatic performance budget (LCP/CLS/INP), scenario battery (11 scenarios), and stress recipes including the real-flavour data battery. Hard gates: console errors/warnings = 0, network 5xx = 0, layout collapse = 0, axe Critical/Serious = 0, perf budget green. Audit-the-audit meta-check rejects rushed reports. Each finding has reproduction steps, evidence path, and suspected code location. Trigger with 'ux audit', 'walkthrough', 'qa sweep', 'audit the app', 'dogfood this', 'check all pages', 'find what's broken', 'stress the UI'."
compatibility: claude-code-only
---
# 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](references/performance-budget.md). Full a11y wiring + severity mapping in [references/a11y-automation.md](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](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](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](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](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](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](references/walkthrough-checklist.md) and [references/workflow-comprehension.md](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](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](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](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](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](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](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](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](references/audit-config.md)) for builder-mode reference pages (Components, Style guide). Full snippet, severity mapping, and findings format in [references/a11y-automation.md](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](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](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](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](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](references/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](references/report-template.md) and [references/perfection-checklist.md](references/perfection-checklist.md). Discipline rules in [references/audit-output-discipline.md](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](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](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](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](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](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](references/project-adaptation.md) |
| Persona library + writing protocol + persona-overload pattern | [references/persona-lock.md](references/persona-lock.md) |
| Auth expiry mid-audit — protocol + headless test-auth resumption | [references/auth-expired-handling.md](references/auth-expired-handling.md) |
| Data seasoning horizons (Day 0 / 1 / 7 / 30) + project seed-script architecture | [references/data-seasoning.md](references/data-seasoning.md) |
| Audit-config allowlist format + semantics + surface overrides | [references/audit-config.md](references/audit-config.md) |
| Interaction Manifest template + replay protocol | [references/interaction-manifest.md](references/interaction-manifest.md) |
| Multi-pane stress matrix + automation snippets | [references/multi-pane-stress.md](references/multi-pane-stress.md) |
| Per-screen evaluation questions, layout-detection JS | [references/walkthrough-checklist.md](references/walkthrough-checklist.md) |
| Wayfinding, mental model, page-to-page continuity | [references/workflow-comprehension.md](references/workflow-comprehension.md) |
| Full protocol for each of the 11 scenarios | [references/scenario-tests.md](references/scenario-tests.md) |
| Extended stress recipes (race, slow network, reduced motion, i18n) | [references/stress-test-recipes.md](references/stress-test-recipes.md) |
| Component-level perfection checklist (6 categories + 6 states) | [references/perfection-checklist.md](references/perfection-checklist.md) |
| AI-tell catalogue, optical centring, design-token discipline | [references/visual-polish.md](references/visual-polish.md) |
| Silent-failure controls + SDK contract checks | [references/live-interaction-smoke.md](references/live-interaction-smoke.md) |
| Playwright killer-flow test starters | [references/playwright-killer-flows.md](references/playwright-killer-flows.md) |
| Report format, verdict block, severity rubric, reproduction-step format | [references/report-template.md](references/report-template.md) |
| Browser tool commands and viewport notes | [references/browser-tools.md](references/browser-tools.md) |
| Round-trip workflow integrity (A→B→A pattern) | [references/round-trip-workflows.md](references/round-trip-workflows.md) |
| Automated accessibility (axe-core injection + severity mapping) | [references/a11y-automation.md](references/a11y-automation.md) |
| Pragmatic performance budget (LCP/CLS/INP via Performance API) | [references/performance-budget.md](references/performance-budget.md) |
| Long-running audit supervision via 15-min `/loop` | [references/long-running-check-in-pattern.md](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.