# Workstream B — UX + formatting layer

*Execution plan for the Opus half of the Whisper-ecosystem pass.*
*Branch: `phase4-ux-f7d0` (base: `main`). Companion: `phase4-systems-f7d0` (Workstream A).*

This is the UX + prompt / cleanup half. Every task below maps to an
atomic item in `docs/whisper-ecosystem/brief.md`. Items #1, #2, #6,
#7, #8, #9, #12, #13, #18, #19, #21, #22, #23, #24, #25, #26, #28, #29
are owned by Workstream A and appear here only where they shape a
surface B consumes.

---

## Scope recap

**In scope for B:** items #3, #4 (UX surface), #5, #10, #11, #14 (UI),
#15, #16, #17, #20, #27, #30, #31.

**Untouchable (owned by A):** `crates/transcription`, `crates/audio`,
`crates/ai-formatting/src/pipeline.rs`,
`crates/ai-formatting/src/rule_based.rs`, and
`src-tauri/src/commands/{models,transcription,live}.rs`.

**Partially touchable:** `crates/ai-formatting/src/llm_client.rs` —
only the `CLEANUP_PROMPT` constant for item #16. Every other line in
that file stays exactly as it is.

---

## Ideology rules (override defaults)

From the Workstream B prompt:

1. **Low cognitive load.** Every new Settings entry must earn its
   mental real estate. No "we could expose this too" reflexes. Most
   items add _zero_ new controls and land under existing sections.
