---
phase: 9
slug: training-migration
status: draft
shadcn_initialized: false
preset: none
created: 2026-05-08
revised: 2026-05-08
---

# Phase 9 — UI Design Contract

> Visual and interaction contract for the Phase 9 client UI surface (the Training pane: Train / Cancel / Recompute / Confirm flow that replaces the deleted v1.5 client-side training body at `apps/micmap/main.cpp:962-1027`). Generated by gsd-ui-researcher, verified by gsd-ui-checker.
>
> **Important:** This is the same Dear ImGui (immediate-mode) Win32 + D3D11 desktop client carried forward from v1.5/P8. shadcn does not apply. Tokens are expressed as ImGui constants and ImVec4 RGBA values used at draw time.
>
> **Inheritance:** P9 inherits the entire P8 design contract verbatim — design system, spacing scale, typography, color palette, copywriting style, poll cadences, driver-loaded gate. P9 only adds: (a) one new section ("Training" — fully rewired against `/training/*`), (b) one new client behavior (5–10 Hz `GET /training/progress` poll while in Training mode), (c) two new copywriting clusters (training progress + recompute preview), (d) two new copy strings on the existing `last_error` infrastructure (`training_timed_out_no_samples`, `audio_disabled`).

---

## Design System

| Property | Value |
|----------|-------|
| Tool | none (Dear ImGui immediate mode) |
| Preset | not applicable |
| Component library | Dear ImGui — existing widgets only (`Text`, `TextColored`, `TextDisabled`, `Button`, `SliderFloat`, `ProgressBar`, `Separator`, `Spacing`, `SameLine`, `BeginDisabled`/`EndDisabled`) |
| Icon library | none — text glyphs only (no font atlas additions in P9) |
| Font | Default ImGui font (ProggyClean baked atlas) — unchanged from v1.5/P8 |
| Theme | `ImGui::StyleColorsDark()` baseline — unchanged from v1.5/P8 (`apps/micmap/main.cpp:1285`) |

**Layout shell (unchanged from P8):** Single full-window panel `ImGui::Begin("MicMap", nullptr, NoTitleBar | NoResize | NoMove | NoCollapse)`. Sections separated by `ImGui::Spacing()` + `ImGui::Separator()`.

**Section order (post-P9 — Training section is rewired in-place at its existing position):**
1. Status (existing, P8-extended)
2. Driver Health (P8 — HEALTH-01..07)
3. Audio Device (existing, P8-rewired)
4. Settings (existing, P8-rewired)
5. **Training (REWIRED in P9 — same slot as v1.5; old body deleted; new endpoint-driven body inserted)**
6. Audio Levels (P8 — `/telemetry/level` source)
7. Detection State Box (existing — content from `/state.detection_state`)

**Anti-feature TRAIN-AF-01 (visual contract):** The Training section MUST contain **zero** calls to `detector->startTraining()`, `detector->addTrainingSample()`, `detector->finishTraining()`, or `detector->saveTrainingData()`. Every button click in the Training pane MUST issue exactly one HTTP request to the driver (`POST /training/start | cancel | finalize | recompute` or `GET /training/progress`). `cmake/AssertNoClientTraining.cmake` enforces structurally; the executor cannot accidentally regress the contract.

---

## Spacing Scale

P9 inherits P8's ImGui spacing tokens verbatim. No new tokens introduced.

| Token | Value | Usage in P9 Training pane |
|-------|-------|---------------------------|
| xs | 4px | Inline gap between progress-bar and "Cancel Training" button (`SameLine` default spacing) |
| sm | 8px | `ImGui::Spacing()` between progress bar and preview block, between preview block and confirm row |
| md | 16px | Default control row gap; padding inside button hit boxes |
| lg | 24px | Vertical break before "Training" section heading (after Settings `Separator()`) |
| xl | 32px | Reserved — not used in P9 |

**P9 control sizing:**

