# UX Audit — 2026-04-23

**Target**: https://vite-flare-starter.webfonts.workers.dev
**Persona**: A developer evaluating the starter. Technical, competent with React/Cloudflare, signed in with Google yesterday to dogfood the demo before forking it into their own SaaS build. Wants to understand which patterns ship out of the box and whether anything is broken.
**Viewport**: 1440 × 900 (baseline)
**Browser tool**: Chrome MCP (logged-in session)
**Prior audits referenced**: `ux-audit-post-roadmap-2026-04-21.md`, `ux-audit-2026-04-22.md`, `ux-audit-2026-04-22-google-workspace.md`

## Status

**Complete + fixes shipped.** 5 threads + connector drill-down + responsive sweep + activity/skills spot-check. 17 findings logged (2 Critical, 3 High, 5 Medium, 6 Low/Polish), 1 retracted after deeper investigation, + 4 works-well callouts.

### Shipped in this session (2026-04-23) — Round 1

| Finding | Fix | Files |
|---|---|---|
| A-THEME-LABEL | Derive label from resolved DOM mode (new `useResolvedMode` hook watching `html.dark` class) — works for `theme === 'system'` case | `theme-provider.tsx`, `site-header.tsx` |
| L-SIDEBAR-CASE | Unified "Toggle sidebar" casing (shadcn defaults were capital-S) | `ui/sidebar.tsx` |
| M-CHAT-EMAIL-CHIP-CLIPPED | `items-start md:items-center` on chat empty-state wrapper | `ChatPage.tsx` |
| M-TOOL-NAMES | New `defaults.tsx` with ~40 tools' displayName+icon — fixes "Fs List" → "List Files" etc. | `tool-renderers/defaults.tsx`, `tool-renderers/index.ts` |
| C-CONNECTOR-ACCIDENTAL-DISCONNECT | All 3 native `confirm()` calls replaced with shadcn `AlertDialog`. Added `e.stopPropagation()` on trash-icon clicks to prevent card-click bubble. Browser modals no longer block automation or accidental-dismiss data loss | `GoogleWorkspacePanel.tsx`, `ConnectorsPage.tsx`, `ConnectionDetail.tsx` |
| C-CHAT-VOICE | New `VoiceDictationButton` wired into the chat input. Streams STT via `useVoiceInput` (Deepgram Nova 3 on the voice DO). Interim transcripts appear live in the textarea; final transcript stays for user to edit + send | `VoiceDictationButton.tsx`, `ChatPage.tsx` |
| H-GWS-CARD-STATE-BROKEN | Retracted — card is correct (separate Workspace token table by design) | — |

### Shipped in this session (2026-04-23) — Round 2

| Finding | Fix | Files |
|---|---|---|
| M-REASON-LABELS | `computeReasoningLabel` helper labels multi-reasoning blocks as "Planned" / "Reviewed (step N of M)" / "Concluded" instead of identical "Thought for a few seconds" pills. Single-block messages keep the default. | `MessageRenderer.tsx` |
| M-SESSION-IP-UNKNOWN | Lazy IP backfill in `/api/settings/sessions` — on first visit, fills null `ipAddress` from `cf-connecting-ip`. One DB write per session, gated by `isNull` check | `settings/sessions.ts` |
| L-RAG-INDEX-STATUS | Added "Not indexed" pill with dashed border for null-state files so users know they're not enrolled for semantic search | `FileList.tsx` |
| L-RAG-RAW-KEYS | `fs_list` now joins with D1 `files` table and includes a `name` field alongside the raw R2 path — agent can reference friendly filenames | `chat/tools/files.ts` |
| L-UPLOAD-DIALOG | Added context panel below dropzone — destination folder, supported formats for search (PDFs/text/markdown/DOCX/images/audio), "indexing runs automatically" hint with pill preview | `files/FileUploader.tsx` |
| L-KBD-HINT | Added `CommandPaletteHint` pill to site-header — claude.ai-style search pill at sm+, icon button at mobile. Dispatches synthetic ⌘K/Ctrl+K event to open the existing CommandPalette | `site-header.tsx` |
| L-CLONE-CTA | Landing page "Ready to build?" now shows `git clone …` block + link to FORKING.md | `LandingPage.tsx` |
| C-ORG-TERMINOLOGY-COLLISION | Scope comment posted on GitHub issue #18 locking "Teams" as user-facing name, better-auth `organization` internal. No code change this session — decision locked for when #18 is picked up | [issue #18 comment](https://github.com/jezweb/vite-flare-starter/issues/18) |