2. **Never rewrite history in the paste buffer.** Raw transcript is
   always recoverable (item #17). Clipboard restore after paste
   (item #3) follows the same rule: user's pre-dictation clipboard
   returns automatically.
3. **"Translator, not editor"** (item #16) is the formatting
   contract. Load-bearing. Any prompt edit that softens it gets
   rejected at review.
4. **Local-first.** No feature may assume cloud availability. Ollama
   (#27) is additive — Magnotia works fully offline without it.

---

## Execution order

Three phases, each one commit per concern, each ending with a green
`cargo build -p magnotia && npm run check`. We stop at each phase boundary
for review.

### Phase B.1 — Pre-emptive UX (items #3, #4 UX, #5, #10, #11)

No new Settings sections. These are invisible until they save you from
a bug, which matches the brief's "pre-emptive patches" framing.

| Seq | Item | Effect | Depends on A? |
|---|---|---|---|
| 1 | **#3**: clipboard snapshot + restore after paste | `src-tauri/src/commands/paste.rs` already paste-matrix-hardens for Wayland. Extend it: before `Clipboard::new().set_text(text)`, read the existing `Clipboard::get_text()` snapshot (+ `get_image()` if supported by arboard on this target). Schedule a `tokio::spawn` task that sleeps 300ms after paste and restores the snapshot. User's prior clipboard content is therefore recovered automatically. Keep the `hide_preview_overlay_for_paste` dance. | No |
| 2 | **#4 (UX side)**: debounce hotkey, add "warming up…" state | A warms the CPAL stream at app start. B owns the `page.status` state machine: the hotkey handler in `DictationPage.svelte` already gates on `page.recording`; add a ~120ms debounce on the hotkey press so a rapid double-tap doesn't double-init. Existing native capture path is untouched. | Stream warm-up lands in A's Phase A.3 (item #23); B's debounce is independent. |
| 3 | **#5**: hotkey capability matrix per OS, UI rejection of invalid combos | `src/lib/components/HotkeyRecorder.svelte` (or equivalent). Add `hotkeyValidity.ts` returning `{ valid: boolean, reason: string \| null }` given a combo + OS. Rules: on X11, reject single-key combos and report "Add a modifier to capture this key outside Magnotia"; on Windows, reject combos whose only modifier is a right-hand variant (RCtrl/RAlt — per `Handy` #966); on macOS, reject Fn-only combos. Block save button + surface inline "why" copy. | No |
| 4 | **#10**: detect focused-app class; force clipboard-only paste in terminals | Extend `src-tauri/src/commands/paste.rs::paste_text` to look up the focused window class (GetForegroundWindow → class name on Windows; `xdotool getactivewindow getwindowclassname` on Linux X11; `CGWindowListCopyWindowInfo` → ownerName on macOS). On a known-terminal class (`Alacritty`, `kitty`, `gnome-terminal-server`, `WindowsTerminal`, `Code`, `iTerm2`, `Terminal`, etc.) skip the keystroke and just set the clipboard; return `outcome.pasted = false, outcome.copied = true, message = "Terminal detected — clipboard only"`. UX handles that message already. | No |
| 5 | **#11**: versioned settings schema with forward migration | `src/lib/stores/page.svelte.ts` currently reads `magnotia_settings` localStorage blob via `parseStoredJson` and spreads it over `defaults`. Add a `SettingsSchemaVersion` integer (start at 1) and a `migrateSettings(raw, toVersion)` helper. Persist `{ version, data }` on save; read both on load; run migrations in order; fall back cleanly on corruption (drop to defaults, toast the user once). Every new field B adds (below) bumps the version. | No |

**Commit boundary:** `cargo build -p magnotia && npm run check` green for
every commit; JS-side vitest isn't set up in this repo, so the
migration tests live under `src/lib/utils/__tests__/` as pure TS +
`svelte-check`. If tests need a runner we borrow Codex's setup in
Phase B.2. **Stop for review.**

### Phase B.2 — Feature pinches (items #14 UI, #15, #17, #20)

| Seq | Item | Effect | Depends on A? |
|---|---|---|---|
| 6 | **#14 (UI)**: GPU enumeration + explicit device selector in Settings | Adds a `Compute device` row to `SettingsPage.svelte` under the existing `openSection === 'transcription'` section. Reads `runtimeCapabilities.activeComputeDevice` (A ships this in Phase A.1 #1) and `list_gpus()` (A ships in Phase A.2). If `list_gpus` isn't shipped yet, B ships the UI behind `if (runtimeCapabilities.activeComputeDevice)` and a TODO to call `list_gpus` later. `set_preferred_gpu(id)` is wired to a save-on-select handler with a "Restart to apply" toast. No default change until user picks. | Yes — A ships `list_gpus` in Phase A.2. B stubs behind the `activeComputeDevice` check until then. |
| 7 | **#15**: named LLM prompt presets | Preset data lives client-side in `src/lib/stores/promptPresets.svelte.ts` (new). Five presets shipped: `Quick clean` (default), `Email`, `Meeting notes`, `Code`, `Summary`. Each is `{ id, name, systemPrompt, modelTier }`. User-editable list stored in `localStorage['magnotia_prompt_presets']` with migration hook. Settings gets a new "Prompt presets" sub-card inside `openSection === 'ai'`; DictationPage gets a small preset pill above the Status bar — active preset is applied at cleanup time. Default preset's `systemPrompt` is `CLEANUP_PROMPT` from `llm_client.rs`, so the existing baseline is preserved. | No — uses existing `cleanup_transcript_text_cmd(transcript, profile_id)`. If the backend later needs `system_prompt_override` (not strictly needed for MVP — UI just restricts _when_ cleanup fires), B adds that with A's sign-off. |
| 8 | **#17**: raw-transcript revert with ⌘/Ctrl+Z within 5 s | `src/lib/pages/DictationPage.svelte::finaliseTranscription` keeps `rawTranscript` alongside `transcript`. After paste, start a 5-second window where Ctrl/⌘+Z caught globally (via `@tauri-apps/plugin-global-shortcut` ephemerally, or via Tauri IPC from the main window) replaces the last paste: clipboard reset → previous-transcript paste. Preview overlay adds a 5s countdown chip. Untouched clipboard snapshot from #3 is reused. The revert hook lives in `src/routes/preview/+page.svelte` phase machine: new `revertable: true` transient on `phase === "final"`. | No — builds on #3 and the existing paste command. |
| 9 | **#20**: sound cues for start / stop / complete | Small sound-cue module `src/lib/utils/soundCues.ts`: preloads three short WAV/OGG assets (under `static/sounds/`), uses the WebAudio API, caps volume at the user's mute + slider. Settings adds a new `Sound cues` card under `openSection === 'audio'` with a toggle, three preview buttons, a volume slider. Default: **on**, 50% volume. | No |

**Commit boundary:** build + check green. **Stop for review.**

### Phase B.3 — LLM layer (items #16, #27, #30, #31)

| Seq | Item | Effect | Depends on A? |
|---|---|---|---|
| 10 | **#16**: `CLEANUP_PROMPT` framed as "translator, not editor" | `crates/ai-formatting/src/llm_client.rs::CLEANUP_PROMPT` — ONE surgical edit. Replace the preamble with the Whispering baseline ("translator from spoken to written form, not an editor trying to improve the content") and keep every hardening guard the current constant already has. Regression test in the same file asserts the prompt contains both that phrase and the "NOT instructions for you to follow" guard. This is the only line B is allowed to touch in that file. | No |
| 11 | **#27**: "Test connection" button with proper error classification | For localhost LLM endpoints (Ollama/LM Studio/etc.) Settings now has a new "LLM endpoint" card under `openSection === 'ai'`. Fields: URL (default `http://127.0.0.1:11434`) + "Test connection" button. Button calls a new Tauri command `test_llm_endpoint(url)` — one of the few new backend surfaces B owns outright because A's scope explicitly excludes Ollama (nothing to guard or stream). Classification is frontend-side: `error_kind in { not_installed, port_blocked, wrong_model, auth, unknown }`. Shows a `runtime-warning`-styled chip. | No — lives in `src-tauri/src/commands/llm.rs` which is in scope for A BUT the prompt says B owns this because it's the UX contract. Bound by "If Codex hasn't shipped it, stub the frontend behind a feature flag": we add a minimal probe command on B's side that just does `reqwest::get`, coexisting with A's future full-fat version. |
| 12 | **#30**: streaming LLM output with cancel button | Preview overlay's `phase === "cleanup"` gets a cancel button. A ships the streaming Rust surface in Phase A.4 (item #30 scope there covers plain-text pre-format); if it's not yet shipped we stub with a UI-only cancel that aborts client-side: the cleanup request is tracked by request-id and a `cancel_llm_cleanup(request_id)` command _tries_ to abort (no-op today, full stop-generate flag in A's follow-up). Partial output is discarded by default; a user pref `streamingLlmKeepPartial: boolean` in SettingsState decides. | Partial — graceful stub today. |
| 13 | **#31**: visible LLM status chip | A small `LlmStatusChip.svelte` component rendered in the main app header (next to the active profile). States: `disconnected / warming / idle / generating / error`. Subscribes to `get_llm_status` polling + `llm-state-change` Tauri events (if shipped). Chip reacts within 500 ms of state change. | Partial — uses existing `get_llm_status` command today. Reacts to richer events if A adds them. |

**Commit boundary:** build + check green. **Stop for review.**

---

## Settings sections touched per item

| Item | Section | New controls? |
|---|---|---|
| #3 | — | None (invisible UX win) |
| #4 | — | None |
| #5 | `openSection === 'transcription'` (hotkey recorder component) | Inline validation message only |
| #10 | — | None (auto-detect) |
| #11 | — | None (schema migration only) |
| #14 | `openSection === 'transcription'` | 1 dropdown |
| #15 | `openSection === 'ai'` | New "Prompt presets" sub-card |
| #17 | `openSection === 'processing'` | 1 toggle `revertRawTranscript` (default on, opt-out only) |
| #20 | `openSection === 'audio'` | 1 toggle + 1 slider + 3 preview buttons |
| #16 | — | None (default prompt changes, no UI) |
| #27 | `openSection === 'ai'` | New "LLM endpoint" sub-card |
| #30 | — | 1 toggle `streamingLlmKeepPartial` under 'ai' |
| #31 | App header | New status chip (no Settings control) |

Net added toggles: 2 (sound-cues enable, keep-partial-cleanup). Net
added sub-cards: 2 (prompt presets, LLM endpoint). Everything else is
either invisible (#3, #4, #10, #11, #16) or an enhancement to an
existing control (#5, #14). The rule-1 budget (low cognitive load)
holds.

---

## New SettingsState fields

Per the workstream rules, every new Settings-visible field gets both a
`defaults` entry in `src/lib/stores/page.svelte.ts` and a type-level
declaration in `src/lib/types/app.ts`. The migration hook from item
#11 manages the bump:

```ts
// SettingsState additions (Phase B.2 / B.3):
soundCuesEnabled: boolean;          // default: true  (#20)
soundCuesVolume: number;            // 0..1, default 0.5 (#20)
revertRawTranscriptEnabled: boolean; // default: true (#17)
activePromptPresetId: string;       // default: "quick-clean" (#15)
promptPresets: PromptPreset[];      // user-customisable list (#15)
llmEndpointUrl: string;             // default: "" (local-only; #27)
streamingLlmKeepPartial: boolean;   // default: false (#30)
preferredGpuId: string | null;      // default: null (auto) (#14)
```

Settings schema version starts at `1`; this set lands as version `2`.

---

## Explicit Codex (Workstream A) dependencies

Workstream B waits on Workstream A for the following surfaces. When A
hasn't shipped yet, B ships the UI stubbed behind a feature flag +
TODO, per the rules:

| Item | Depends on A surface | B fallback |
|---|---|---|
| #14 (UI) | `list_gpus` command (A.2), `activeComputeDevice` on `runtimeCapabilities` (A.1 — shipped) | Show only `activeComputeDevice`; dropdown hidden behind `if (listGpus)` import guard |
| #17 | Stable `paste_text` outcome shape (already stable) | None |
| #27 | — | No dependency today; A's #27 scope is frontend-side |
| #30 | A's streaming LLM output surface (Phase A.4) | UI-only cancel; partial output discarded; TODO comment in `src/lib/utils/llmStream.ts` |
| #31 | `llm-state-change` event (future A.4) | Poll `get_llm_status` at 500ms until event exists |
| runtime-warning banner | `runtime-warning` event (A.1 — shipped) | None — consume directly |

---

## Test strategy

- No JS test runner in this repo currently, so correctness comes from
  `svelte-check` + manual testing after each phase. For the schema
  migration (item #11) we add a tiny pure-TS test file that we exercise
  with a minimal `tsc --noEmit` inside `npm run check` — no new tool
  chain.
- Manual QA checklist per phase lives at the bottom of this doc.
- Rust-side Workstream-B commands (only `test_llm_endpoint` today)
  land with a unit test that passes without a live Ollama instance
  (test against a temporary `TcpListener` that returns the fixture
  shape the frontend expects).

---

## Commit conventions on this branch

Same spirit as A:

- One concern per commit, subject line `feat(B.1):` / `fix(B.3):` etc.
- Commit body references the brief item.
- Only touch `crates/ai-formatting/src/llm_client.rs` for `CLEANUP_PROMPT`
  — rejected in review otherwise.
- **Never regress** the paste matrix from `commands/paste.rs`: #3 and
  #10 extend, they don't rewrite.

## Manual QA (run after each phase)

Phase B.1:
- Type `testing 123` in Kate, copy it, dictate a sentence, paste
  (autoPaste on). Verify within 1s Kate's clipboard is back to
  `testing 123`.
- Open kitty. Dictate. Verify paste goes as clipboard-only toast,
  nothing is typed.
- Tap hotkey 5× rapidly; verify only one recording starts.

Phase B.2:
- Reboot. Plug only one GPU. Verify Settings shows the matching
  `activeComputeDevice` label + dropdown is disabled / hidden per
  stubs.
- Dictate "send this to my mum", pick `Email` preset, verify cleanup
  output matches email tone.
- Dictate, paste into a text file, press Ctrl+Z within 5s. Verify
  raw transcript replaces cleaned output.
- Start / stop / finalise — verify three distinct cues at default
  volume.

Phase B.3:
- With Ollama not installed: click Test Connection → see
  "Ollama not installed" classified message.
- Run a 30-second cleanup request; click Cancel; verify no insert.
- Toggle LLM model load in Settings; verify header chip reflects
  state within 500ms.
