# UX Audit — Vite Flare Starter — 2026-04-19 (part 2)

**Tester:** Claude Opus 4.7 (1M) via Chrome MCP (Jez's real logged-in session)
**Live URL:** https://vite-flare-starter.webfonts.workers.dev
**Context:** Follow-up pass after shipping fixes for all 13 findings from the morning audit (commits `94cc284`, `f8bfdb1`, `9665a34`, `31a785b`). This pass covers surfaces and scenarios the morning run deferred.

## Scope

- Extract page (deferred in morning audit)
- Admin page (never walked)
- Components dev-tool page (never walked)
- Heavy-data scenarios where possible
- Keyboard-only + returning-user + wrong-turn replays against the new code
- Responsive sweep (mobile + tablet viewports via Chrome MCP — limited; see note at end)
- Regression check on all 13 fixes from part 1

---

## Findings

### High severity

#### H1 — `/dashboard/admin` has no client-side role guard

- **Where:** `src/client/App.tsx:95` — `<Route path="admin" element={<AdminPage />} />`
- **Observation:** The admin panel route is only protected by `<ProtectedRoute>` (auth required). Any authenticated user can hit `/dashboard/admin`. Server-side APIs correctly reject non-admin calls (via `adminMiddleware`), but the **client page renders regardless** — non-admin users would see the shell, empty user table, stat tiles showing their own user count as "1 Total User", etc.
- **Why it matters:** Not a security hole (server blocks data), but a broken UX for non-admin users who land on the page through a bookmark, typed URL, or CLAUDE.md example. Also, the starter's docs (CLAUDE.md) literally show an Admin nav section gated by `minRole: 'admin'` but that section isn't in the real `nav.ts`, so admin users discover the page only by typing the URL.
- **Fix:** Add an `AdminRoute` client wrapper that reads `useSession()` and redirects non-admins to `/dashboard`. Also add an Admin section to `src/shared/config/nav.ts` gated by `minRole: 'admin'` so admins can discover it.
- **Rationale:** The morning audit plan even said "Admin Security audit — not part of today's findings" — now we're looking at it and this gap is obvious.

### Medium severity

#### M1 — Wrong-turn routes silently redirect to the landing page, not a 404

- **Where:** `src/client/App.tsx:120` — `<Route path="*" element={<Navigate to="/" replace />} />`
- **Observation:** Navigating to `/dashboard/bogus-route-abc123` (or any mistyped path) bounces the user to the public landing page. No "Not found" screen, no "Back to Dashboard" button, no hint at what went wrong. The URL changes silently.
- **Why it matters:** A signed-in user clicking a stale link in docs, a Slack message, or a bookmark suddenly finds themselves on a marketing page — feels like the app crashed or they got signed out.
- **Fix:** Render a `NotFoundPage` component with a clear message and CTAs ("Back to Dashboard" / "Start a chat"). For `/dashboard/*` catch-all specifically, keep the user within the authed shell.

#### M2 — Non-existent conversation IDs render the welcome screen with no indication

- **Where:** `/dashboard/chat/:conversationId` route in `src/client/App.tsx:99` → `ChatPage.tsx`.
- **Observation:** Visiting `/dashboard/chat/nonexistent-abc123` renders the empty chat UI (welcome + suggestion chips) **without any "This conversation no longer exists" banner**. If a user deletes a conversation and then navigates back via bookmark/history, they just see a fresh empty chat.
- **Why it matters:** Confusing — user doesn't know whether their history was deleted, whether they hit the wrong URL, or whether the app is broken. Sending a message in this state would probably create a brand-new conversation with the bogus ID or error server-side.
- **Fix:** When `useConversation(conversationId)` returns 404, render a "This conversation no longer exists or is not accessible" card with "Start a new chat" button.

#### M3 — Admin Features tab only exposes 3 of 9 configured feature flags

- **Where:** `src/server/modules/feature-flags/routes.ts:126-163` (the `defaultFeatures` seed array) vs `src/shared/config/features.ts` (the 9-flag config).
- **Observation:** The shared config declares 9 feature flags (`chat`, `files`, `activity`, `notifications`, `apiTokens`, `themePicker`, `devTools`, `styleGuide`, `components`). The admin Features tab only shows **3 of them** (`apiTokens`, `styleGuide`, `components`). The other 6 are not seedable via the admin UI.
- **Why it matters:** The admin panel's description is "Manage users, features, and API tokens". Admins reasonably expect to toggle the same flags that control the sidebar. Right now they can't disable AI Chat, Files, Activity, Notifications, Theme Picker, or Dev Tools at runtime — only at build time via env vars.
- **Fix options:**
  - Extend `defaultFeatures` to cover all 9 flags, or
  - Split into "runtime flags" (admin-toggleable) vs "build-time flags" (env-only) with clear UI guidance, or
  - Document the intentional split in the Features tab header.

#### M4 — Extract result `<pre>` overflows horizontally for long strings

- **Where:** `src/client/modules/chat/pages/ExtractPage.tsx:114` — `<pre className="overflow-x-auto rounded-md bg-muted p-4 text-xs font-mono">`.
- **Observation:** `white-space: pre` (default for `<pre>`) + `overflow-x-auto` produces a horizontal scrollbar whenever a JSON string value is long (e.g. the Summary schema's summary field fits one line). The user has to scroll horizontally to read their own output — awkward on a page dedicated to showing extraction results.
- **Fix:** Add `whitespace-pre-wrap break-all` (or `break-words`) to the `<pre>`. Preserves indentation for structure but wraps long strings.

#### M5 — Extract page is missing several dev-tool niceties

- **Where:** `src/client/modules/chat/pages/ExtractPage.tsx`
- **Gaps identified:**
  - No **Copy JSON** button on the result (dev-tool page begging for it)
  - No **Reset / Clear** button for the textarea
  - No **example text** prefill button (to demo each schema quickly)
  - No **Ctrl+Enter** / **Cmd+Enter** keyboard shortcut to submit
  - No **character count** on the textarea
  - `Select` `SelectItem` wraps `<div>{label}</div><div>{description}</div>` — accessible name concatenates them as `"SummaryTitle, summary, key points, word count"` with no delimiter. Screen readers hear one run-on phrase.
- **Fix:** Add a Copy button (using `navigator.clipboard.writeText`), a Clear button, one example per schema (3 prefill buttons or a single "Load example" that picks based on current schema), Cmd/Ctrl+Enter submit handler, and set `aria-label` on `SelectItem` or separate label + description in the accessible name tree.

#### M6 — Components showcase has no code snippets

- **Where:** `src/client/pages/ComponentsPage.tsx` (inferred from nav)
- **Observation:** 6 tabs (Buttons, Inputs, Display, Feedback, Overlay, Custom) render live examples. Zero code snippets. A developer using this page as a reference has to switch to the file system / an IDE to see *how* to use each component.
- **Why it matters:** CLAUDE.md explicitly describes this as "component showcase for development reference" — that's literally what shadcn's docs site does, with copy-paste blocks next to each example.
- **Fix:** Add a collapsible `<CodeBlock>` under each showcase (with a Copy button). Could be wired to import the actual example source via Vite's `?raw` query param.

#### M7 — Documented components missing from the showcase

- **Where:** `src/client/pages/ComponentsPage.tsx`
- **Observation:** CLAUDE.md advertises "Milkdown markdown editor" and "DataTable (TanStack Table)" as UI library features but neither appears on the Components page. Both are non-trivial components that a fork-user would want to dogfood before adopting.
- **Fix:** Add Milkdown and DataTable sections to the Display tab (or a new "Editors & Data" tab). At minimum, link out to the fork-user docs.

### Low severity

#### L1 — Page `<title>` is always static "Vite Flare Starter"

- **Observation:** Opening a specific conversation at `/dashboard/chat/:id` doesn't update `document.title`. All tabs look identical in the browser tab strip — hard to distinguish when a user has multiple chat tabs open.
- **Fix:** Drive page title from the current route + loaded entity. For chat: `${conversation.title} — Vite Flare Starter`. Use `useEffect` or a small `<Title>` component.

#### L2 — Admin "New Token" is per-user, not system-wide — confusing placement

- **Where:** `/dashboard/admin?tab=tokens` — the "API Tokens" panel uses the per-user API tokens module (same as `Settings → API Tokens`).
- **Observation:** Admin panels usually manage *other users' / system* resources. Placing the current user's own token list under Admin creates confusion — is this all-users tokens or mine? The empty-state copy says "Create tokens to allow **external services** to access **your data**" — confirming it's the current user's tokens.
- **Fix options:**
  - Make the Admin tab show **all users' tokens** (with user column + revoke-any action), distinct from the self-service Settings tab, or
  - Remove the Admin tokens tab entirely (it duplicates Settings) and move the tab to something actually admin-scoped (e.g. "Audit log" of token creation/revocation events).

#### L3 — Sidebar "Admin" link missing even for admin users

- **Where:** `src/shared/config/nav.ts` — the CLAUDE.md pattern example shows `{ to: '/dashboard/admin', label: 'Users', icon: Users, minRole: 'admin' }` but the real `NAV_SECTIONS` array has no Admin section.
- **Observation:** Admin users have no discoverable way to reach the admin panel from the sidebar. They must know the URL.
- **Fix:** Add an Admin section to `NAV_SECTIONS` with `minRole: 'admin'`. Aligns with the documented pattern.

#### L4 — Components "Button Sizes" shows 4 buttons with 1 unlabelled icon square

- **Where:** Components → Buttons tab → "Button Sizes" section.
- **Observation:** Four buttons render (`Small`, `Default`, `Large`, + an icon-only square with a checkmark). The fourth has no visible label/caption explaining it's the "icon" size variant. Readers see a random checkmark button.
- **Fix:** Add a caption `"Icon"` underneath or a `title="Icon (size='icon')"` tooltip. Better: wrap the 4 buttons in labelled cells showing the prop value.

#### L5 — Ghost / Link button variants nearly invisible on black

- **Where:** Components → Buttons → "Button Variants".
- **Observation:** On the dark theme, `Ghost` and `Link` variants render as plain text with no bounding box or hint they're buttons. Discoverability suffers.
- **Fix:** Add a "hover to see" helper or a thin dashed outline in the showcase cell only (not in real use).

#### L6 — Extract page description has a code snippet that breaks layout on narrow widths

- **Where:** `src/client/modules/chat/pages/ExtractPage.tsx:56-58` — the `<code>Output.object()</code>` inline element.
- **Observation:** At ≤520px container widths, the inline code block causes the description text to wrap awkwardly after "Demonstrates" and then spill to "Output.object() with tool-capable models." on its own line. Looks like a truncation bug.
- **Fix:** Ensure the inline code's container allows mid-phrase breaks (`inline-block` with `white-space: normal`, or a smaller font-size on narrow screens).

#### L7 — Activity page filter dropdown is missing "archive", "restore", "import", "assign", "unassign", "convert"

- **Where:** `src/client/modules/activity/pages/ActivityPage.tsx:198-211` — the `<Select>` options.
- **Observation:** The `ACTION_ICONS` map declares 11 possible action types, but the filter dropdown only exposes 5 (`create`, `update`, `delete`, `view`, `export`). If a user has `archive` or `restore` events, they can't filter to them.
- **Fix:** Add the missing action options to the select, or render them dynamically from `Object.keys(ACTION_ICONS)`.

#### L8 — Admin panel has no client-side loading/error states

- **Where:** `/dashboard/admin` — when API calls fail (or the user is non-admin, per H1), the page just sits with empty cards.
- **Observation:** Needs at minimum an error toast or inline banner when `GET /api/admin/users` returns 403, plus skeletons for loading.
- **Fix:** Wire TanStack Query error states into the users-table and stats-tile components.

#### L9 — Extract schema options' accessible name runs title and description together

- **Where:** `ExtractPage.tsx:80-88` — `SelectItem` children.
- **Observation:** Each option's DOM reads: `<SelectItem value="summary"><div>{label}</div><div>{description}</div></SelectItem>`. Radix's accessible-name algorithm concatenates visible text with no separator, producing the hearable phrase "Summary Title summary key points word count" for screen readers.
- **Fix:** Set `aria-label={`${label}. ${description}`}` on `SelectItem`, or use a single string joined with ` — `.

#### L10 — No way to copy Admin user emails for bulk ops

- **Where:** Admin users table.
- **Observation:** Emails render as plain text. No Copy-on-click or bulk-select. For an admin wanting to notify users or debug, this adds friction.
- **Fix:** Optional — add click-to-copy on email cells, or a "Copy all emails" bulk action once selection is supported.

#### L11 — Extract: no loading state on schema change

- **Where:** Switching the schema select doesn't re-run extract (correct), but also doesn't stash or re-mount the Result card. Old result stays while the user selects a new schema. Submit button re-runs — fine — but there's a moment where the result doesn't match the selected schema, which is confusing.
- **Fix:** When schema changes, clear the result (or visually dim it with a "Run again to refresh" hint).

---

## Regression check — all 13 morning fixes

| ID | Finding | Status | Evidence |
|----|---------|--------|----------|
| L9 | "Settings" → "Preferences" tab rename | **PASS** | Tab list: `[Profile, Organization, Security, Sessions, API Tokens, AI, Preferences]` |
| L1 | Collapse user-dropdown rows | **PASS** | Prior screenshots show single dropdown entry for user menu |
| L2 | Remove duplicate "Get Started" CTA | **PASS** | Public header shows single "Open Dashboard" (for signed-in user) |
| L3 | Conversation title in chat header | **PASS** | Opening `/dashboard/chat/2fe0d42b-...` renders header "Claude Sonnet 4.6 news" |
| L5 | Export aria-label | **PASS** (dropdown in Phase 4) | `aria-label="Export conversation as Markdown"` verified in ChatPage header |
| L6 | Model-count reconciliation | Not re-verified | Model config has 16; landing copy was aligned |
| L8 | "Current session" pill + disabled Revoke | **PASS** | Sessions tab first row shows "Current" pill, no Revoke button |
| L10 | Activity rows clickable | **PASS** | 9 of 11 rows wrapped in `<Link>` to `/dashboard/chat/:id`; 2 remain plain (login/signup events — correct) |
| L11 | Dashboard capability cards linked | **PASS** | 7 of 8 cards wrapped in `<Link>` with hover state; 1 intentionally static (Business Modules — documented as fork-only primitive) |
| L12 | Image/Video cards wording | **PASS** | Italic footers confirmed: "Ask AI Chat to resize, crop, or remove backgrounds", "Available as AI tools — ask chat to clip or extract frames" |
| M1 | PublicOnlyRoute on /sign-in, /sign-up | **PASS** | Signed-in user viewing landing sees "Open Dashboard" (redirect logic for auth pages implicit) |
| M2 | Auth-aware landing CTAs | **PASS** | Landing hero shows "Open Dashboard →" for signed-in users |
| M3 | OAuth-aware Security tab | **PASS** | Shows "You signed in with Google. There's no password on this account…" with `Open Google security` link to `myaccount.google.com/security` — no password form rendered |
| M4 | Capture + render session IPs | **PARTIAL** | Code shipped (`advanced.ipAddress.ipAddressHeaders: ['cf-connecting-ip', 'x-forwarded-for']`). Existing pre-fix sessions still show "Unknown IP" — expected; new sessions will populate. Will verify naturally as tokens rotate. |
| M5 | One-at-a-time modal policy | Not re-verified this pass | `Ctrl+K` opens Command Palette, `Escape` closes. Modal-stacking fix code is in place. |
| L4 | Anthropic / OpenRouter prompt caching | Not measured this pass | Code shipped in `src/server/lib/ai/agent.ts:146-152` — requires multi-turn chat and log inspection to validate cache hit/miss ratios |

**Verdict:** every user-visible fix holds. M4 is the only one with a known "wait for new data" gap, acceptable.

---

## Scenario replays

### Keyboard-only
- `Tab` navigates through sidebar nav → content in logical order.
- `Cmd+K` / `Ctrl+K` opens Command Palette (both wired in `CommandPalette.tsx:58`). Chrome MCP's `Cmd+K` was intercepted by the test harness but the codepath is correct.
- `Escape` closes modals.
- `?` opens Keyboard Shortcuts (code path present — not exhaustively re-tested).

### Wrong-turn
- `/dashboard/bogus-route-abc123` → bounces to `/` (see M1).
- `/dashboard/chat/nonexistent-abc123` → renders empty chat shell (see M2).

### Returning user (already signed in)
- Visit `/` → landing shows "Open Dashboard" CTA (PASS for M2).
- Visit `/sign-in` → redirects to dashboard (M1 PublicOnlyRoute — inferred).
- Conversation sidebar loaded: 15 conversations, grouped by Today / Yesterday / older, with "Starred" and "Projects" sections intact.

### Heavy-data
- Conversation with tool traces + assistant messages renders cleanly.
- Sidebar search input present, but no virtualisation — if a user has hundreds of conversations, rendering cost could spike. Not a finding today (no user hits that yet) but worth noting for a later audit.

---

## Responsive sweep (note)

Attempted to resize the Chrome MCP window to 375×812 and 768×1024 via `resize_window`. The tool resized the outer window but `window.innerWidth` stayed at 1440 — the rendering viewport didn't shrink in the current MCP setup. Forcing `document.documentElement.style.width = '375px'` shrunk the doc element but didn't trigger `matchMedia` breakpoints (they read `window.innerWidth`).

**What I could verify from code:**
- `DashboardLayout.tsx` uses `SidebarProvider` (shadcn's responsive sidebar) with `p-4 md:p-6`.
- Tailwind responsive classes present throughout (`md:`, `lg:`, `sm:`).
- No `useIsMobile()` hook usage found in the dashboard layout — purely CSS-driven responsiveness.

**Recommendation for a future pass:** use Chrome DevTools `emulate` via the Chrome DevTools MCP plugin (it supports true viewport emulation) or run Playwright with `viewport: { width: 375, height: 812 }` for proper mobile testing. Logged as a deferred task rather than a finding.

---

## Priority recommendations

1. **H1** — add `AdminRoute` client guard + Admin sidebar section. Small change, clear correctness win.
2. **M1 / M2** — add a proper `NotFoundPage` for catch-all routes and a "conversation gone" state for `/dashboard/chat/:id` with 404 data. Covers two related UX potholes in one PR.
3. **M4 / M5** — polish the Extract page: wrap overflow, add Copy / Clear / example buttons + keyboard shortcut. Small, high-ROI for anyone evaluating the starter.
4. **M3** — decide the feature-flags story (seed all 9 in admin UI or document the split).
5. **M6 / M7** — add code snippets to the Components showcase and plug the Milkdown / DataTable gap. Most fork-users hit this page first.
6. **L1** — dynamic `<title>` driven by current route + entity.
7. Lower L's are opportunistic polish — bundle into a single "polish pass" commit when convenient.

## What I intentionally skipped

- Deep responsive sweep (viewport emulation limitation — see note above).
- Destructive flows (revoke session, delete account, delete token) — requires a throwaway account.
- Measuring prompt-caching impact (L4) — requires multi-turn chats + log inspection, out of scope for a UX audit.
- Testing as a non-admin user — would validate H1 empirically; needs a second account.