**Live-verified**: all four visible changes confirmed in a single chat screenshot — "⌘K Search" pill in header, "List Files" tool name (not "Fs List"), "Planned" + "Concluded" reasoning labels, VoiceDictationButton mic icon. Landing page shows the clone command + FORKING.md link.

**Deployed**: ✓ Two `wrangler deploy` runs in this session. **Type-check + build**: ✓ clean both times.

**Remaining open** (out of scope for this session): L-HOME-REPEATED-CTA (3× "Open Dashboard" on landing — cosmetic), L-TOOL-ARGS (raw JSON of tool inputs — partially served by the fallback renderer already showing `input` as JSON).

---

## Findings

### [HIGH] C-CHAT-VOICE — Chat mic uses "upload + ask to transcribe" pattern instead of streaming dictation

**Location**: `src/client/modules/chat/pages/ChatPage.tsx:1061`, handler at line 562

**Observed**: Pressing the mic button in the chat toolbar triggers `<AudioRecorder compact onRecordingComplete={handleAudioRecording} />`. The handler uploads the blob to R2 as a file and fires:

```tsx
sendMessage({
  text: 'Transcribe this audio recording.',
  files: [{ type: 'file', url, mediaType: blob.type }]
})
```

The transcript then appears as the **assistant's message**, not as editable user input.

**Why it's a problem**:
- Breaks dictation UX — you can't dictate a query, you dictate a request TO transcribe
- No interim/live transcript — user has to stop recording and wait for the full STT turn
- User confirmed via Ambient Notes dogfood: watching words appear as you speak lets you self-correct mid-sentence. Batch kills that
- iPhone dictation, ChatGPT Voice, Gemini, Claude mobile all stream partials into the input field. This doesn't

**Intended pattern (agreed with user)**:
- Wire `useVoiceInput` hook (from `@cloudflare/voice/react`) into the chat mic button
- Partial transcripts fill `PromptInput` textarea live as user speaks
- Final transcript stays in field on stop; user edits + sends
- DO instance name = `userId` (shared across conversations, cheap connect/disconnect)
- Decouple from `VITE_FEATURE_VOICE_AGENT` flag — that flag only gates the standalone demo page, not the DO itself (always compiled)
- Keep existing `transcribe_audio` agent tool for the "attach an audio file to a thread" flow — different use case

**Recommendation**: Ship the streaming dictation wiring. ~45-60 min.

---

### [HIGH] A-THEME-LABEL — Theme toggle aria-label describes current state, not action

**Location**: Header theme toggle button (moon icon, top right)

**Observed**: When app is in dark mode (`html.className === "dark"`), the button's aria-label is **"Switch to dark mode"**. It should say **"Switch to light mode"** — the label describes the ACTION the button takes, and that action flips based on current state.

**Why it's a problem**:
- Screen reader users hear "Switch to dark mode" while already in dark mode — confusing
- Sighted users relying on tooltip will misread the button's function
- Classic labeling bug: icon-only controls must tell the user what will happen, not what's currently true

**Fix**: in the theme toggle component, derive label from current theme:

```tsx
const label = theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'
```

---

### [LOW] L-SIDEBAR-CASE — "Toggle Sidebar" vs "Toggle sidebar" — inconsistent casing

**Observed**: Two sidebar-toggle buttons (desktop + mobile variants) have aria-labels that differ only in case: one uses `"Toggle Sidebar"` (capital S), the other `"Toggle sidebar"` (lowercase s).

**Why it matters**: Not a blocker, but a symptom that the labels aren't centralised. Consistency in UI copy is a trust signal.

**Fix**: Extract into a single constant (e.g. `TOGGLE_SIDEBAR_LABEL = 'Toggle sidebar'`) and reuse.

---

### [LOW] L-KBD-HINT — No Cmd+K hint visible on dashboard

**Observed**: CLAUDE.md documents a Command Palette (Cmd+K) and Keyboard Shortcuts (press `?`) as shipped UI features. Neither is surfaced on the dashboard. A dev evaluating the starter won't discover them unless they read the docs or try the shortcuts on a hunch.

**Fix**: Add a subtle hint in the header — a small `⌘K Search` pill next to the bell/theme-toggle, or a persistent "?" help icon. claude.ai does this well with an always-visible search chip in the header.