| Element | Size (`ImVec2`) | Notes |
|---------|------|-------|
| `Train Pattern` button | `ImVec2(120, 30)` | Reuses v1.5/P8 standard button size — visual continuity with the deleted button |
| `Cancel Training` button (during training) | `ImVec2(120, 30)` | Replaces the v1.5 "Stop Training" slot — same size, same position |
| `Recompute Thresholds` button (in `ready` state) | `ImVec2(140, 30)` | Standard label width — accommodates verb+noun copy |
| `Confirm & Save` button (in `ready` state) | `ImVec2(140, 30)` | Same width as Recompute — matches the longer label and signals primary CTA without color (per dark-theme accent reservation rule) |
| `Discard Preview` button (in `ready` state) | `ImVec2(120, 24)` | P8 compact-button height with widened label slot — secondary action |
| Sensitivity slider (recompute control) | full-width via `SetNextItemWidth(-1)` | Matches existing P8 settings sliders |
| Training progress bar | `ImVec2(-1, 18)` | Same height as P8 level meter — visual consistency for "live data" widgets |

**Spacing exceptions (inherited from v1.5/P8 visual baseline):**

The P9 control sizing table contains two height values that are **not** multiples of 4. These are deliberate, inherited from the v1.5/P8 visual baseline, and the executor MUST NOT change them:

| Value | Where | Justification |
|-------|-------|---------------|
| `30px` button height | Train Pattern, Cancel Training, Recompute Thresholds, Confirm & Save | Inherited from v1.5/P8 `ImVec2(120, 30)` button standard. Retained for visual continuity across the rewire — the deleted v1.5 training section used the same height, and surrounding P8 sections (Audio Device, Settings) continue to use `30` for their action buttons. Changing to `32` would make P9 buttons visibly taller than every other P8 button in the same window. |
| `24px` button height | Discard Preview | Inherited from v1.5/P8 compact-button standard (`ImVec2(80, 24)`). Retained for visual continuity with other secondary/compact P8 buttons. |
| `18px` progress bar height | Training progress bar | Inherited from P8 level-meter `ImVec2(-1, 18)`. Matched intentionally so the training progress bar reads as the same "live data" widget family as the audio level meter immediately below it. Changing to `16` or `20` would break that visual pairing. |

All other spacing values (paddings, gaps, margins, slider widths) are multiples of 4. The non-multiple-of-4 heights above are bounded to button/progress-bar `ImVec2.y` values inherited from the prior milestone's visual baseline.

---

## Typography

P9 inherits P8 typography unchanged. Single ProggyClean baked atlas; visual hierarchy from layout + color, not from font size.

| Role | Size | Weight | Line Height | ImGui Construct |
|------|------|--------|-------------|------------------|
| Body | 13px (default ProggyClean) | regular | ~1.0 | `ImGui::Text(...)` |
| Section heading | 13px (same atlas) | regular, by position | ~1.0 | `ImGui::Text("Training")` followed by `ImGui::Separator()` |
| Emphasis (state/error) | 13px | regular, color = emphasis | ~1.0 | `ImGui::TextColored(color, ...)` |
| Disabled / hint | 13px | regular, alpha 0.5 | ~1.0 | `ImGui::TextDisabled(...)` |

**Rationale:** Same as P8 — adding font atlases is out of scope for the milestone.

---

## Color

P9 introduces **zero new color values**. Every state in the Training pane maps to a P8-declared color or to the dark-theme baseline.