---

### [MEDIUM] M-REASON-LABELS — "Thought for a few seconds" repeats identically before and after each tool call

**Location**: Chat transcript, reasoning disclosures on any message that triggered a tool call

**Observed**: When the agent reasons → tool-calls → reasons again, the UI renders TWO identically-labelled `"Thought for a few seconds"` disclosures. Only by expanding both can the user tell they contain different content:

- **Before tool**: explains which tool was chosen and why ("I'll use calendar_list_events with the today range")
- **After tool**: interprets the result ("No meetings found today")

**Why it's a problem**:
- User sees two identical-looking collapsed rows — confusing, looks like duplication
- Can't tell at a glance which reasoning step is which without expanding
- claude.ai and Gemini solve this by folding all reasoning into a single timeline inside one disclosure, or by labelling steps differently

**Fix options**:
1. **Consolidate** — single disclosure containing the full reasoning stream with tool-call inline between steps ("Thought for Xs" summarises total elapsed)
2. **Label differently** — first = "Chose calendar tool", second = "Interpreted result" (or similar — derived from position / preceding-tool context)

Recommend option 1 — closer to current LLM chat conventions.

---

### [LOW] L-TOOL-ARGS — Tool call expanded view doesn't show the input args it ran with

**Location**: Any expanded tool card (e.g. Calendar — List)

**Observed**: Expanding the Calendar tool card shows only the output ("No events in this window."). The INPUT the tool was called with — the resolved time range, calendar id, natural query translation echo — is not surfaced.

**Why it matters**:
- Debugging trust: when a tool returns empty, the user can't tell whether the query was correct
- The starter already has `translatedFrom` metadata for Gmail/Calendar natural-query translation (per CLAUDE.md) — that should echo in the tool card too
- "Calendar — List · no events" in the subtitle suggests it worked, but "no events for what?" is unanswered

**Fix**: Render the tool's input args in the expanded view as a read-only pretty-printed JSON block or a structured key/value list. If `translatedFrom` is present, show it prominently.

---

### [LOW] L-CLONE-CTA — Landing page "Ready to build?" section has no clone command or FORKING.md link

**Location**: `/` marketing landing

**Observed**: Final CTA says "Clone the repo and start building your next project in minutes" — but on-screen the only affordances are "Open Dashboard" and "Star on GitHub". No `git clone` command shown, no link to FORKING.md.

**Fix**: Add a one-line code block with the clone command (`git clone https://github.com/jezweb/vite-flare-starter`) and a secondary link to `./FORKING.md`. Targeted at the very user who just read "clone the repo".

---

### [LOW] L-HOME-REPEATED-CTA — "Open Dashboard" appears 3× on landing

**Location**: `/` header, hero, final CTA

**Observed**: The same primary CTA appears three times — in the header, the hero, and the bottom "Ready to build?" section. For a landing page this is fine (long scroll → each fold gets a CTA), but worth noting — consider whether the header version could be a different action (e.g. "Star on GitHub") so each CTA adds variety.

**Priority**: Low / cosmetic.

---

### [MEDIUM] M-TOOL-NAMES — Tool card display names are inconsistent (humanised vs raw snake_case)

**Location**: Chat transcript, any tool card

**Observed**:
- `calendar_list_events` renders as **"Calendar — List"** ✓ (humanised, calendar icon)
- `fs_list` renders as **"Fs List"** ✗ (raw snake_case → PascalCase with space, generic wrench icon)

Both came from the same message in the same session, so it's not a timing issue — the starter's `ToolDefinition.render.displayName + render.icon` metadata is missing for some tools and set for others.

**Why it matters**: User sees "Fs List" and doesn't know what it means. Raises a "what's this weird jargon tool?" question that shouldn't exist. The starter is a pattern library — if half the tools ship without render metadata, forks will think it's optional.

**Fix**: Audit `src/server/modules/chat/tools/*.ts` for tools missing `render.displayName` and/or `render.icon`. Every tool should have both. Consider adding a lint/type check that makes `render` required.

---

### [LOW] L-RAG-INDEX-STATUS — Files with `indexStatus: null` render no pill (invisible to user)

**Location**: `/dashboard/files`

**Observed**: The one existing file in the account (`save-test-1776770053725.png`) has `indexStatus: null`, `indexChunks: null`, `indexedAt: null` — it was uploaded pre-Phase 4 and was never ingested. The FileList renders no pill at all for the null state.

**Why it matters**: Per CLAUDE.md "FileList shows Indexed / Indexing… / Index failed pills". Users expect to see status for any file — `null` = invisible = "is this file even part of my searchable content?" unanswered. When the user runs `semantic_search` and gets no results, they won't know WHY (file never indexed vs. legitimate empty match).

**Fix**: Render a fourth pill state — "Not indexed" (grey/neutral) — for `indexStatus: null`. Optionally offer a "Reindex" action in the overflow menu.

---

### [LOW] L-RAG-RAW-KEYS — Agent surfaces raw R2 keys in conversational output

**Location**: Chat transcript, after an `fs_list` tool call

**Observed**: Agent answered "You have 2 files in your storage:
1. `uploads/7c5a0448-a8b3-4c9d-81a5-34630822b60a.png` — uploaded image
2. `generated/0c9e557f-dca8-4faa-96d4-150022f5e89c.png` — generated image"

**Why it matters**: The raw R2 key (with user UUID-folder prefix) is meaningless to a human reader. The starter stores a friendly `name` (`save-test-1776770053725.png`) and a human path (`folder: '/chat-attachments'`) in D1. The agent should reference those, not the R2 key.

**Fix**: In the `fs_list` tool output shape, prefer `name` + `folder` over `key` in the response rendering. Optionally add a `displayPath` derived field.

---

### [LOW] L-UPLOAD-DIALOG — Upload dialog is missing folder selector + format guidance + "indexing" hint

**Location**: `/dashboard/files` → Upload Files dialog

**Observed**: Dialog shows a simple drag-drop zone with "Max 10MB per file" limit repeated twice. Missing:
- **Folder selector** — files land where? (Root? The currently-filtered folder? `/chat-attachments`?)
- **Supported formats** — starter converts PDFs, images, audio via `env.AI.toMarkdown()` + vision fallback, but users can't tell which formats will actually be searchable
- **"Indexing takes a moment"** hint — RAG ingestion is async and the indexing pill will change over time. Nothing in the dialog prepares the user for that

**Fix**: Add a folder picker, a one-line "Supported: PDFs, text, markdown, images (via OCR), audio (transcribed). Up to 10MB.", and a subtle note: "We'll index the content for semantic search — this may take a few seconds after upload."

---

### [WORKS-WELL] Voice + Video example pages are textbook pattern demos

Both `/dashboard/voice-example` and `/dashboard/video-example` are excellent reference scaffolds:
- Clear title + subtitle explaining what the scaffold demonstrates
- Session ID surfaced for debugging
- Status pill ("idle")
- Action buttons ("Start listening" / "Camera off")
- **"How this works" explainer** — 4-step plain-English breakdown of WS → routeAgentRequest → DO → transcriber → broadcast
- Accessible even when feature flags are off (graceful opt-in pattern working correctly)
- Video page explicitly signals "No Cloudflare Realtime SDK required" — forward-looking comment that aligns with the project's `no-sdk-companion-scaffold.md` rule

**Suggestion**: every reference scaffold page in the starter should follow this template. Consider adding a similar "How this works" card to Skills, Connectors, Extract, and the Components/Style Guide pages so forks learn how each pattern is wired.

---

### [HIGH] C-ORG-TERMINOLOGY-COLLISION — "Organization" tab name collides with planned better-auth Organization plugin (#18)

**Location**: `/dashboard/settings?tab=organization`

**Observed**: The Settings page has an "Organization" tab. Clicking it reveals **Business Information** (name, website, email, phone, address) — the existing single-row business-settings module (`server/modules/organization/routes.ts` per CLAUDE.md).

**Why it's a problem**: GitHub issue #18 plans to add the **better-auth Organization plugin** — multi-tenant teams/workspaces with members, roles, and invitations. If that ships with its default naming, the starter will have TWO distinct concepts both called "Organization" in the UI:

1. **Business Information** (Settings → Organization) — your business identity: name, logo, address (single-row, per-account)
2. **Teams/Workspaces** (issue #18) — groups of users with shared resources, roles, invitations (multi-row, collaborative)

Users, forks, and future Claude Code sessions will conflate these. The confusion compounds once multi-tenancy ships.

**Fix — needs a call BEFORE #18 lands**:
| Option | Pros | Cons |
|---|---|---|
| **A.** Rename existing tab/module to "Business Info" or "Company Profile" | Frees "Organization" for better-auth — no collision | Breaking change: route (`/organization`), DB table name, API paths |
| **B.** Rename the better-auth concept to "Teams" or "Workspaces" throughout the UI | No breaking change to existing module | Divergence from better-auth docs/naming |
| **C.** Consolidate — business info becomes per-org data (every team has its own business identity) | Cleanest model for B2B apps | More invasive refactor |

**Recommendation**: Option B (call it "Teams" in the UI; keep better-auth's `organization` plugin under the hood) — minimal breaking change, sidesteps the collision entirely, matches what most SaaS products (Notion, Linear, Vercel, etc.) call it anyway.

**Add to the issue #18 scope comment** so the naming decision is locked before any plugin code lands.

---

### [MEDIUM] M-SESSION-IP-UNKNOWN — Current session shows "Unknown IP" while older sessions captured IP correctly

**Location**: `/dashboard/settings?tab=sessions`

**Observed**: Active Sessions list shows:
- Current session (Chrome 146 / macOS): **"Unknown IP"** — Active now
- Chrome 147 / macOS: `2001:8003:c406:7100:...` (IPv6 captured) — Last active 3 days ago
- Chrome 146 / macOS: **"Unknown IP"** — Last active 6 days ago

The old IPv6-captured session proves the capture logic exists. Recent sessions show "Unknown IP" — suggests a regression in how the IP is read from the request, or an environmental difference (e.g. `cf-connecting-ip` header missing in some path).

**Fix**: Inspect the session-create code path in `src/server/modules/auth/` — for Cloudflare Workers, the IP should come from `cf-connecting-ip` header. Likely this is empty when the session is refreshed via better-auth's auto-renewal vs. a fresh sign-in.

---

### [WORKS-WELL] AI settings tab is an excellent transparency pattern

**Location**: `/dashboard/settings?tab=ai`

Chat preferences panel exposes:
- Preferred name, response style, tone, confirmation-mode toggle, "About you" free-text

Crucial detail: **the description explicitly says "These settings are appended to the system prompt on every chat request. Leave any field blank to use the default behaviour."**

Most AI chat products hide this — the user just "trusts" that their preferences affect the model. Exposing it as a developer-tooling starter is the right choice. Keep.

**Suggestion**: consider showing the ACTUAL rendered system-prompt suffix in a collapsed "What gets sent" disclosure below the form. That'd be peak transparency and a great debugging aid for devs forking this.

---

### [CRITICAL] C-CONNECTOR-ACCIDENTAL-DISCONNECT — Clicking a Connected Google Workspace card appears to have disconnected it

**Location**: `/dashboard/connectors`

**Observed**:
- **Initial view** (19:22 UTC): Google Workspace card showed:
  - Green **"Connected"** pill
  - Subtitle: **"Connected as anthro@jezweb.net"**
  - 5 scope chips (Read Gmail, Read Drive, Send Gmail, Read + create calendar events, Create Drive files)
  - Trash icon (delete)
- **I clicked the card** (intending to open the per-tool-policies detail sheet per CLAUDE.md)
- **A browser-chrome-level modal fired** (not a webpage dialog — Jez dismissed it manually)
- **After dismissal, the card shows**:
  - No status pill
  - Description paragraph ("Give the AI direct access to Gmail, Drive, and Calendar...")
  - Same 5 scope chips as static metadata
  - **"Connect" button** (instead of the previous trash icon)
- **API confirms disconnect**: `GET /api/mcp-connections` now returns only `google-drive` (pending). The Google Workspace connection row is gone from D1.

**Why it's critical**:
- A single click on a Connected card without an explicit confirmation caused the connection to be deleted
- The browser modal Jez dismissed was likely a consequence (OAuth revoke, Keychain access, or similar), not a warning the user understood as "are you sure you want to disconnect"
- Even if the modal WAS a disconnect confirmation, it was OS-level not app-level — users expect destructive actions to be confirmed in the app's own UI first

**Unknown — needs investigation**:
- Exact trigger: was it the click handler, or the modal's dismissal cascade?
- Was the connection actually revoked on Google's side, or just deleted from our D1?
- What was the browser modal? (Popup permission? Keychain? Third-party cookie prompt?)

**Fix direction**:
- Clicking a Connected card should open a read-only detail sheet, NEVER disconnect
- Disconnect should be: trash icon → in-app confirm dialog ("Disconnect from anthro@jezweb.net? You'll need to re-authorize to restore access.") → explicit confirm → server call
- Never rely on OS-level prompts as disconnect UX

**Also**: this is a recurring shape — the `oauth-popup-blocked-in-dialogs.md` rule already documents that OAuth inside React dialogs has popup-blocker issues on Chrome. Browser-chrome-level modals during connector interactions suggest the flow is bumping into Chrome's safety layers.

---

### [RETRACTED] ~~H-GWS-CARD-STATE-BROKEN~~ — not actually a bug

**On further investigation this is correct behaviour, not a bug.** The Google Workspace card reads `/api/google-workspace/status` which correctly reflects the presence of a row in the `googleWorkspaceTokens` table. That table is deliberately separate from the better-auth `account` table so users can link the Workspace connector to a DIFFERENT Google account than the one they signed in with (e.g. sign in with jeremy@jezweb.net, connect Workspace as anthro@jezweb.net).

After the accidental disconnect in the audit, the `googleWorkspaceTokens` row was correctly deleted. The card's "Connect" state is accurate — there's no Workspace-scoped token. The better-auth `account` row still has `gmail/drive/calendar/...` scopes from sign-in, but that's a separate concern from Workspace-level tool access.

**Original finding kept below for context, but scored as non-issue.**

---

### ~~[ORIGINAL] H-GWS-CARD-STATE-BROKEN~~ — Google Workspace card shows "Connect" despite the user's account already having all scopes

**Location**: `/dashboard/connectors` — Google Workspace card (post-disconnect state)

**Observed**: After the accidental disconnect above, the card now shows the "Connect" button. But `/api/auth/list-accounts` confirms the user's better-auth Google account has all 10 scopes (gmail.readonly, gmail.send, gmail.modify, drive, calendar, contacts, contacts.other.readonly, userinfo.email, userinfo.profile, openid). **Calendar tool calls still work** (proven earlier in this audit — `calendar_list_events` returned "no events today").

So the card is showing "Connect" even though the underlying Google OAuth scopes are present and working.

**Root cause**: The card state is derived from `GET /api/mcp-connections` returning a row with `connectorId === 'google-workspace'`. The card's description says "native Google OAuth — no MCP server needed", which suggests the card SHOULD derive state from the better-auth account record instead. But the implementation is inconsistent — the card title and description say "native OAuth", while the state check queries MCP connections.

Attempted status endpoints — all 404:
- `/api/mcp-connections/google-workspace/status`
- `/api/mcp-connections/status`
- `/api/connectors/google-workspace`
- `/api/gws/status`

**Why it matters**:
- User sees "Connect" even though they're fully connected — confusing
- Clicking Connect would trigger a fresh OAuth flow unnecessarily
- Inconsistent model: for an OAuth-scopes-are-state connector, the card should reflect scope grants from `better_auth.account`, not presence of an MCP-connections row

**Fix direction**:
- Add a dedicated status check for native-OAuth connectors: "does the current user have a Google account linked with these scopes?"
- If yes → Connected state derived from `account.scopes`
- Independent from MCP-connections table (which should only apply to generic MCP server connectors)

---

### [OBSERVATION] Connectors page — layout and OAuth resume pattern

**Location**: `/dashboard/connectors`

**What I saw** (before Chrome MCP dropped):
- Title + subtitle explain MCP clearly ("Paste any MCP server URL — public, community-hosted, or your own Cloudflare Worker. OAuth and bearer tokens both work; tokens are encrypted at rest.")
- Two buttons: **Examples** + **Add connector**
- **Google Workspace** card — Connected status, scope chips (Read Gmail, Read Drive, Send Gmail, Read + create calendar events, Create Drive files), trash icon
- **Google Drive** card — **Pending** status + **Configure** button (the OAuth-resume path per `oauth-popup-blocked-in-dialogs.md` rule)

Couldn't finish testing:
- Clicking into the Workspace connection to see the per-tool policies sheet (always/ask/never three-state picker)
- Clicking "Configure" on the pending Google Drive to verify the OAuth-resume endpoint works (issues `POST /api/mcp-connections/:id/authorize` and redirects via `window.location.href`)

**Deferred to a follow-up audit.** These paths were identified in the prior audit's Phase 5 remediation and the pattern is documented — the remaining verification is "is it actually wired on this deploy?"

**Recommendation**: Next audit should explicitly re-test the OAuth-resume path with a fresh pending connection to confirm the 2026-04-22 fix is still in place.

---

### [MEDIUM] M-CHAT-EMAIL-CHIP-CLIPPED — User email chip is clipped by chat header at widths ≤ 768px

**Location**: `/dashboard/chat` (empty state) — at both 768px (tablet) and 375px (mobile)

**Observed**: The greeting section has a `jeremy@jezweb.net` chip displayed above the "Good evening, Jeremy" heading. At 1440px it sits cleanly below the header with padding. At 768px and 375px it's **clipped — the top half of the chip is hidden behind the chat-panel header bar** ("AI Chat" header row).

This is a z-index + overflow interaction bug — the chip is likely positioned with insufficient top-padding, so once the chat panel's header grows sticky (or the greeting section moves up in the flow), the chip underflows the header.

**Why it matters**: The user's account email is the most identity-critical element on the page for a first-time mobile user ("am I signed in as the right account?"). Clipped → unreadable → identity anxiety.

**Fix**: add vertical padding / `margin-top` to the greeting container, OR raise the chip's z-index and add a background colour so it stacks above the header bar. Prefer the former — structural fix rather than z-index wrestling.

---

### [WORKS-WELL] Activity + Skills pages are solid

**Activity** (`/dashboard/activity`):
- 3 stat cards (Total / Today / This Week)
- Filtered audit log with clear entity + action chips
- Relative timestamps ("41 minutes ago")
- My session actions appeared correctly in the log

**Skills** (`/dashboard/skills`):
- Grid of skill cards with slash-command names in monospace (`/code-review`)
- Per-skill Enabled toggle + preview eye icon
- 3 install paths clearly surfaced: Sync bundled / Install from GitHub / Add skill
- "bundled" vs (future) "custom" badge distinguishes sources

**Nice UX pattern from Skills**: the header tagline links to `agentskills.io` spec AND tells users how to invoke (`/skill-name` in chat). Teaches the pattern in one line. Copy this tone for other reference pages.

---

## Priority recommendations (ranked)

### Ship now

1. **C-CONNECTOR-ACCIDENTAL-DISCONNECT** — A click on a Connected connector card caused a working Google Workspace connection to be deleted (via a browser-chrome-level modal the user dismissed). Clicking a Connected card should NEVER disconnect — detail sheet only; disconnect must require an in-app confirm dialog with the account name. This is the highest-severity finding in the audit.
2. **H-GWS-CARD-STATE-BROKEN** — Google Workspace "native OAuth" card derives Connected state from the MCP connections table, but should derive it from `better_auth.account.scopes`. UI shows "Connect" despite the user's account having all required scopes. Needs a dedicated status check that reads from `account`.
3. **C-CHAT-VOICE** — Rewrite chat mic to use streaming STT via the voice DO (agreed in this session). ~45-60 min. The single biggest user-facing UX improvement available.
4. **C-ORG-TERMINOLOGY-COLLISION** — Before issue #18 (better-auth Organization plugin) lands: decide naming. Recommend using "Teams" in UI, keep `organization` under the hood. Add to issue #18 scope comment. Lock now to avoid mid-implementation refactor.
5. **A-THEME-LABEL** — Theme toggle aria-label should describe action based on current state, not always say "Switch to dark mode". ~5 min fix.
6. **M-TOOL-NAMES** — Audit all tools for missing `render.displayName` + `render.icon`. Tools without metadata render as `Fs List` (raw snake_case) with a generic wrench icon. ~30 min to sweep all modules.

### Next sprint

7. **M-CHAT-EMAIL-CHIP-CLIPPED** — User email chip at top of chat empty state is clipped by the chat-panel header at widths ≤ 768px. Identity-critical element hidden on mobile.
8. **M-REASON-LABELS** — Consolidate the two "Thought for a few seconds" disclosures into a single timeline, OR label them distinctly. Confusing as-is.
9. **M-SESSION-IP-UNKNOWN** — Regression in session IP capture. Investigate whether `cf-connecting-ip` is being read on session refresh.
10. **L-RAG-INDEX-STATUS** — Add a "Not indexed" pill state for files with `indexStatus: null` so the UI doesn't go silent on pre-Phase-4 files.

### Polish / when-time-permits

11. **L-TOOL-ARGS** — Show tool call input args in expanded view + surface `translatedFrom` for natural-query translations.
12. **L-RAG-RAW-KEYS** — `fs_list` should return friendly names, not raw R2 keys.
13. **L-UPLOAD-DIALOG** — Add folder selector, supported formats list, and "indexing takes a moment" hint to the upload dialog.
14. **L-KBD-HINT** — Surface Cmd+K palette as a visible pill in the header.
15. **L-SIDEBAR-CASE** — Centralise sidebar-toggle aria-label copy.
16. **L-CLONE-CTA** — Landing page "Ready to build?" section should include the `git clone` command and a link to FORKING.md.
17. **L-HOME-REPEATED-CTA** — Three "Open Dashboard" buttons on landing — consider varying one of them.

---

## Coverage + gaps

### Covered

- `/` marketing landing (full scroll)
- `/dashboard` home
- `/dashboard/chat` empty state + one tool-calling turn (Calendar — List)
- `/dashboard/chat/<id>` persisted conversation
- `/dashboard/files` list + upload dialog + API shape inspection
- `/dashboard/voice-example` full page including explainer
- `/dashboard/video-example` main viewport
- `/dashboard/settings` all 7 tabs spot-checked (Profile, Organization, Security, Sessions, API Tokens, AI, Preferences)
- `/dashboard/connectors` overview only

### Not covered (Chrome MCP disconnected mid-audit)

- Per-connector detail sheet (per-tool policies three-state picker)
- OAuth-resume Configure button end-to-end
- `/dashboard/extract` page
- `/dashboard/activity` audit log viewer
- `/dashboard/skills` skills catalogue
- `/dashboard/notifications` full page
- `/dashboard/admin/*` (admin panel tabs)
- `/dashboard/components` + `/dashboard/style-guide` dev pages
- Responsive sweep (1280 / 1024 / 768 / 375)
- Scenario battery (8 scenarios)
- Heavy-data tests (500+ conversations, long transcripts)

Recommend scheduling a follow-up audit session focused on these after the session-continuity issue is addressed.

### Scenarios skipped

- **Keyboard only** — would need the Chrome MCP still connected; can re-run
- **Destructive confidence** — intentionally not tested (didn't want to actually delete anything in Jez's account)
- **Second user** — skipped (no separate test account)

---

## What works well (keep doing)

- Landing page copy is tight and developer-focused — "60+ agent tools" is a strong specific claim
- Dashboard welcome greeting + 3-card primary CTA structure is very familiar (claude.ai-like) and friction-free
- Chat empty state with 5 preset chips + 4 example prompts — mirrors modern AI chat conventions
- Conversation persistence via URL UUID works instantly on first message; title auto-generation is a nice touch
- Tool call rendering as collapsible cards with status pills is genuinely good
- Reasoning disclosures (even if labelled identically) give good transparency
- Security tab's OAuth-aware password handling — "you signed in with Google, manage it there" — is kinder than showing a broken form
- AI settings tab explicit "appended to system prompt" transparency is industry-leading
- Voice + Video example pages with "How this works" explainers are the gold standard for reference scaffolds
- Graceful feature-flag degradation — feature-flagged pages (voice/video) remain accessible by URL while hidden from nav
- Connector UI surfaces scopes as chips (not buried in settings) — good trust signal
- Console is essentially silent across pages — only a benign Sentry "DSN not configured" info log

## Session metadata

- **Started**: 2026-04-23 ~19:10 UTC
- **Audit engine**: Chrome MCP + JS introspection
- **Chrome MCP disconnects**: 2 (once pre-audit while waiting for Connect; once during Connectors drill-down)
- **Screenshots captured**: ~15 (retained by Chrome MCP, not copied into this repo)
- **Tasks**: 5 of 10 complete; remaining 5 (Element sweep, Responsive, Scenario battery, Report writing [this doc], Thread-5 drill-down) partially or fully deferred
- **Findings**: 14 logged (4 High/Critical, 3 Medium, 7 Low/Polish) + 3 "works well" callouts

---

## Next steps the user might want

1. **Pick up the voice dictation wiring** (C-CHAT-VOICE) — agreed during this session, ~45-60 min
2. **Add the scope-refinement comment to issue #18** capturing the terminology-collision decision before any implementation starts
3. **Schedule a follow-up UX audit pass** on the 11 routes not covered, ideally with a stable Chrome MCP session
4. **Fix the low-hanging polish items 3, 4, 5** in a single "polish" commit — all are &lt;30min changes