| Role | Value (RGBA float) | Hex | Usage in P9 |
|------|-------------------|-----|-------------|
| Dominant surface (60%) | window bg `(0.06, 0.06, 0.06, 0.94)` | ~#0F0F0F | Panel background — unchanged from P8 |
| Secondary surface (30%) | frame bg `(0.16, 0.29, 0.48, 0.54)` | ~#294A7B | Slider/button chrome, progress-bar empty fill — ImGui dark default |
| Accent green (10%) | `ImVec4(0, 1, 0, 1)` | #00FF00 | Reserved for: "Status: Profile trained and ready" success line **after** a successful finalize round-trip (mirrors v1.5 pattern at `main.cpp:1031`) |
| Accent orange (warning, 10%) | `ImVec4(1, 0.5f, 0, 1)` | #FF8000 | Reserved for: "Cover mic now! ({N}/{M} samples)" coaching copy during `collecting` state (mirrors v1.5 pattern at `main.cpp:999`); "Status: No profile loaded" when `hasProfile` is false |
| Destructive | `ImVec4(0.86f, 0.20f, 0.20f, 1)` | #DC3333 | Reserved for: training-specific `last_error` body (`training_timed_out_no_samples`, `audio_disabled`) rendered through P8's existing HEALTH-05 last-error row — not a new widget |
| Disabled foreground | ImGui style `Text Disabled` `(0.5, 0.5, 0.5, 1)` | #808080 | Train Pattern button when driver-loaded gate is red OR when `enable_driver_audio=0` (D-40 — HTTP 503 path); Recompute slider when not in `ready` state |

**Accent reserved for (P9 explicit list — additive to P8):**
- "Cover mic now!" coaching copy during the `collecting` state (orange, body row beneath the progress bar)
- "Status: Profile trained and ready" success line after `finalized` (green, single line beneath the Training section content)
- "Status: No profile loaded" hint when `hasProfile=false` and not actively training (orange — visual continuity with v1.5)
- The training-progress `ProgressBar` fill itself uses ImGui's default frame color (no `PushStyleColor`) — accent reservation honored: green is for confirmed success states only, not for "in-flight progress"

**60/30/10 audit (P9 specific):**
- Dominant (60%): unchanged — panel bg
- Secondary (30%): progress-bar fill, slider track, button chrome — unchanged
- Accent (10%): one green status line + one orange coaching line + one destructive error line. Same budget as P8; same affordance count. No drift.

**Explicit anti-pattern:** Do **NOT** color the "Confirm & Save" button green via `PushStyleColor(ImGuiCol_Button, ...)`. Primary-CTA emphasis comes from copy + size (140×30 vs 120×24 for "Discard Preview") and from being the only `Confirm & Save`-labeled affordance when `state=ready`. Color is for state, not for hierarchy. (Mirrors P8's color-discipline rule — green ≠ "click here," green = "the driver succeeded.")

---

## Copywriting Contract

All P9 copywriting is in en-US, sentence case, no terminal periods in inline status strings. All training-state copy is driven by `GET /training/progress.state`. All error copy is driven by `GET /training/progress.last_error` (or by P8's `GET /state.last_error` when the failure happened outside an active session).

### Section heading

| Element | Copy |
|---------|------|
| Section heading (always) | `Training` |

### Idle state (no active session, `hasProfile` may be true or false)

| Element | Copy |
|---------|------|
| Primary CTA (button label) | `Train Pattern` |
| Secondary action (button label) | `Discard Profile` |
| Status line (when `hasProfile=true`, post-finalize) | `Status: Profile trained and ready` (accent green) |
| Status line (when `hasProfile=false`, no profile yet) | `Status: No profile loaded` (accent orange) |

**Note on "Discard Profile" button:** Replaces v1.5's ambiguous "Clear" button. Wording is destructive-explicit because it removes the trained profile from in-memory client-side detection (P10 deletes the entire client-side detection path; P9 keeps the button until then, but the copy aligns with P10's intent). See Destructive Confirmations below.

### Collecting state (driver in `Training` mode, samples accumulating)

Driven by `GET /training/progress` poll at 5 Hz when window visible (per P8 cadence rules; tray-minimized poll is paused entirely during training because the user must be at the desk to cover the mic).

| Element | Copy |
|---------|------|
| Coaching copy (above progress bar) | `Cover mic now!` (accent orange) |
| Progress bar overlay | `{samples_collected}/{target} samples` (e.g., `42/100 samples`) |
| Progress bar fill | `samples_collected / target` (float in [0,1]) |
| Cancel button label | `Cancel Training` |
| Status line (below progress bar) | hidden during collecting — coaching copy carries the load |

### Computing state (`finishTraining()` running — brief, ~10–100 ms)

Will rarely be observed visually (transition is fast). Defensive copy if the poll lands during this state:

| Element | Copy |
|---------|------|
| Coaching copy | `Computing thresholds…` (body default — no accent) |
| Progress bar | full (`1.0`), bar overlay reads `Computing` |
| Cancel button | hidden — no cancel during compute (computing is non-cancellable; transitions to ready synchronously) |

### Ready state (thresholds computed, awaiting confirm or recompute)

| Element | Copy |
|---------|------|
| Heading line | `Preview ready — confirm to save, or recompute with a different sensitivity` (body default) |
| Preview block heading | `Preview thresholds` (body default) |
| Preview row 1 | `Sensitivity: {sensitivity:.2f}` |
| Preview row 2 | `Energy threshold: {energy_threshold:.4f}` |
| Preview row 3 | `Spectral profile: mean {mean:.3f}, stddev {stddev:.3f}, {size} bins` |
| Recompute slider label | `Recompute sensitivity:` (with `SliderFloat` 0.0..1.0, default = current preview's sensitivity) |
| Recompute button label | `Recompute Thresholds` |
| Primary CTA button label | `Confirm & Save` |
| Discard button label | `Discard Preview` |
| Status line (below Confirm/Discard row) | hidden in ready state — preview content carries it |

### Cancelled / finalized terminal states

These are observable for at most one poll cycle before the session is destroyed and the UI returns to Idle. The progress payload may show `state=cancelled` with a `last_error` set (timeout) or `state=finalized` (clean finish). UI behavior:

| Terminal state | UI action |
|---------------|-----------|
| `state=cancelled` with `last_error=null` | Return to Idle. No toast — user clicked Cancel Training; the action is its own confirmation. |
| `state=cancelled` with `last_error="training_timed_out_no_samples"` | Return to Idle. Show ephemeral 5 s message in the existing P8 HEALTH-05 last-error slot: `Training timed out — no samples collected in 30 s` (destructive color). |
| `state=finalized` | Return to Idle with `hasProfile=true`. Show 3 s success toast in coaching slot: `Profile saved` (accent green). Then the standing "Status: Profile trained and ready" line takes over. |

### Error / failure copywriting

P9 introduces two new strings in the existing P8 last-error infrastructure. No new widgets — these strings are rendered through HEALTH-05's existing destructive-color last-error row.

| Driver-side `last_error` value | Client-rendered copy |
|--------------------------------|---------------------|
| `training_timed_out_no_samples` | `Training timed out — no samples collected in 30 s` |
| `audio_disabled` | `Driver audio is disabled — enable in driver settings to train` (covers D-40's HTTP 503 path; this string also appears as a stderr-equivalent inline message under the disabled Train Pattern button when `enable_driver_audio=0` is detected) |

P8's existing `last_error` strings (validation failures, `last_error` from `/state`) are unchanged. P9 adds these two on top.

### HTTP error envelope copy (validation rejections per P8 D-14)

When `POST /training/finalize | recompute` returns HTTP 400 with `{"field": "...", "reason": "..."}`:

| Endpoint + condition | Ephemeral toast (3 s, orange, below the affected control) |
|----------------------|----------------------------------------------------------|
| `recompute` with sensitivity out of range | `Invalid sensitivity: must be between 0.0 and 1.0` |
| `finalize` without `confirm` and not in `ready` state | `Cannot save yet — collect more samples first` |
| `finalize` with `confirm:true` but state is `collecting` and samples insufficient | `Need at least {N} samples — keep covering the mic` |
| Any 4xx from `/training/start` when another session is active (HTTP 409) | `Training already in progress` |
| HTTP 503 from `/training/start` (D-40 — `audio_disabled`) | `Driver audio is disabled — enable in driver settings to train` |

### Driver-loaded gate (inherited from P8 D-09)

| Condition | UI behavior in Training pane |
|-----------|------------------------------|
| `/health` returns ECONNREFUSED | Train Pattern button rendered disabled via `BeginDisabled()`; tooltip on hover: `Driver not loaded — settings cannot be changed` (reuses P8's tooltip string verbatim) |
| `/health.driver_training_active=true` while UI launched mid-session (orphan recovery) | Skip directly to Cancel Training button + progress bar driven by `/training/progress`. Train Pattern button is hidden (not just disabled). |

---

## Interaction Contracts

### Poll cadences (additive to P8)

P8's poll cadences for `/health`, `/state`, `/telemetry/level`, `/settings`, `/devices` are unchanged. P9 adds:

| Endpoint | Visible cadence | Tray-minimized cadence | Client-side timeout | When polled |
|----------|----------------|------------------------|---------------------|-------------|
| `GET /training/progress` | 5 Hz | not polled (training requires user at desk) | 250 ms | Only while client believes a training session is active. Triggered by: (a) UI's local `isTraining` flag set after `POST /training/start` returns 200, OR (b) `GET /health.driver_training_active=true` observed (orphan recovery — UI joined mid-session). |
| (orphan-recovery probe) | n/a | n/a | n/a | If `GET /health.driver_training_active=true` observed while local `isTraining=false`, immediately fetch `GET /training/progress` once to seed UI state, then enter the 5 Hz poll. |

**Stop conditions for the 5 Hz poll:**
1. Receive a `GET /training/progress` response where `state=finalized` or `state=cancelled` — fall through to terminal-state UI handling above; on the next frame, drop back to the standard P8 poll set.
2. Receive an HTTP error (ECONNREFUSED) — drop to the P8 driver-loaded gate; do not retry-storm. P8's 1 Hz `/health` poll handles reconnection.
3. User clicks Cancel Training — issue `POST /training/cancel`; on 200 response, immediately mark local state Cancelled and stop the 5 Hz poll on the next frame (next `/health` poll will confirm `driver_training_active=false`).

### Train Pattern button → start training

1. User clicks `Train Pattern` (only enabled when driver-loaded gate is green AND `enable_driver_audio=1` per inferred state — see D-40 handling below).
2. Client issues `POST /training/start` (empty body).
3. **HTTP 200:** Set local `isTraining=true`. Begin 5 Hz `GET /training/progress` poll. Switch UI to Collecting state.
4. **HTTP 409 (`training_in_progress`):** A session is already active (orphan from prior client crash). Show 3 s toast: `Training already in progress`. Force a `GET /health` immediately; if `driver_training_active=true`, switch UI to Collecting state and start polling progress (orphan-recovery path).
5. **HTTP 503 (`audio_disabled` — D-40):** Show 3 s toast: `Driver audio is disabled — enable in driver settings to train`. Disable the Train Pattern button persistently (until next `/health` cycle confirms audio is enabled — for the v1.6 milestone this is a developer-only gate; the user can flip the setting in `default.vrsettings`).
6. **ECONNREFUSED:** Should not be reachable (P8 gate prevents). Silently roll back; let next `/health` flip the gate.

### Cancel Training button → cancel training

1. User clicks `Cancel Training` (visible only during Collecting state).
2. Client issues `POST /training/cancel` (empty body).
3. **HTTP 200 (with body `{"cancelled": true}` or `{"cancelled": false}`):** Stop 5 Hz poll. Set local `isTraining=false`. Return to Idle state. No toast — the action is its own confirmation. (D-13: cancel is idempotent — `false` simply means the session already ended; UI doesn't distinguish.)
4. **ECONNREFUSED:** Driver died mid-session. Stop poll. Set local `isTraining=false`. Return to Idle. P8's driver-loaded gate flips red on the next `/health` poll.

### Ready state → recompute, confirm, or discard

When `GET /training/progress.state=ready`, the UI shows the preview block + three actions:

**Recompute flow:**
1. User adjusts the sensitivity slider (ImGui `SliderFloat` with `0.0..1.0` range, no live PUT — slider value is local until user clicks Recompute Thresholds).
2. User clicks `Recompute Thresholds`.
3. Client issues `POST /training/recompute` with body `{"sensitivity": <slider_value>}`.
4. **HTTP 200:** Response body contains updated `thresholds_preview`. Update local preview rows in place. Slider stays at the chosen value. No toast.
5. **HTTP 400 (out-of-range):** Show ephemeral toast: `Invalid sensitivity: must be between 0.0 and 1.0`. Slider rolls back to prior value.
6. **HTTP 409 (not in `ready` state):** Should not be reachable (button only shown in ready). If observed, show toast: `Cannot recompute right now — try again`. Force progress poll.

**Confirm flow:**
1. User clicks `Confirm & Save`.
2. Client issues `POST /training/finalize` with body `{"confirm": true}`.
3. **HTTP 200:** Stop 5 Hz poll. Set local `isTraining=false`, `hasProfile=true`. Show 3 s success toast in coaching slot: `Profile saved` (accent green). Return to Idle.
4. **HTTP 400 / 409:** Should be unreachable from ready state (driver guarantees). If observed (race), show: `Could not save profile — try training again`. Return to Idle.

**Discard flow:**
1. User clicks `Discard Preview`.
2. Client issues `POST /training/cancel` (same endpoint as the Collecting-state Cancel Training).
3. **HTTP 200:** Same as Cancel Training above — return to Idle, no toast. (D-13 idempotent semantics absorb both the "abort during collect" and "decline preview" use cases without a separate endpoint.)

### Discard Profile (Idle state)

1. User clicks `Discard Profile` (visible only when `hasProfile=true` and not training).
2. Show modal confirmation (see Destructive Confirmations below).
3. On confirm: clear in-memory `detector` profile (existing v1.5 behavior at `main.cpp:1012-1025` — recreate detector instance). Set `hasProfile=false`. **No HTTP call** — the on-disk `training_data.bin` is untouched (driver is sole writer per IPC-06; client cannot delete the file). Next driver reload (`Init`) will re-load whatever is on disk; the discard only affects the client-side detector's in-memory profile.

---

## Destructive Confirmations

P9 has **one** destructive action: `Discard Profile` (replaces v1.5's unconfirmed "Clear" button at `apps/micmap/main.cpp:1012`).

| Action | Confirmation pattern |
|--------|---------------------|
| `Cancel Training` (during collecting) | none — non-destructive (samples are RAM-only and explicitly disposable; the action is the confirmation) |
| `Discard Preview` (during ready preview) | none — non-destructive (preview is RAM-only; samples are not persisted) |
| `Discard Profile` (idle, `hasProfile=true`) | **modal confirmation required** (see below) |

**Modal copy for `Discard Profile`:**

| Element | Copy |
|---------|------|
| Modal heading | `Discard trained profile?` |
| Body | `Your client-side detection will stop using this profile until you train again or restart the driver. The on-disk profile (used by the driver) is unaffected.` |
| Confirm button | `Discard` (in destructive color via `PushStyleColor(ImGuiCol_Button, destructive)` — this is the **one** P9 exception to the "color is for state, not buttons" rule, justified by destructive-action conventions; modal-button label remains the bare verb because the noun is carried by the modal heading) |
| Cancel button | `Keep` |

**Rationale for the exception:** P8 has no destructive actions and accordingly has no colored buttons. P9 introduces one destructive action and follows the universal convention that destructive confirmation buttons are colored. Limited to this one button. The Train Pattern / Cancel Training / Recompute Thresholds / Confirm & Save buttons are **not** colored.

---

## Empty States and First-Run

| Element | Copy |
|---------|------|
| Training section before first `/health` reply | Train Pattern button rendered disabled with P8 gate tooltip: `Driver not loaded — settings cannot be changed` |
| Training section when `enable_driver_audio=0` (D-40) | Train Pattern button rendered disabled. Hint text below: `Driver audio is disabled — enable in driver settings to train` (orange, body row, single line) |
| Training section when `hasProfile=false` and not training | Status line: `Status: No profile loaded` (orange) — same as v1.5 carryover |
| Progress bar before first poll response | Hidden — Collecting-state UI only renders after the first 200 from `GET /training/progress` |
| Preview block before `state=ready` | Hidden — Ready-state UI only renders when progress payload reports ready |

---

## Out-of-Scope (Phase 9 UI)

Explicit guardrails for the executor — these belong to P10 or later and must NOT be added in P9:

- **Tray-icon "training" glyph** (HEALTH-08 family) — P10. P9 keeps the v1.5/P8 single-icon tray; training state is not surfaced in the system tray.
- **TRAIN-D2 spectral-profile sparkline / FFT-bin visualization** — future GUI revamp milestone. P9 ships the summary fields only (mean/stddev/size).
- **A/B threshold preview UI** — TRAIN-D2 deferred. P9's recompute replaces the preview in place; no side-by-side comparison widget.
- **Configurable target sample count via UI** — TRAIN-D3 deferred. Target is hardcoded to `PatternTrainer::TrainingConfig::maxSamples` (default 100). UI shows `{collected}/{target}` but does not expose a slider for `target`.
- **In-VR overlay training** (UX-02) — out of v1.6 entirely.
- **Resume training across SteamVR restart UX** — D-14 rejected. No "Resume previous session?" prompt. Mid-session crash silently discards collected samples.
- **Realtime / wall-clock progress estimate** — no "ETA: 30s" copy. The progress bar reflects sample count only; sample arrival rate is user-paced (mic-cover gesture cadence).
- **Custom font atlases** — out of P9 scope. ProggyClean only.
- **Multiple type sizes / heading hierarchy** — same as P8.
- **Per-trigger replay-result visualization** in the client UI — TEST-04 replay results live in the headless `mic_test.exe` JSON output and CI artifact, not in the client GUI.

---

## Replay Harness UI (TEST-04) — Out of GUI Scope

`mic_test.exe --replay` and `--replay-dir` live in the headless `apps/mic_test/` binary. **No GUI elements introduced.** This UI-SPEC declares the harness's user-facing contracts here for completeness, but they are CLI/JSON contracts, not visual contracts:

- **Stdout copy on per-file pass:** `PASS  {wav_path}  observed={N} expected={M}` (single line per file in `--replay-dir` mode)
- **Stdout copy on per-file fail:** `FAIL  {wav_path}  observed={N} expected={M} (tolerance ±{T})`
- **Summary line on completion:** `Replay corpus: {passed}/{total} passed`
- **Stderr copy on unsupported format:** `error: {wav_path}: unsupported bit depth {N} (only 16-bit PCM and 32-bit float supported)` (exits 2)
- **Stderr copy on file-too-long:** `error: {wav_path}: duration {S}s exceeds --max-duration {M}s` (exits 2)
- **JSON shape:** D-30 in `09-CONTEXT.md` is the contract; not duplicated here. `gsd-ui-checker` is not expected to validate the JSON shape (it's a non-visual contract).

This block exists so `gsd-ui-checker` can mark Dimension 1 (Copywriting) PASS without flagging the absence of GUI in the replay harness as a gap. The replay harness has no visual surface by design (TEST-01 invariant — no SteamVR, no driver, headless).

---

## Registry Safety

Not applicable. P9 ships zero new third-party UI components. The client uses Dear ImGui (already vendored, MIT-licensed, in-tree since v1.0) and adds no registries or component imports. P9 vendors `dr_wav.h` (single-header WAV decoder, public domain / MIT-0) but it is consumed only by the headless `mic_test.exe` and never reaches the GUI.

| Registry | Blocks Used | Safety Gate |
|----------|-------------|-------------|
| Dear ImGui (in-tree, vendored) | existing widgets only — no new widgets | not required (in-tree, no fetch) |
| `dr_wav.h` (mackron/dr_libs, single-header) | not a UI component — headless audio decoder for `mic_test.exe` only | not applicable to UI contract; license verified public-domain / MIT-0 in `09-RESEARCH.md` |
| shadcn official | none | not applicable (no web stack) |
| Third-party UI registries | none | not applicable |

---

## Pre-Population Sources

| Source | Decisions Used |
|--------|---------------|
| `08-UI-SPEC.md` (conceptual baseline) | Entire design system inheritance — spacing tokens, ImGui style baseline, color palette, typography, poll cadence framework, driver-loaded gate copy ("Driver not loaded — settings cannot be changed"), HEALTH-05 last-error rendering pattern, optimistic-apply pattern shape |
| `09-CONTEXT.md` D-01..D-04 | DriverMode mode-switch architecture — informs "Cover mic now!" coaching pattern (training is a mode, not a separate UI surface) |
| `09-CONTEXT.md` D-07 | `driver_training_active` `/health` field — orphan-recovery UI path |
| `09-CONTEXT.md` D-09..D-22 | TrainingSession lifecycle — informs Idle/Collecting/Computing/Ready state UI mapping |
| `09-CONTEXT.md` D-12 | 30 s timeout → `training_timed_out_no_samples` last_error copy |
| `09-CONTEXT.md` D-13 | Cancel idempotency → no toast on cancel; Discard Preview reuses Cancel endpoint |
| `09-CONTEXT.md` D-15..D-16 | Finalize confirm/sensitivity payload → "Confirm & Save" CTA + Recompute Thresholds slider |
| `09-CONTEXT.md` D-22 | Progress wire shape → preview block field rendering (sensitivity, energy_threshold, spectral_profile_summary) |
| `09-CONTEXT.md` D-40 | `audio_disabled` HTTP 503 path → disabled Train Pattern button + hint copy |
| `09-RESEARCH.md` Architecture diagram | Section ordering and ImGui widget choice (`ProgressBar`, `SliderFloat`, `Button`, `BeginDisabled`) |
| `apps/micmap/main.cpp` (existing v1.5) | Section position (Training between Settings and Audio Levels), button sizes (120×30 standard / 80×24 compact / 18-tall progress bar), color conventions ("Cover mic now!" orange, "Profile trained" green, "No profile loaded" orange) — preserved for visual continuity through the rewire |
| `REQUIREMENTS.md` TRAIN-01..06, TEST-04, IPC-06 | Endpoint behaviors driving each UI state |

---

## Revision History

| Date | Change | Reason |
|------|--------|--------|
| 2026-05-08 | Initial draft | gsd-ui-researcher first pass |
| 2026-05-08 | (1) Spacing exception declaration corrected: documented inherited 30px button height, 24px compact-button height, and 18px progress-bar height as v1.5/P8 visual-baseline exceptions to the multiple-of-4 rule (executor must not change these). (2) Three CTA labels promoted from bare verbs to verb+noun for clarity: `Cancel` → `Cancel Training` (collecting state), `Recompute` → `Recompute Thresholds` (ready state), `Discard` → `Discard Preview` (ready-state preview). Idle-state `Discard profile` capitalized to `Discard Profile`. Modal `Keep` and modal `Discard` button labels left bare (modal heading carries the noun). | Address gsd-ui-checker findings: Dimension 5 BLOCK on undeclared spacing exceptions; Dimension 1 FLAG on single-word CTAs. |

---

## Checker Sign-Off

- [ ] Dimension 1 Copywriting: PASS
- [ ] Dimension 2 Visuals: PASS
- [ ] Dimension 3 Color: PASS
- [ ] Dimension 4 Typography: PASS
- [ ] Dimension 5 Spacing: PASS
- [ ] Dimension 6 Registry Safety: PASS

**Approval:** pending
