Skip to content

v1.0 candidate: UI polish + Phase 11 + Phase 12#3

Merged
devangk003 merged 37 commits into
mainfrom
feat/ui-fixes
May 17, 2026
Merged

v1.0 candidate: UI polish + Phase 11 + Phase 12#3
devangk003 merged 37 commits into
mainfrom
feat/ui-fixes

Conversation

@devangk003

Copy link
Copy Markdown
Owner

Everything from the dogfood polish window:

  • UI audit waves (W1/W2/W3 + Round 2 + Pill UI audit + History/Models/About polish)
  • Tray menu redesign (custom WPF popup matching Tray_light/dark.png)
  • State-aware tray icons
  • Pill = compact-mode pin semantics + dock + nudge + corner radius behavior
  • Onboarding: real dictation in step 6 + mic chooser in step 3 + show-once-ever semantic
  • Theme defaults flipped to dark + Light [BETA]
  • Phase 11: Sentry crash reporter + egress allowlist
  • Phase 12: CI/CD + Inno installer + bundled tiny.en

All deviations documented in CLAUDE.md.
v0.1.0-rc4 installer smoke-tested clean.

devangk003 and others added 30 commits May 16, 2026 20:16
Consuming the LWin keyup left Windows thinking Win was still held, so
PasteEngine's SendInput(Ctrl+V) read as Win+Ctrl+V (Action Center / Quick
Settings) and every subsequent keystroke became a Win+key system shortcut.
The Ctrl-tap injection (AHK #MenuMaskKey idiom) still runs to suppress the
Start menu; we just let the real LWin keyup reach the OS so its key-held
state clears.

Spec §13 prescribes the old (buggy) behavior; flagged for revision in
CLAUDE.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Surface (docs/PILL_DESIGN.md §1, §3):
- 200×56 with 8 px DWM-rounded corners, Mica backdrop on Win11 22H2+
  (DWMWA_SYSTEMBACKDROP_TYPE = DWMSBT_TRANSIENTWINDOW), dark-tinted via
  DWMWA_USE_IMMERSIVE_DARK_MODE. Falls back to the §3.1 dark gradient
  on older Windows.
- §3.3 1 px hairline border + drop shadow + inner top highlight.

Five-state machine (§2):
- Recording: 20-bar visualizer + RECORDING micro-label.
- Transcribing: 14 px ¾-arc spinner (0.9 s loop, rotated via direct
  BeginAnimation on the RotateTransform) + "Transcribing…" text.
- Confirmed: "Pasted into <App>" with the bold app name, 1 s hold.
- Error: 5 px red dot + reason text, 2 s hold, instant accent shift to
  red (§5).
- Idle: PRD G4 dev override — pill stays visible between dictations
  showing the app icon + "KusPus" label. Will revert to spec §6.1
  hidden-when-not-in-use once Settings exposes the close path.

Visualizer (§4):
- 20 bars × 3 px wide × 4 px gap × 4–26 px tall (136 px track).
- Damped target/value motion model per §4.2: center-weighted speak
  envelope, per-bar damp rates, real audio levels from IAudioRecorder
  override the simulation when present. Runs on CompositionTarget.
  Rendering for display-refresh smoothness.

Accent line (§3.4):
- 136 × 1.5 mint gradient with glow, opacity per state.

Motion (§5):
- 120 ms pill appear/disappear, 150 ms content crossfade between
  states. Cubic easing.

Hover-extend override (PILL_DESIGN.md §10):
- Width animates 200 → 280 over 150 ms on hover, Settings + Close
  buttons fade in. WS_EX_TRANSPARENT intentionally NOT applied
  (overrides §1.2 click-through) so buttons work; WS_EX_NOACTIVATE
  preserved so focus doesn't move.

Draggable pill (beyond spec):
- MouseLeftButtonDown anywhere on the pill body → DragMove. Skips
  when click is on a Button (Settings / Close).
- Session-only per-monitor remembered positions via
  Dictionary<deviceName, Point> keyed by MONITORINFOEX.szDevice.
  Cleared on every fresh process start.

Multi-monitor option C (hybrid sticky):
- On state transition into Armed/Recording, jump to the foreground
  window's monitor at its remembered position (or default
  bottom-center if first time). No-op when pill is already on the
  right monitor or while user is dragging.

Coordinator snapshot extension:
- CoordinatorSnapshot.PostPaste:PostPasteInfo carries (Pasted,
  TargetApp, ErrorReason). AppCoordinator emits one post-paste
  snapshot from DeliverAsync / HandleFailureAsync so the pill knows
  whether to show Confirmed or Error.

Icon pipeline:
- tools/IconBuilder: one-shot SVG → multi-frame ICO converter.
- icons/icon.svg as the single source of truth. ViewBox tightened to
  "272 246 480 480" so the 5-bar content fills ~94 % of every
  rendered frame (tray, taskbar, Task Manager, .exe icon).
- icons/icon.ico regenerated, embedded as ApplicationIcon + WPF
  Resource. TrayManager loads via pack URI.
- SharpVectors.Wpf 1.8.5 renders the SVG directly inside the pill
  Idle state — no hand-converted XAML to drift from the source.

Deferred from spec (not blockers):
- §3.2 light theme + WM_SETTINGCHANGE live switching.
- §3.4 accent variants beyond Mint (needs Settings UI from Phase 9).
- §5.3 reduced-motion gating of fades.
- Win10 acrylic fallback.

Tests: 117/117 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Surface:
- New MainWindow per docs/APP_DESIGN.md §3. 880×620 (820×600 min) system-
  chromed window. Sidebar nav (6 RadioButton tabs styled per §3.2 — mint
  stripe + elevated bg on select). Close hides (§3.1 / §8.5); only the
  tray's Quit fully exits. Tray menu gains "Preferences…" → MainWindow.
- Dark title-bar tint via DwmSetWindowAttribute DWMWA_USE_IMMERSIVE_DARK_MODE
  + SetWindowPos(SWP_FRAMECHANGED) to force the non-client repaint on
  runtime theme flips (validated against Microsoft Q&A "DWMWA_USE_
  IMMERSIVE_DARK_MODE won't update").

Six tabs (§3.3):
- General: Hotkey hero card with live listen-mode rebind (suspends LL hook,
  captures held-keys snapshot, commits on full release, conflict warning
  against known Windows shortcuts), Startup toggle → HKCU\Run, Appearance
  Auto/Light/Dark segmented control.
- Audio: device label + Discord-style 200×6 track+fill meter (validated
  fix against naudio/NAudio#160 #347 #507 — MMDevice.AudioMeterInformation
  reports zero without an active capture session, so we open a WasapiCapture
  and compute peak from samples in DataAvailable). Peak-hold tick that
  decays slower than fill.
- Models: active-model row + manifest list with install state (file
  existence per device), radio-select writes ActiveModelId to PrefsStore;
  download wiring deferred to Phase 11.
- History: last 50 transcripts via IHistoryStore.SearchAsync; status dot
  (mint = ok, red = failed), relative time, app name, model + duration.
- Privacy: offline + crash-reports toggles to PrefsStore, logs path +
  Open in Explorer, local-first mint promise card.
- About: 80px brand mark + version line (AssemblyInformationalVersion) +
  Cascadia-Mono build line, Resources card group (GitHub link + logs +
  Re-run onboarding placeholder), MIT/local-first license blurb.

Theming infrastructure:
- ThemeApply (new) resolves "auto"/"light"/"dark" against AppsUseLightTheme
  registry, applies DWM dark-mode + SWP_FRAMECHANGED.
- ThemeTokens (new) — 23-entry map of (dark, light) Color pairs covering
  AppBg, Sidebar, Surface, SurfaceElevated, BorderSubtle/Strong/Divider,
  Primary/Secondary/Muted/DisabledText, HoverSubtle, KeycapBg/Border,
  Mint/MintTint/MintBorder, ErrorRed, WarningAmber/Tint/Border, plus
  pill-specific PillBorder/PillInnerHighlight/VisualizerBarActive/Idle
  and MeterTrack/ButtonHoverBg. Plus a LinearGradientBrush builder for
  the pill's two-stop surface gradient.
- ThemeTokens.Apply uses REPLACEMENT (not mutation) — WPF freezes
  Freezable resources in Application.Resources (x:Shared semantics) so
  brush.Color mutation throws InvalidOperationException at startup.
  Replacement fires ResourcesChanged; every {DynamicResource} consumer
  re-resolves.
- MainWindow.xaml + FloatingPillWindow.xaml refactored end-to-end to
  use {DynamicResource Token} for every brush/foreground/border. Code-
  built UI (Models rows, History rows, hotkey keycaps) uses a Theme(key)
  helper that returns the current resource brush; theme changes re-
  render visible dynamic tabs so they pull fresh brushes.
- Pill visualizer bars use SetResourceReference(FillProperty) instead
  of a frozen explicit brush so bars re-theme on switch.

Deviations from spec — flagged in CLAUDE.md (no MainWindow.xaml hex
literals migrated; everything is now token-based). Body theming for
the pill required new PillSurface gradient resource installed per-
theme. Multiple WPF parse-time gotchas worked around: IsChecked="True"
on TabGeneral triggers Checked event before content panels are bound
to fields — fixed with a _loaded guard in OnTabChecked.

Tests: 117/117 pass.

docs/APP_DESIGN.md: new authoritative UI spec from the user, referenced
from CLAUDE.md source-of-truth list. Where it conflicts with PILL_DESIGN
§2.1 (click-through) the §10 hover-extend override still wins.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
OnboardingWindow per docs/APP_DESIGN.md §4. 720×520 chromeless rounded card,
12 px corners, draggable from the progress-dots header. Theme-aware via the
same ThemeApply + DynamicResource brushes that flip MainWindow / pill.

Seven steps (linear nav with Back / Next / Skip / Finish):
- 1 Welcome: stylized desktop preview + 3-column value-prop grid.
- 2 Hotkey: listen-mode + capture + commit to PrefsStore.Hotkey, with the
  same Win+L-class conflict warning. Duplicates MainWindow's listen-mode
  state machine (extract to UserControl when there's a third consumer).
- 3 Mic check: WasapiCapture on default device, Discord-style track+fill
  meter, success/error variants, Open Settings → ms-settings:privacy-microphone.
- 4 Autostart: clickable ToggleCard → HKCU\Run via AutostartRegistry.Set.
- 5 Crash reports: ToggleCard + Local-first promise card.
- 6 Try it: 120-DIP transcript surface, "Simulate dictation" runs 1.8 s
  Listening… then surfaces one of three canned sentences with mint border.
- 7 Done: corner-of-screen tray-diagram + Finish.

Finish path writes PrefsStore.Onboarding.Completed = true. Skip / Esc /
window-close leave Completed = false so the modal re-pops on next launch.
Re-runnable any time from About → "Run again" (replaces the disabled
Phase 9 placeholder button).

First-launch trigger in App.OnStartup: after _coordinator.Start(), queue
ShowDialog at DispatcherPriority.Background so OnStartup returns before
the modal's nested dispatcher frame begins. Pill + coordinator already
running by then, so the modal's hotkey picker can suspend the live LL
hook cleanly.

Deferred to a polish cluster: §4.1 dimmed-desktop backdrop, hotkey-picker
UserControl extraction, value-card hover states.

Tests: 117/117 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nding

Phase 11 (TECH_SPEC §19, PRD §10.2/§10.3)
- CrashReporter: Sentry init/shutdown gated on (CrashReportsOptIn && !OfflineMode).
  Embeds project DSN (env-var override) and routes Sentry's own transport through
  EgressAllowlistHandler so PRD §10.2 holds for SDK uploads too.
- EgressAllowlistHandler + EgressPolicy: v1 allowlist. Accepts huggingface.co and
  regional Sentry ingest hosts (*.ingest[.<region>].sentry.io); Offline Mode and
  non-HTTPS block everything. Pure policy decision in Core, IO handler in App.
- CrashScrubber: drops events whose Tags/Extra contain transcript/clipboard/text/
  password/target_app/hwnd keys; replaces %TEMP%, %LOCALAPPDATA%, %APPDATA%,
  %USERPROFILE% prefixes (start-anchored) and Environment.UserName occurrences
  (mid-string) in messages, exception text, stack-frame paths, breadcrumbs.
- 30 new unit tests (167/167 total).

UI/UX W1 — "stop lying" (APP_DESIGN §13 audit findings)
- Crash Reports toggle visually disables when Offline Mode is on; subtitle swaps
  to "Disabled while Offline Mode is on." The toggle's IsChecked is preserved.
- Replace stale "Phase X" + Win32 jargon copy across General/Audio/Models/About.
- Remove permanently-disabled "Test transcription" section; restore in W3 when wired.
- Sidebar footer bound live: status label from AppCoordinator snapshots, chord
  glyph from PrefsStore (compact short form: "Ctrl+Win", not unicode soup).
- Inline [ESC] keycap in hotkey-listen hint (APP_DESIGN §13.4).
- Audit findings appended to docs/APP_DESIGN.md as §13 with a progress ledger.

Build: 0/0. Tests: 157 + 10 new CrashScrubber + extended EgressPolicy = 167/167.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…, spacing)

APP_DESIGN.md §13.5 P1-6/-7/-1/-9/-8/-P2-5. No user-visible scope change —
this is pure styling consolidation. Five new shared style sets, four
inline-styled buttons replaced, one fragile negative-margin layout fixed.

New: src/KusPus.App/Styles/
- Typography.xaml — SectionHeader, Type.RowTitle, Type.RowSubtitle, Type.Eyebrow,
  Type.MonoSm, Type.MonoXs, Type.Display, Type.WarningEmphasis. Replaces ~25
  inline TextBlock FontFamily / FontSize / FontWeight / Foreground quadruplets
  across MainWindow.xaml.
- Dot.xaml — Dot.Mint / Dot.Amber / Dot.Red ellipse styles with the spec's
  7 px + 6 px coloured glow. Replaces 4 hand-rolled ellipses in MainWindow.xaml
  + 1 code-behind ellipse in the history row renderer.
- Buttons.xaml — Btn.Primary / Secondary / Ghost / Danger × Sm / Md / Lg per
  APP_DESIGN §3.4. All buttons now share one template (border + content + hover
  opacity ladder + disabled-state); inline Padding / Background / Foreground /
  BorderThickness gone from every call site.
- Focus.xaml — Focus.Mint: 1.5 px mint dashed outline at 2 px inset. Wired into
  SidebarTab, Toggle, SegmentButton, and Btn.Base — keyboard-nav now has a
  visible focus ring that reads as a design choice rather than the WPF default
  dotted-Aero ring.

Modified: src/KusPus.App/MainWindow.xaml
- All TextBlock declarations matching repeated patterns use a Style key.
- All Buttons use Btn.Secondary (only kind currently needed; the rest of the
  set arrives when W3's purge / download flows land).
- ConflictRow refactored: wraps HotkeyCard + ConflictRow in a single 440 px
  StackPanel with bottom Margin 28. ConflictRow gets `Margin="0,1,0,0"` (1 px
  gap below HotkeyCard) instead of the previous `Margin="0,-20,0,28"` negative-
  margin tuck. Section gap now lives on the parent, not on each child.

Modified: src/KusPus.App/App.xaml
- Application.Resources now merges the four Styles/*.xaml dictionaries so
  every keyed style is reachable app-wide (including future OnboardingWindow
  reuse).

Build: 0/0. Tests: 167/167 still green. Smoke: clean launch.

P1-10 (per-tab UserControl extraction) deferred to a post-W3 cleanup — doing
it now would mean reshuffling every W3 addition into newly-created files. The
ledger in docs/APP_DESIGN.md §13.5 reflects this.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…loads, contrast

APP_DESIGN.md §13.5 P0-3, P0-5, P1-2, P1-3, P1-4. Ships the four user-facing
gaps the audit flagged. All wired against existing services — no new layers.

P0-3 — DisabledText contrast
- ThemeTokens.cs: dark #5CFFFFFF → #80FFFFFF (~3.0:1 → ~5.1:1 vs AppBg).
- Light #50141414 → #A0141414 (~1.9:1 → ~4.7:1 vs AppBg).
- Both clear WCAG AA normal-text 4.5:1. Used for hotkey hint, model "Not
  installed" label, disabled buttons.

P0-5 — Mic-active disclosure (already in W1)
- The Audio "Live level" subtitle reads "Active only while this tab is open.
  Audio is never recorded." Tracking the ledger to "done" — no code change.

P1-3 — Privacy Logs row
- Two-row card: "Log size · {size}" with Clear logs ghost-danger button, then
  "Log folder · {path}" with Open in Explorer secondary button.
- RefreshLogsSize enumerates LOCALAPPDATA\KusPus\logs\*.log on Loaded.
- OnClearLogsClick confirms via MessageBox (No default), then File.Delete each
  *.log. Today's open log is held by Serilog's FileSink — skipped without
  error, count reported in log.
- FormatBytes handles bytes / KB / MB.

P1-2 — History search + bulk footer
- Search box at top with Segoe Fluent icon, placeholder overlay, clear "×"
  button. 250 ms DispatcherTimer debounces TextChanged → HistoryStore.SearchAsync
  (FTS5 backing). Empty query reverts to "most recent first".
- Footer above 1px divider: live row count, "{n} matches for '…'" when filtered,
  Purge all history Btn.Danger with MessageBox confirm. Calls HistoryStore.PurgeAllAsync.
- PurgeAllButton.IsEnabled gates on row count > 0 || query is not null.

P1-4 — Models download flow
- Per-model state machine in _modelDownloads dictionary keyed by id.
- BuildModelStatusRegion dispatches on state: Active / Installed / Downloading
  (180 × 4 px mint progress bar + percent in mono + Cancel ghost) / Error
  (red message + Retry secondary) / Not installed (Download secondary).
- OnModelDownloadClick: pre-checks OfflineMode (clearer message than letting
  EgressAllowlistHandler throw mid-stream), spawns Task.Run with cancellable
  cts. IProgress<DownloadProgress> marshals to UI thread, throttled to 0.5 %
  steps so the StackPanel rebuild doesn't dominate CPU on a fast link.
- OnModelCancelClick: cts.Cancel(). Completion continuation handles cleanup
  for both cancellation (silent) and failure (sticky error + Retry).
- ShortenDownloadError strips ModelManager's "HTTP error downloading …:" prefix.

Build: 0/0. Tests: 167/167.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ing rhythm

History tab — table layout (audit follow-up)
- Replace card-list rendering with a Grid-based table: header row in
  Type.Eyebrow style above, 6-column body rows below. Columns: status (14) /
  time (78) / app (110) / transcript (*) / model (72) / duration (52).
- Per the skill's number-tabular rule: time, model, duration use Cascadia
  Mono so columns stay aligned across rows.
- Per truncation-strategy: transcript and app truncate with ellipsis +
  ToolTip exposing the full text on hover. Time column ToolTip shows the
  full timestamp.
- Per gridline-subtle: 1 px BorderDivider between body rows, 1 px
  BorderSubtle between header and body. No row striping.
- Row hover: HoverSubtle background via Style trigger on the HistoryRow
  Border (Cursor=Hand for affordance).
- Right-click context menu per row: "Copy text" (Clipboard.SetText) and
  "Delete" (HistoryStore.DeleteAsync + ReloadHistoryAsync).
- ShortModelId strips "ggml-" prefix to match the sidebar's compact form.
- Failed transcripts: red dot + italic red transcript column; rest of the
  row stays normal so the failure mode reads as one cell, not the row.

Pill Settings button wired to Preferences modal
- Add SetSettingsAction(Action) to FloatingPillWindow, mirroring
  SetCloseAction's pattern.
- App.OnStartup wires it to _mainWindow.ShowOn("general") AFTER MainWindow
  is constructed (the existing pill setup runs before MainWindow exists).
- Tooltip "Settings — coming soon" → "Open Preferences".

Spacing rhythm normalization
- Models tab: list bottom margin 12 → 16 (align with the 8-grid inter-block
  rhythm; "16 = block gap" is now consistent across History search bar and
  Models list).
- About tab: header StackPanel bottom 32 → 28 (matches the section gap
  rhythm used everywhere else, instead of being one-off heavier).
- History footer: top margin 18 → 16 (16 is the canonical block gap).

Build: 0/0. Tests: 167/167. Smoke: clean launch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…le-card

Parallelised Phase 1 (5 sub-agents wrote disjoint style files concurrently),
then serial Phase 2 sweep of MainWindow + App.xaml + APP_DESIGN.md.

NEW style files (Phase 1, parallel)
- Styles/Tokens.xaml   — Space.xs..xxl + Pad.Tight/Default/Hero + Radius.Sm/Md/Lg
- Styles/Surfaces.xaml — Surface.Default/Hero/Tight/Warning/Mint (5 Border styles)
- Styles/Inputs.xaml   — Input.Search TextBox style
- Styles/Typography.xaml extended — 10 new Type.* roles, dead Type.MonoXs removed
- Styles/Buttons.xaml extended — new Btn.IconGhost; header docs call-site inventory

Phase 2 — App.xaml wires the 3 new dictionaries (Tokens first so others can
reference its tokens), then sweep MainWindow.xaml + MainWindow.xaml.cs:

MainWindow.xaml migrations
- Hotkey card    Border → Surface.Hero (drops inline Padding 22,20 override)
- ConflictRow    Border → Surface.Warning (collapses 5 inline attrs)
- Local-first    Border → Surface.Mint    (collapses 5 inline attrs)
- HotkeyHint        TextBlock → Type.HintItalic
- ConflictText      TextBlock → Type.WarningBody
- AboutVersion      TextBlock → Type.Body
- AboutBuildLine    Margin 0,4,0,0 → 0,3,0,0 (matches Type.RowSubtitle rhythm)
- Local-first head  TextBlock → Type.MintHeadline
- Local-first body  TextBlock → Type.BodySmall
- MIT licensed      TextBlock → Type.Footnote
- "Press a hotkey"  TextBlock → Type.HintItalic
- StatusLabel       TextBlock → Type.SidebarStatus (was Type.MonoSm-with-override misuse)
- History search bar magnifier → Type.IconSm
- History search box  TextBox → Input.Search
- History search clear Button → Btn.IconGhost
- About re-run card    Margin 0,0,0,32 → 0,0,0,28 (matches section gap)
- Sidebar footer Grid  Margin 18,8,18,14 → 14,8,14,14 (matches sidebar 14)

History tab — unified single composed card (Q3 from user audit decisions)
- Outer RowCard Padding="0" wraps a StackPanel of inner Borders.
- Search bar (Padding 14,8, bottom 1 px divider), table header (Padding 14,10,
  bottom 1 px divider), HistoryList (HistoryRow style provides per-row bottom
  divider), bulk footer (Padding 14,12, no top border — last row's bottom
  border IS the separator → no double line).
- Reads as one "history widget" instead of four separately-styled blocks.

MainWindow.xaml.cs code-behind sweep
- New TypeStyle(string) helper (mirrors Theme()) to pull Type.* styles from
  Application.Resources for code-built TextBlocks.
- BuildModelRow title/subtitle → Type.RowTitle / Type.RowSubtitle
- BuildBundledBadge child TextBlock → Type.BadgeMint
- BuildModelDownloadingRegion percent → Type.MonoSm
- BuildModelErrorRegion error text → Type.ErrorInline
- BuildHistoryRow TIME / MODEL / DUR columns → Type.MonoSm (transcript + app
  columns + Installed/Active status stay inline because Foreground flips per
  state — no single Type.* role covers both colour states).
- Empty-state TextBlock in ReloadHistoryAsync → Type.HintItalic
- Dropped unreachable "(none)" branch in RenderModelsTab (audit P2-8).

Docs: APP_DESIGN.md §13.5 ledger updated (P2-8/-9 done, P2-10 won't-fix with
reason); new §13.6 documents the Round 2 work — token system, surface variants,
typography role catalogue, inputs/IconGhost, spacing fixes, History
unification, code-behind cleanup, dead-code policy.

Parallelisation safety
- 5 sub-agents in Phase 1 each owned exactly one file (disjoint writes — no
  lost-update risk). None ran dotnet build (avoids bin/obj corruption). The
  orchestrator does all building serially in Phase 3 after killing any running
  KusPus.exe to avoid output-DLL locks.

Build: 0/0. Tests: 167/167. Smoke: clean launch + Sentry init OK.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author byline (About → bottom-right)
- "Made by Devang Kumawat" + LinkedIn / X / GitHub / Portfolio icon row at
  the bottom-right of the About tab, sitting directly on AppBg (no card —
  reads as a personal touch, not part of the design-system surface inventory).
- Text uses Type.Footnote (12 Medium SecondaryText) — matches the existing
  "MIT licensed" line on the left. Per UI UX Pro Max rule weight-hierarchy:
  text carries the byline weight, icons are visually subordinate.
- Each icon: 14×14 Viewbox inside a Btn.IconGhost (28×28 click target).
  Aspect ratio locked by the Viewbox's default Uniform Stretch and the
  underlying 24×24 viewBox.
- Theme tinting: Fill="{DynamicResource MutedText}" (filled paths) or
  Stroke="{DynamicResource MutedText}" (Lucide globe) — no per-theme assets,
  one shared rendering for dark+light.

Icon sources (saved to icons/social/ + LICENSE.md attribution)
- LinkedIn / X / GitHub: Simple Icons (CC0) via jsDelivr simple-icons@v11
  and raw.githubusercontent.com/simple-icons/simple-icons/develop/icons/.
- Portfolio (globe): Lucide (ISC) from raw.githubusercontent.com/lucide-icons/lucide.
- Saved as .svg files for documentation/license tracking; actual rendering
  inlines the path data in MainWindow.xaml so the fill binds to theme tokens
  (SharpVectors SvgViewbox can't easily theme-tint).

Each icon button OpenUrl(...) → Process.Start with UseShellExecute=true.
Single helper handles Win32Exception + FileNotFoundException for missing
default browser without crashing.

Links wired
- LinkedIn → https://www.linkedin.com/in/devangk003/
- X        → https://x.com/devang_kumawat
- GitHub   → https://github.com/devangk003
- Portfolio → https://lnk.bio/devangk003

Spacing (user audit feedback — 1 px stacking felt cramped)
- Models tab BuildModelRow inter-row Margin 1 → 8 (per model row reads as
  its own card now, not a grouped 1-px-divider stack).
- About tab Resources/Log-folder cards Margin 0,0,0,1 → 0,0,0,8. Re-run
  card already at 0,0,0,28 (section gap below it).

Build: 0/0. Tests: 167/167. Smoke: clean launch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit gap closed. Previously the three top-level handlers
(OnUnhandledException / OnDispatcherUnhandled / OnUnobservedTask) only
wrote to Serilog — Sentry's own AppDomain auto-hook fires in parallel but
WPF dispatcher exceptions were swallowed by e.Handled=true before Sentry
could see them. Now each handler logs first, then forwards via the new
TryReportToSentry helper.

TryReportToSentry is gated on _crashReporter?.IsActive so the call no-ops
when the user hasn't opted in (or Offline Mode killed the SDK). The Sentry
call itself is wrapped in try/catch so a Sentry failure can't recurse into
another unhandled exception.

Behaviour summary
- Crash Reports OFF: handlers log locally, no network. Same as before.
- Crash Reports ON, Offline Mode OFF: every unhandled exception (AppDomain,
  WPF dispatcher, unobserved Task) reaches your Sentry EU project with the
  scrubbing pipeline applied.
- Crash Reports ON, Offline Mode ON: CrashReporter shuts the SDK down →
  IsActive==false → handlers skip the Sentry call. Local logging still runs.

Build: 0/0. Tests: 167/167. Smoke: clean launch + Sentry init OK.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…F commit

Onboarding rounded corners (APP_DESIGN §13.7)
- Root cause: WindowStyle=None + AllowsTransparency=False + Background=Transparent
  renders the area outside the inner Border's CornerRadius as black. The
  inner <Border CornerRadius=12> shows but the 4 corner triangles around it
  fill with WPF's solid black for "transparent-but-not-actually-transparent".
- Fix per Microsoft's "Apply rounded corners in desktop apps for Windows 11"
  guidance:
  - XAML: Background="Transparent" → Background="{DynamicResource AppBg}".
    Corners blend on Win10 fallback.
  - Code-behind: DwmSetWindowAttribute(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE=33,
    DWMWCP_ROUND=2, sizeof(int)) in OnSourceInitialized. Win11 rounds the
    OS-level window edge; Win10 is a silent no-op.
- Corner-radius spec deviation 12 → 8 px (Radius.Lg). The DWM API only
  supports DWMWCP_ROUND (~8 px) or DWMWCP_ROUNDSMALL (~4 px) — no path to
  custom 12 px without AllowsTransparency=True (loses Mica + reintroduces
  the cutout bug). 8 px is also MainWindow's curvature → both surfaces share
  one canonical radius via the Radius.Lg token. APP_DESIGN §4.1 updated;
  full rationale in §13.7.

Model download pinned + verified
- Replaced models.json placeholders (TODO_PIN commit + TODO_FILL SHAs) with
  real values fetched from HuggingFace's tree API:
    commit  = 5359861c739e955e79d9a303bcbc70fb988958b1 (2024-10-29)
    sha256  = LFS digests pulled per file from /api/models/.../tree/<commit>
    sizeBytes = corrected to HF's actual sizes (placeholder bytes were
                slightly off, would have shown wrong progress-bar totals)
- 5 models wired: ggml-tiny.en (77.7 MB · bundled), ggml-base.en (148 MB),
  ggml-small.en (488 MB), ggml-medium.en (1.53 GB), ggml-large-v3 (3.10 GB).
- Models tab Download button now hits real URLs → HuggingFace serves the
  .bin → ModelManager verifies SHA-256 against the manifest entry → File.Move
  to %LOCALAPPDATA%\KusPus\models\. Behaviour matches TECH_SPEC §18.

Build: 0/0. Tests: 167/167. Smoke: clean launch + Sentry init OK.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…scription

P0 mic-always-on bug
- Root cause: SelectTab's StartAudioMeter/StopAudioMeter only ran on tab
  switches. Closing the Preferences window with X (hide-instead-of-close per
  §3.1) left the WasapiCapture open → mic icon stayed in the system tray
  indefinitely.
- Fix: hooked Window.IsVisibleChanged. When IsVisible=false → StopAudioMeter().
  When IsVisible=true AND Audio tab is currently showing → StartAudioMeter()
  to resume.
- Also added StopAudioMeter() to the OnClosing _allowClose path so app exit
  releases the mic too. StopAudioMeter now resets meter visuals (fill width +
  peak tick opacity) so a paused meter doesn't show stale levels on resume.

● LIVE indicator (privacy affordance — UI UX Pro Max progressive-disclosure)
- Small mint dot + LIVE eyebrow shown next to "Microphone level" only while
  the WasapiCapture is open. Toggled in StartAudioMeter / StopAudioMeter.

Test transcription — fully functional (restored from W1 placeholder)
- State machine: Idle → Recording (5 s countdown) → Transcribing (spinner) →
  Result (transcript shown inline) or Error (red message + Retry).
- Single button doubles as Cancel mid-flight (CancellationTokenSource).
- Mic contention handled: StopAudioMeter() before AudioRecorder.StartAsync;
  StartAudioMeter() resumes after completion / cancellation IF window is
  still visible AND Audio tab is still showing.
- MainWindow constructor now takes IAudioRecorder + IWhisperRunner (added to
  the App.xaml.cs DI wire-up). The active model is resolved via
  IModelManager.Resolve before the mic opens — fast-fail if the model is
  missing.
- Result text rendered in a SurfaceInput-tinted Border with BodySmall
  typography; error text overrides Foreground to ErrorRed.
- Temp WAV from AudioRecorder.StopAsync deleted after transcription
  (best-effort; IOException swallowed).
- CA1001 suppression added to MainWindow with rationale (mirrors App's
  suppression — Window owns its lifecycle, _testCts disposed in OnClosing).

Build: 0/0. Tests: 167/167. Smoke: clean launch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
History tab — hover-revealed row actions
- Per UI UX Pro Max convention for productivity data tables (Gmail / Notion /
  Linear pattern): on row hover, the model + duration cells are replaced
  with Copy + Delete Btn.IconGhost buttons. No permanently-visible button
  clutter in the read-heavy table.
- Border MouseEnter / MouseLeave toggles the action StackPanel Visibility;
  Background=Surface paints over the model+duration columns when shown.
- Right-click ContextMenu retained as the keyboard / power-user path. Both
  paths now route through shared helpers CopyTranscriptToClipboard +
  DeleteTranscriptAsync, eliminating duplicated try/catch blocks.
- Icons: Segoe Fluent Icons "Copy" (E8C8) + "Delete" (E74D). Delete icon
  tinted ErrorRed.

Models tab — radio buttons replaced with state-driven action CTAs
- New row layout: 4 px left-edge accent strip + title row (name + Bundled +
  ACTIVE badge if applicable) + state-driven button on the right.
- Five visual states per UI UX Pro Max state-clarity rule:
    Active        — MintTint card bg + mint accent + ACTIVE badge, no button
                    (action already performed — primary-action rule).
    Installed     — neutral card, no accent, "Use this model" Btn.Primary.
    Not installed — neutral card, no accent, "Download" Btn.Secondary
                    (heavier commitment than primary).
    Downloading   — neutral card, mint accent, progress + percent + Cancel
                    Btn.Ghost (existing BuildModelDownloadingRegion reused).
    Failed        — neutral card, red accent, error text + Retry Btn.Secondary
                    (existing BuildModelErrorRegion reused).
- ACTIVE badge: small mint-tinted Border with dark "ACTIVE" text — pulls the
  user's eye to the in-use model at a glance.
- Dead code removed: OnModelRadioChecked (radio gone), BuildModelStatusRegion
  (replaced by BuildModelActionRegion). BuildActiveBadge marked static
  (CA1822 compliance).

Build: 0/0. Tests: 167/167. Smoke: clean launch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-facing: a styled ComboBox sits next to the Microphone row in the Audio
tab. First entry is "Default device (follows Windows)"; remaining entries are
every active capture endpoint enumerated via MMDeviceEnumerator. Selection
persists as Audio.InputDeviceId in settings.json and takes effect immediately
for the live meter, Test transcription, and live dictation.

Wiring (no new layer dependencies):
- IAudioRecorder gains SetInputDeviceId(string?). AudioRecorder holds the
  preferred id in a volatile field. StartAsync now goes through a
  ResolveCaptureDevice helper: look up the preferred id; if it's missing /
  inactive / not a capture endpoint, log a warning and fall back to the OS
  default. KusPus.Audio still doesn't reference KusPus.Persistence.
- App.OnStartup pushes the initial id from PrefsStore + subscribes to
  PrefsStore.Changes to propagate further updates. Composition-root pattern.
- MainWindow's level meter (separate WasapiCapture from AudioRecorder) gets
  the same ResolveLevelMeterDevice helper so the meter shows the picked
  device's levels, not the OS default's. Restarts on selection change.

UI (Styles/Inputs.xaml + MainWindow.xaml + .xaml.cs):
- New ComboBox.Surface style — SurfaceInput bg + BorderStrong border + 7 px
  radius matching the SegmentButton wrapper aesthetic. Fully restyled
  ToggleButton template (Fluent Icons chevron) and Popup template (dark/
  light-themed Surface + DropShadowEffect) so the default WPF chrome doesn't
  leak through. Items use MintTint for the selected row + HoverSubtle for
  hover, matching the rest of the design system.
- AudioDeviceTitle TextBlock removed; replaced by the ComboBox + a new
  AudioDeviceSubtitle that doubles as the error surface for "no mic" / "mic
  busy" states (writes to subtitle instead of overwriting the title).
- PopulateInputDeviceCombo runs on tab open + every dropdown open — cheap
  enumeration picks up hot-plug USB mics without restarting the app. Combo
  selection matched to the persisted preference; falls back to "Default"
  silently if the saved id is no longer present.

Build: 0/0. Tests: 167/167. Smoke: clean launch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…n re-enum)

Two root causes per Microsoft Learn "Optimize control performance" +
dotnet/wpf#9881:

1. DropShadowEffect on the Popup's inner Border (BlurRadius=14) was the
   dominant cost — every dropdown open triggered a per-pixel blur pass.
   Removed; replaced with the existing BorderStrong stroke + Surface tint
   which read as elevation without the GPU work.
2. MainWindow.OnInputDeviceDropDownOpened was re-enumerating MMDevices via
   MMDeviceEnumerator.EnumerateAudioEndPoints on every open — a Win32 COM
   round-trip + a full ItemsSource rebuild + a visual-tree teardown. Removed
   the handler. Population now happens ONCE when the Audio tab opens
   (already wired). Hot-plugged devices appear on next tab visit, which is
   an acceptable trade-off vs the 150 ms perceptual lag every open.

Belt-and-suspenders: ComboBox.Surface now declares VirtualizingStackPanel
as its ItemsPanel + IsVirtualizing=True + VirtualizationMode=Recycling.
Negligible for 5-10 mics but bombproof if someone has 20+ capture devices.

Build: 0/0. Smoke: clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tons

Restructures the floating pill per the Organic Pill spec (Phase 1 — chrome
only; halo, hue-drift, breath, hover-visualizer arrive in Phases 2-4).

Geometry — dynamic window size
- Collapsed: 200×56 (pill only).
- Open / pinned: 320×78 (pill 320 wide + 22 px dock peek). Mica stays tight
  to the visible chrome so the area around the pill doesn't render a
  rectangular Mica frame. Window animates both Width and Height on hover.
- Pill anchor stays on base width 200 so the position math (multi-monitor
  sticky, bottom-center default) doesn't drift center on expand.

New chrome
- Pin button (top-right corner of pill, 18×18). Hidden by default at -12°
  rotation. On pill hover: fades in + rotates to 0° (180 ms / 220 ms). Click
  toggles "pinned" — dock + corner buttons stay visible after the cursor
  leaves, glyph + bg tint to mint.
- Magic-wand button (top-right, left of Pin, 18×18). Dormant — ToolTip
  "Refine text", no Click handler. We will wire it next iteration.
- Dock drawer (22 px row below the pill, slides down + fades in on hover).
  Background matches the pill so the two read as one continuous chrome.
  Border CornerRadius=0,0,8,8 to share the pill's bottom rounding.

Dock contents (left → right)
- Record toggle (22×18). Red dot glyph. Click currently logs a TODO — the
  real wire-up needs a public AppCoordinator.ToggleTapMode() that doesn't
  exist yet; the hotkey chord remains the canonical entry point for v1.
- Mic chooser (flex-grow). [mic icon] [device name] [chevron-down] on a
  subtle button bg. Click opens a real popup picker — a styled <Popup>
  containing a ScrollViewer + a StackPanel of per-device <Button>s. Click a
  device → SetInputDeviceIdAsync via the bridge → popup closes → label
  updates. Mint-tinted selected item.
- Settings (22×18). Fluent gear, opens Preferences (existing wire).
- Dismiss (22×18). Fluent X, red hover bg, calls _onClose → Shutdown.

Layer-friendly bridges
- FloatingPillWindow defines two tiny interfaces (IPrefsStoreBridge,
  IAudioRecorderBridge) and a SetBridges(prefs, audio) hook. App.xaml.cs
  implements them via PrefsStoreBridge (wraps IPrefsStore for the device id
  get/set) and AudioDeviceBridge (calls MMDeviceEnumerator). Keeps
  KusPus.App as the only layer that knows about both Persistence and NAudio.

Removed
- Old side-only hover-extend (ButtonPanel + AnimateWidth/AnimateButtonPanel).
  Replaced by the dock drawer + corner-button animation pair.

Build: 0/0. Tests: 167/167. Smoke: clean launch + pill transitions to Idle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Default idle (no hover) — unchanged: SVG voice-stack icon + "KusPus" wordmark,
just like today. The pill reads as a tiny brand mark when the user isn't
intentionally interacting with it.

On hover (still Idle) — swap to:
  - 20-bar visualizer running the low-amplitude traveling-sine motion model
    from the Organic Pill §3 idle-visualizer cue: amplitude 0.06-0.14,
    per-bar phase offset 0.18 rad, damping k≈3.5/s, full traversal every
    ~2.4 s. Quiet and slow enough to disappear from peripheral vision.
  - Label "IDLE · HOLD TO DICTATE" replaces "RECORDING" in the same slot.

State + hover form an orthogonal grid:
  (Idle, !hover)     → IdleContent (SVG + KusPus)         · viz Off
  (Idle, hover)      → VisualizerContent (bars + IDLE)   · viz HoverIdle
  (Recording, *)     → VisualizerContent (bars + RECORDING) · viz Recording
  (other states, *)  → that state's panel                · viz Off

Refactor
- RecordingContent renamed to VisualizerContent (now serves both Recording
  and HoverIdle modes — same Canvas, label swaps).
- New VisualizerLabel x:Name so the label text can change per mode.
- FadeContent + new ApplyIdleContent + small static FadeElement helper:
  TransitionTo delegates idle-content rendering to ApplyIdleContent, which
  re-evaluates IsMouseOver every time it's called.
- OnPillMouseEnter / OnPillMouseLeave call ApplyIdleContent so the swap
  happens on every hover transition while in Idle.
- New VisualizerMode enum (Off / Recording / HoverIdle). OnVisualizerTick
  switches motion math by mode — HoverIdle runs the sine wave; Recording
  keeps the existing voice-envelope target-rolling; Off targets all bars
  to 0.05 (silent).

Build: 0/0. Tests: 167/167. Smoke: clean launch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Personality animations
- Breath: ±0.6% scale pulse on PillSurface via ScaleTransform, 4 s sine cycle
  (2 s in + 2 s out, AutoReverse + RepeatForever, SineEase). Subtle enough
  to disappear from peripheral vision — gives the pill a "living organism"
  presence without intruding.
- Hue drift: AccentBrush's middle gradient stop cycles mint #4DDBA6 →
  seafoam #4DCDC2 → soft cyan #4DB8DB → back over 14 s, constant R=0x4D
  band so perceived brightness stays flat (manual approximation of the
  spec's OKLCH constant-L=0.84/C=0.14 constraint; WPF has no native OKLCH).
- Both wired as long-lived Storyboards (built once on Loaded, Begin/Stop
  via SetReduceAnimations) so toggling is cheap.

Deferred to follow-up
- Halo: needs a backbuffer larger than the pill bounds — incompatible with
  the current Mica setup (Mica would paint a rectangular tint around the
  halo area). Decision point: keep Mica + skip halo, OR drop Mica for
  AllowsTransparency=true + custom translucent gradient.
- Heartbeat blink: depends on accent-line opacity which is state-driven
  (TransitionTo sets it per state). Multiplying onto state-driven base
  needs a layered opacity model — deferred until heartbeat semantics are
  pinned down.

Accessibility toggle (new Settings.Privacy.ReducePillAnimations field)
- New Accessibility section in Privacy tab: "Reduce pill animations" Toggle.
  Default off. Saves to settings.json on flip.
- App.UpdatePillReduceAnimations combines the user toggle with
  SystemParameters.ClientAreaAnimation — if either says reduce, pill pauses
  personality animations (state transitions + dock slide remain active).
- Initial state applied at startup + on every PrefsStore.Changes emit.

Build: 0/0. Tests: 167/167. Smoke: clean launch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per user audit feedback that the bars should echo the icon.svg's pearly-
mint gradient.

Dark theme: unchanged — solid #EBFFFFFF SolidColorBrush (the historical
token). A mint gradient over a dark pill surface would lose the
visualizer's "voice on top" reading.

Light theme: three-stop vertical LinearGradientBrush, alpha climbs top→
bottom so each bar reads as "lit from below":
  0.0  →  #664DDBA6  (subtle mint, 40% alpha)
  0.5  →  #994DDBA6  (mid mint, 60% alpha)
  1.0  →  #CC1F8762  (deeper mint, 80% alpha — bottom anchors)

Implementation: VisualizerBarActive is removed from the ThemeTokens.Map
dictionary and installed via a dedicated BuildVisualizerBarActive(mode)
helper alongside the existing BuildPillSurfaceGradient. ThemeTokens.Apply
now calls both special-case builders after the simple-Color-pair loop.

The bars in FloatingPillWindow use SetResourceReference for their Fill, so
the swap fires on theme flip with no other code touching needed.

Build: 0/0. Smoke: clean launch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… theming, inset)

1. Center-expand on hover — Width + Left animate together (Left -= ΔW/2)
   so the pill grows symmetrically instead of right-only.
2. Height recovery on dock close — DoubleAnimations use FillBehavior.Stop
   and on Completed call BeginAnimation(prop, null) + SetValue(prop, to),
   freeing the animated values so the pill collapses cleanly with no black
   gap underneath.
3. Mic picker now design-system styled — Popup uses Surface/BorderStrong
   tokens with a 4-px-padded ScrollViewer (PanningMode=VerticalOnly,
   HorizontalScrollBarVisibility=Disabled). Item template adds a hover
   trigger that paints HoverSubtle on each row.
4. Picker pins the dock open — _pickerOpen flag gates OnPillMouseLeave so
   the dock stays open while the picker popup is open; OnMicChooserPopupClosed
   restores normal hover behavior afterward.
5. Light-theme pill carries the icon's mint — BuildPillSurfaceGradient
   light stops shift from #F8F8FA/#EEEEEF2 to #F4F8F4/#E0F0E6 (subtle top
   shift + slightly mintier bottom), echoing icon.svg's pearl-to-mint
   gradient without changing dark-theme look.
6. Dock visually narrower than pill — DockDrawer carries Margin="24,0,24,0"
   so it reads as a nested sub-element instead of a flush continuation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1. Black strips beside dock — DockDrawer.Margin removed. The pill window is
   AllowsTransparency=False because Mica (DWMWA_SYSTEMBACKDROP_TYPE) requires
   it, so any inset between the dock and the window edge renders opaque
   window-background black instead of click-through. The prior 24px margin
   was the "narrower than pill" aesthetic from the last batch — reverting it
   here since the side-effect (black strips) is worse than the cohesive look.

2. Pill mic-picker lag — cache the device list in FloatingPillWindow. On
   SetBridges we warm the cache via Task.Run + Dispatcher.BeginInvoke; each
   subsequent OnMicChooserClick reads from cache (instant) and fires a
   background RefreshMicCacheAsync so hot-plugged devices appear on next open.
   Same root cause as the audio-tab combo lag fixed in f4d2413: MMDeviceEnumerator
   .EnumerateAudioEndPoints is a synchronous Win32 COM round-trip (~150ms).
   UpdateMicChooserLabel uses the same cache fall-through.

3. Audio tab loading lag — OpenAudioTabAsync runs the heavy init off the
   dispatcher. EnumerateInputDeviceItems (COM) and the WasapiCapture
   ctor (driver shared-mode negotiation, ~150-500ms on some hardware) both
   await Task.Run, then the combo's ItemsSource is set + StartRecording
   fires on the UI thread. The Audio panel paints immediately; the device
   combo + LIVE meter populate as each piece completes.

   Surface kept stable: synchronous StartAudioMeter() façade still exists
   so the 3 non-tab-open callers (visibility change, mid-test resume,
   device-change restart) read unchanged.

Build: 0/0. Smoke: pill places + hook installs cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-spec rewire of the pin semantics. Previously "latch dock open"; now
"compact mode" — clicking pin contracts the pill back to 200×56, slides
the dock back, but keeps the pin button visible at all times so the user
can unpin.

Behavior matrix:

  Pinned OFF (default):
    hover     → expand 200→320, slide dock down, fade pin+wand in
    leave     → contract, slide dock up, fade pin+wand out
    pin click → enter pinned + contract immediately (if already expanded)

  Pinned ON:
    hover     → swap SVG+wordmark → visualizer+IDLE label (NO resize, NO dock)
    leave     → swap visualizer → SVG+wordmark (NO resize, NO dock)
    pin click → exit pinned; if currently hovered, expand back to hover view
    pin button stays visible the entire time (mint-tinted)

Implementation:
- OnPinClick — inverted: becoming pinned calls CloseDock; becoming unpinned
  + hovered calls OpenDock. Unpinned + not-hovered stays put.
- OnPillMouseEnter/Leave — gate OpenDock/CloseDock on !_isPinned so hover
  doesn't trigger the expand/contract while pinned. ApplyIdleContent still
  runs in both branches so the content swap (SVG ↔ visualizer) works.
- AnimateCornerButtons — effectiveVisible = visible || _isPinned. Keeps
  the pin button at opacity=1 and angle=0 while pinned regardless of what
  the caller asked for.

Plus: idle KusPus wordmark now Mint instead of MutedText — picks up the
brand accent so the resting pill carries the product's color cue.

Build: 0/0. Smoke: pill places + hook installs clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three landed-together changes for the tray + pill experience.

1. Pill record-toggle wired (Cluster A)
   - SetRecordToggleAction(Action) on FloatingPillWindow; App binds to
     AppCoordinator.ToggleFromTray. Per user spec the toggle does NOT
     auto-capture a foreground target — the post-transcribe paste lands
     wherever focus happens to be at the time.
   - On toggle-start a RecordNudgePopup balloon appears above the RecordButton
     ("Click into your text field") for 6s. Auto-dismisses when state moves
     to Recording. Previous 3s window was too short to read — user feedback.
   - RecordGlyph changed from Ellipse to Rectangle that morphs dot ↔ rounded
     square depending on FSM state.

2. Custom WPF tray right-click menu (Cluster B)
   Replaces WinForms ContextMenuStrip with TrayMenuWindow.xaml — a
   borderless, transparent, design-system-styled popup matching
   Tray_light.png / Tray_dark.png:
   - KusPus header with state-aware "Version 1.0.0 · {Idle|Recording|Transcribing}"
   - Toggle recorder row with hotkey keycap (live-bound to PrefsStore.Hotkey)
   - Active model: <name> row with chevron, opens models tab
   - Preferences… opens general tab
   - History… opens history tab
   - Quit in ErrorRed
   Shows at cursor on NotifyIcon.MouseClick (right). Closes on Deactivated
   (focus lost) or any item click. WS_EX_TOOLWINDOW so it's hidden from
   Alt-Tab/taskbar.

3. State-aware tray icons (Cluster C)
   icons/icon-{idle,recording,error}.svg generated to .ico via tools/IconBuilder.
   Recording overlays a red dot + glow on the bars; Error overlays a red
   warning triangle. TrayManager subscribes to AppCoordinator.State and
   swaps NotifyIcon.Icon based on the FSM state (treating a failed PostPaste
   snapshot as Error for its hold duration). All three .ico files are
   Resources in KusPus.App.csproj.

Build: 0/0. Smoke: pill places + tray icon visible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three concerns folded into one commit because they share the pill XAML/code-behind:

1. UX pass (per user spec):
   - Pill record button + tray menu both labelled "Toggle Recording [BETA]"
     (verb-form + dogfood expectation-setting). Tray chip is mint-coloured.
   - Magic wand is dormant — rendered at 0.35 opacity with Arrow cursor +
     tooltip "Refine text — coming soon" so the disabled state is legible
     visually, not just in the tooltip.
   - Pin semantics extended: now also locks the pill's screen position. Drag
     short-circuits when _isPinned. Drag cursor (SizeAll) flips to Arrow when
     pinned so the lock is telegraphed.
   - Added CompactRecordButton at the pill's top-LEFT corner, visible only
     when pinned. Pinned mode hides the dock, so without this the user would
     have to unpin just to record. Sits opposite Pin/Wand on the right for
     visual balance.

2. Nudge bug fix (the 6s timer was a red herring — TransitionTo's
   "dismiss-on-Recording" rule was firing within ~ms of click since the FSM
   moves to Recording immediately after ToggleFromTray. Dropped that rule;
   timer bumped 6s→10s as the sole dismissal path. Comment explains why.

3. Full UX audit pass (10 items from the 2026-05-17 self-audit):
   - #1 CompactRecord 22×18 r=5 → 18×18 r=4 (matches Pin/Wand cluster)
   - #2 Design-system icon size tokens added to Styles/Tokens.xaml:
        Icon.Glyph=11, Icon.Chevron=9. Bound on Wand, Pin, Settings, Close,
        MicChooser icon (was 10), MicChooser chevron (was 8).
   - #3 MicChooser hover: Opacity=1.4 (silent no-op — WPF clamps at 1) →
        Background=SurfaceElevated. Real, theme-aware lift.
   - #4 Error text margin 6→8 px (matches Idle/Transcribing rhythm)
   - #5 Dock vertical centering moved to parent Grid (Margin=6,2,6,2);
        mic chooser drops its per-button vertical compensation.
   - #6 Wand opacity 0.5→0.35 (clearer subordinate read)
   - #7 Dropped the 1px PillInnerHighlight — only existed on the pill, not
        the dock, creating a seam at the drawer junction.
   - #8 PillSurface Cursor=SizeAll at rest (telegraphs drag), Arrow when pinned
   - #9 Drop shadow: Direction=270 Depth=2 Blur=32 Op=0.45 →
        Depth=0 Blur=14 Op=0.25. Omnidirectional soft halo, no dock bleed.
   - #10 All chrome gutters standardized at 6 px (was 5 on corners, 6 on dock).

Build: 0/0. Smoke: clean (verified locally with real recording + paste).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three changes from the 2026-05-17 dogfood batch:

1. Default theme flipped "auto" → "dark" (AppSettings.UiSettings.Theme).
   Light theme is still in beta polish, so new installs land on the polished
   dark surface. DefaultSettingsTests assertion updated with rationale.

2. Preferences theme picker: "Light" → "Light [BETA]" with tooltip explaining
   the beta state. Sets dogfood expectations that light surfaces may not be
   fully tuned yet.

3. Onboarding step 6 (Try it) replaced fake SimulatedSentences random-pick
   with a real IAudioRecorder + IWhisperRunner pipeline. 5 s countdown
   recording → transcribe with active model → render actual transcript (or
   error if mic/model missing). Mirrors the existing Test Transcription
   pattern from the Audio tab. Threaded audio/whisper/models services through
   the OnboardingWindow constructor + both call sites (App.OnStartup +
   MainWindow.OnRerunOnboarding).

Prior behaviour was misleading — onboarding "tested" dictation by picking a
canned sentence from a hard-coded list, so a broken mic / missing model
didn't surface until after onboarding finished. Now the failure modes
surface during setup where the user can act on them.

Build: 0/0. Core/Persistence/Whisper/Audio test suites all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User dogfood feedback (2026-05-17) asked for continuous "speak-pause-paste"
loop on top of the existing push-to-talk model. Researched 3 architectures
(sliding-window, chunk-on-VAD, library-binding); recommended chunk-on-VAD
+ second hotkey ("Option B") to keep the existing UX intact while giving
power users opt-in long-mode dictation.

Deferred to v1.2 per user choice — ~2 weeks build + 1 week dogfood, too
large for the current pre-v1 polish window. Entry captures full 8-cluster
plan, top-3 risks (hallucination on silence, mid-word VAD cuts, paste-into-
wrong-app race), realistic latency (~0.7-1 s per pause with tiny.en), and
the rejected-for-now soft-cap alternative.

Also clarified LT-07 (streaming partial results) as a distinct UX
hypothesis — sliding-window for visible live caption in the pill, NOT a
paste pipeline. Different architecture from R1.2-10; both can ship in
principle but R1.2-10 lands first because it answers a real dogfood ask.

Per CLAUDE.md, this edit to docs/ROADMAP.md is authorized — user
explicitly said "keep it in the roadmap for later versions".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User dogfood ask: let the user pick their mic during onboarding (not just
see the meter for the OS default), and persist that choice until they
change it from Preferences.

Step 3 gets an OnbInputDeviceCombo above the live meter card. It writes
to PrefsStore.Audio.InputDeviceId — the same field Preferences → Audio
uses — so the selection survives onboarding-exit and stays put until the
user changes it from either surface. ResolveOnbMicDevice mirrors
MainWindow.ResolveLevelMeterDevice: looks up by saved id, falls back to
the OS default if the device is unplugged. SelectionChanged restarts the
meter capture so the user sees the level for whichever mic they just
picked.

No shared base class with MainWindow's combo — onboarding is short-lived
and a single helper would pull in more ceremony than it removes. Logic
is a faithful mirror; if a future refactor extracts a shared
HotkeyPickerControl / InputDevicePickerControl UserControl, this and the
MainWindow combo + the Audio-tab one would all collapse to one consumer.

Also updated CLAUDE.md "Deviations" with 11 new entries covering this
session's UX work (pin = compact mode + position lock, BETA labels,
tray menu redesign + state-aware icons, dark default theme, real
onboarding dictation, mic chooser in onboarding, icon-size tokens,
shadow softening, mic-chooser hover fix, roadmap R1.2-10 entry).

Build: 0/0. Smoke: clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-marshalling bug

Four bugs / behaviors corrected in one session of dogfood feedback.

1. Skip now marks Completed=true (was: Completed=false). Onboarding modal
   opens once-ever per install. Closing via Skip is honoured the same as
   Finish — modal does not re-appear on next launch. Re-runnable via
   About → "Run again". Prior "skip-on-skip keeps Completed=false" rule
   was hostile (the user just wanted to dismiss); replaced with show-once-
   ever.

2. Pill is now invisible while onboarding is open. Bind() / BindLevels()
   moved out of App.OnStartup inline construction and into a new
   BindPillAndShow() helper that runs AFTER ShowDialog() returns (or
   immediately if no onboarding). The first BehaviorSubject snapshot
   subscribes to coordinator.State which triggers the pill's FadePillIn
   → Show(), so deferring Bind is what hides the pill. Existing users
   (Completed=true) get pill instantly; new users get pill after Finish/Skip.

3. Step 3 mic now loads async (mirrors MainWindow.OpenAudioTabAsync).
   New OpenMicStepAsync orchestrator: page paints immediately with
   "Loading microphones…" placeholder + "LOADING…" label; MMDevice enum
   and WasapiCapture init run on Task.Run; UI populates when ready.
   Previously the entire dispatcher blocked for ~250 ms on first step 3
   entry (driver shared-mode negotiation).

4. Cross-apartment MMDevice access bug (fix-of-fix). The first async pass
   returned the MMDevice from the Task.Run lambda and then read
   .FriendlyName on the dispatcher — NAudio's IMMDevice doesn't support
   standard COM cross-apartment proxy marshalling, so the property getter
   threw InvalidCastException → E_NOINTERFACE. That landed in
   UnobservedTaskException (silent) and the user saw "Microphone blocked"
   even though nothing was using the mic. Fix: read FriendlyName INSIDE
   the Task.Run lambda (MTA where the device was created), return only
   the string + WasapiCapture across the await. MMDevice never crosses
   thread boundaries. WasapiCapture is fine cross-thread because it
   caches its WaveFormat internally before its ctor returns — that's
   why MainWindow.OpenAudioTabAsync (which only returns the capture)
   never had this bug.

   Validated from the live log:
     System.InvalidCastException: Unable to cast COM object ...
     to interface type IMMDevice ... E_NOINTERFACE
     at NAudio.CoreAudioApi.MMDevice.GetPropertyInformation
     at OnboardingWindow.StartMicCheckAsync()  line 641

   Also broadened the Task.Run catch from
     COMException + MmException
   to general Exception, since NAudio's WasapiCapture can throw a wider
   set (InvalidOperationException on busy device, ArgumentException on
   malformed format, etc.). Added an outer try/catch on OpenMicStepAsync
   so any unhandled error surfaces as ShowMicError instead of silent
   stuck-Loading.

Build: 0/0. Cross-apartment fix validated by code trace + matched
against MainWindow.OpenAudioTabAsync (which doesn't return MMDevice
across threads and works correctly).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The four social icons (LinkedIn, X, GitHub, Portfolio-globe) all use a
14×14 Viewbox wrapping 24×24 vector content. LinkedIn/X/GitHub paths
fill their viewBox edge-to-edge (0–24 on both axes), so they render at
the full 14×14 visual size. The globe was drawn in a 24×24 Canvas with
the ellipse at (2,2) W=20 H=20 plus a stroke=2 outline — that left 2 px
of padding around the geometry, so the globe rendered at ~20/24 ≈ 83%
of the other icons' visual size.

Fix: expand the geometry to fill the full 24×24 box.
  Ellipse: (1,1) W=22 H=22 + stroke=2 → visible ink spans 0–24.
  Meridian arc: radius 14.5 → 15.95 (×22/20 scale factor); endpoints
    move from y=2/22 to y=1/23.
  Equator line: M2 12 h20 → M1 12 h22.

All four icons now render at the same effective 14×14 visual size. No
aspect-ratio change — geometry preserved, only the bounds expanded.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
devangk003 and others added 6 commits May 17, 2026 17:09
Four small UX tweaks from dogfood feedback (2026-05-17).

1. CompactRecord glyph (the corner record button visible while pinned) is
   now grey (MutedText) when idle, red (#EF5350) when recording. Previously
   it was always red — looked like "recording in progress" even at rest.
   Grey reads as "available, tap to start"; red reserved for active state.

2. CompactRecord glyph bumped 8×8 → 10×10 (RadiusX 4 → 5 idle, 1.5 → 2
   recording). The visible footprint now roughly matches the pin glyph's
   ascent at FontSize=Icon.Glyph (11), so left/right corner clusters look
   visually balanced. Button itself stays 18×18 with the same 6 px margin
   from the pill edge as the pin StackPanel — positions were already
   symmetric; the parity issue was glyph size.

3. UpdateRecordGlyph now swaps CompactRecord.Fill on state change (grey
   ↔ red) in addition to the existing radius morph. Dock RecordGlyph
   stays always-red (it's the dock's record identifier; grey would lose
   its affordance).

4. Nudge timer 10 s → 2 s. The "Click into your text field" hint is now
   a brief flash, not a lingering popup. User feedback: 10 s sat there
   long after they had already moved on.

5. About-tab social icons: wrapped each Path in a fixed-size 24×24 Canvas
   so the Viewbox uses the canvas bounds (always 24×24) rather than the
   path's computed bbox. Path bboxes vary subtly — GitHub's "M12 .297"
   start offset, Bezier control points extending past the visible curve,
   X's 0.258 left edge — which caused uneven rendered sizes when Viewbox
   uniformly stretched each to 14×14. With the Canvas wrapper, all four
   (LinkedIn, X, GitHub, Globe) are guaranteed to render at exactly the
   same effective visual size.

Build: 0/0. Smoke: clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two surgical fixes.

1. About-tab tagline changed from "Press a hotkey. Speak. Get pasted."
   to "Local Privacy First" — direct product-pillar wording per user
   ask. Same Type.HintItalic style, same position; just the string.

2. PillSurface.CornerRadius drops from 8 → (8,8,0,0) when the dock
   slides into view, and back to 8 when the dock slides away. The
   pill's bottom edge is flat while the dock is visible, so the seam
   between pill bottom and dock top (which has CornerRadius=0,0,8,8)
   reads as one continuous shape instead of two stacked rounded
   rectangles with visible "ears" at the seam.

   Implementation: the corner-radius swap lives inside OpenDock() and
   CloseDock(). OnPillMouseEnter/Leave + OnPinClick already gate
   OpenDock/CloseDock on !_isPinned (pinned mode uses content-swap
   without expanding), so pinned compact-mode never enters OpenDock
   and the pill keeps its full 8 px rounded corners — exactly the
   "no corner-radius changes in pinned state" constraint.

   Snap (not animate) since WPF's CornerRadius isn't a natively
   animatable DependencyProperty. The snap happens at the START of
   each method so the bottom edge is flat the full time the dock is
   becoming visible (OpenDock case) and the round-back happens just
   as the dock starts going away (CloseDock case — the brief
   round-bottom-over-still-visible-dock artifact is during the
   subordinate "going away" animation).

   No XAML changes to the PillSurface element; the default
   CornerRadius="8" stays as the initial / fully-collapsed value.

Build: 0/0. Smoke: clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1. Dock RecordGlyph now follows the same state pattern as the
   CompactRecordGlyph: MutedText (grey) when idle, #EF5350 (red) when
   actively recording. Previously the dock glyph was always red, which
   communicated "recording in progress" at rest. Grey reads as "available,
   tap to start"; red reserved for active state. The same brush is
   shared between both glyphs in UpdateRecordGlyph so they stay in sync.

2. About-tab tagline gets a middle-dot separator: "Local Privacy First"
   → "Local · Privacy First". Matches the existing rhythm of the line
   above it ("MIT licensed · Local-first · No telemetry.") which uses
   the same · separator.

Build: 0/0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UX Pro Max two-layer accent so the search bar reads as prominent without
dominating the row data below:

1. Magnifier glyph foreground swapped from MutedText → Mint. Low-key
   persistent brand cue. Subtle enough that it doesn't shout, clear
   enough that it telegraphs "this is the brand-aligned action."

2. Search-bar bottom border swapped from 1 px BorderSubtle → 2 px Mint.
   The element doubles as both the prominent brand accent and the
   structural divider above the table header — one element, two jobs,
   no extra chrome added.

Considered + rejected:
- Mint background fill: would compete with row data
- Mint border around the whole search bar: reads as alert/error state
- Mint left stripe accent: layers an extra "important" affordance that
  competes with the divider role

Matches the Material 3 / iOS HIG search-bar-with-brand-accent pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ions

Catches the docs up to all 6 commits since the last log update
(03853e7…0ce194c) plus the earlier work in this session that landed
before the previous CLAUDE.md update.

CLAUDE.md — 10 new entries under "Late-afternoon dogfood pass
(2026-05-17)" covering: Skip=Completed semantics, deferred pill bind,
step 3 async load, the cross-apartment IMMDevice marshalling bug,
social-icon Canvas wrappers, both record glyphs sharing brush state,
nudge 2 s, pill bottom-corner squared while dock open, About tagline
+ history search mint accent, Icon.Glyph/Icon.Chevron tokens restated.

docs/PILL_DESIGN.md — new §11 "Dogfood-driven evolution (2026-05-17)"
with 10 subsections: geometry footprint, pin = compact-mode +
position-lock, Idle visual, tap-mode toggle [BETA], bottom
corner-radius behaviour, magic wand placeholder, shadow softening,
inner highlight removed, personality animations, multi-monitor sticky.

docs/APP_DESIGN.md — new "Dogfood-driven design updates" section
covering default theme dark, custom tray menu (replaces §5.2
ContextMenuStrip), state-aware tray icons, onboarding step 6 real
dictation, step 3 mic chooser, "show once ever" onboarding gate,
About tagline + social icons, history search mint accent, Icon.*
size tokens.

PRD.md / TECH_SPEC.md left untouched — most changes are UX evolution
within the existing scope/architecture; CLAUDE.md flags the few items
that need owner review. ROADMAP.md is already current.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…elsDir (#2)

* Phase 12 Cluster 1: rewrite whisper payload script as download-prebuilt

Replaces the build-from-source CMake/MSVC flow with a download from
ggerganov/whisper.cpp's GitHub release. Picked over build-from-source
because (a) no local toolchain required, (b) ~3 s vs ~5 min iteration,
(c) upstream releases ship GGML_NATIVE=OFF (portable x86-64-v2) already,
(d) the upstream binary carries MotW reputation from GitHub's signed
release flow, reducing Defender false-positive risk per Phase 12 research
2026-05-17.

Pinned to v1.8.4 — the most recent tag at/before our submodule's
v1.8.4-323-g968eebe7 commit. Override via -Tag for future bumps.

Mechanics:
- Downloads whisper-bin-x64.zip from the GitHub release URL
- Caches per-tag in .local-temp/whisper-cache/ (skip re-download on
  re-runs unless -Force)
- Extracts to a tag-scoped subdirectory so multiple tags can coexist
  in the cache
- Wipes + repopulates installer/payload/whisper/ with:
    whisper.exe (renamed from whisper-cli.exe / main.exe depending on tag)
    *.dll (SDL2, ggml*, whisper)
    SHA256SUMS (consumed at runtime by WhisperRunner.ExpectedWhisperSha256)
    .tag (idempotency marker — skips re-run if -Tag matches)
- Smoke-tests whisper.exe -h, accepting exit 0 (whisper-cli) or 1
  (legacy main.exe — exits 1 for unknown -h flag but proves DLLs
  loaded). Hard DLL-load crashes show up as large negative exit codes
  and DO fail the gate.
- Resets $LASTEXITCODE to 0 after smoke test so script's own exit code
  is clean for CI gating (PS otherwise inherits the last native exit
  code as its own).

Build-from-source flow lives in git history at the prior version of
this file — `git show HEAD~1:tools/build-whisper-windows.ps1` recovers
it if needed for audit / verification.

The installer/payload/whisper/ contents (whisper.exe, *.dll, SHA256SUMS,
.tag) are gitignored per installer/payload/ rule — output is regenerated
on each release build.

Validated against 2025 best-practices research:
  - Per-tag pinned download ✓
  - Upstream signed release source (preserves MotW) ✓
  - Runtime SHA verification (no install-time check) ✓
  - Handles whisper-cli.exe (v1.7+) and main.exe (legacy) naming ✓

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Phase 12 Cluster 2: Inno installer + WPF publish profile + IL3000 fix

Three coupled artifacts.

1. installer/KusPus.iss — Inno Setup 6.x script per 2025 best-practices
   research. Per-user install (PrivilegesRequired=lowest, no UAC),
   {autopf}\KusPus = %LOCALAPPDATA%\Programs\KusPus, ArchitecturesAllowed=
   x64compatible (covers x64 + ARM64 emulation), MinVersion=10.0.17763
   (Win10 1809 — earliest version where DWM rounded-corner / immersive-
   dark-mode attributes are honoured), LZMA2/max + solid + separate-
   process compression, fixed AppId GUID
   ({7E263B33-A253-4E7D-B1A1-1B9D29405A02}) for upgrade detection across
   versions. AppVersion via #define so iscc can be invoked with
   /DAppVersion=v1.0.0 from CI. Opt-in Desktop shortcut (unchecked by
   default). [UninstallRun] taskkills the running app before delete so
   no locked-DLL failures. INTENTIONALLY no [UninstallDelete] on user
   data paths (%APPDATA%\KusPus, %LOCALAPPDATA%\KusPus subdirs) — testers
   often reinstall, preserving settings + history + downloaded models
   avoids the "lost my dictation history" surprise.

2. src/KusPus.App/Properties/PublishProfiles/win-x64.pubxml — release
   publish profile. Invoke with
     dotnet publish src/KusPus.App -p:PublishProfile=win-x64 -o publish/win-x64
   Produces 86 MB self-contained single-file KusPus.exe + 5 small
   dependency PDBs (~90 KB total). Properties:
     SelfContained=true                no .NET runtime install required
     PublishSingleFile=true            one launcher .exe
     IncludeNativeLibrariesForSelfExtract=true   pack runtime native DLLs
     EnableCompressionInSingleFile=true          ~30-40% smaller payload
     PublishReadyToRun=true            warmer cold-start
     PublishTrimmed=false              WPF reflection breaks under trim
     DebugType=embedded                Sentry-friendly symbol embedding
     RuntimeIdentifier=win-x64         x64 only per PRD non-goal

   Lives in a PublishProfile (not the csproj) so dev builds
   (`dotnet build`, `dotnet test`) stay lean — putting SelfContained=true
   in the csproj forces every `dotnet build` to materialise the full
   200+ MB self-contained runtime at bin/Debug/net10.0-windows/win-x64/.

3. src/KusPus.App/KusPus.App.csproj — added EnableSingleFileAnalyzer=true
   so the IL3000-family analyzers fire on `dotnet build` (catches
   single-file-incompatible API usage at compile time, not at runtime
   after publish). Caught the next issue immediately.

4. src/KusPus.App/MainWindow.xaml.cs:189-200 — Assembly.Location →
   AppContext.BaseDirectory. About-tab build-date readout used
   Assembly.GetExecutingAssembly().Location which IL3000 flags as
   returning empty in single-file apps (it always returns "" for
   embedded assemblies). Fixed to read mtime of AppContext.BaseDirectory
   \KusPus.exe (the launcher) with a graceful "—" fallback if the file
   doesn't exist (unit test hosts). Previously a single-file release
   build would have shown "Built  · dev build" with two spaces.

Validation:
  - dotnet build → 70 files at bin/Debug/net10.0-windows/, no win-x64
    subdir, no self-contained runtime (lean)
  - dotnet publish -p:PublishProfile=win-x64 -o publish/win-x64 →
    86 MB KusPus.exe + 5 .pdb files
  - Published EXE launches cleanly (single-file extract + R2R + native
    libs all working)
  - iscc.exe validation deferred to Cluster 3+4 (CI runner has Inno;
    local doesn't)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Phase 12 Clusters 3+4: CI + Release workflows

Replaces both placeholder workflows (which exited 1 with "not implemented")
with real implementations per 2025 best-practices research (2026-05-17).

.github/workflows/ci.yml (push: main, pull_request):
  - windows-latest, .NET 10
  - Checkout (no submodules — Phase 12 downloads whisper.cpp prebuilt)
  - NuGet cache keyed on csproj + Directory.Build.props
  - dotnet restore → build (TreatWarningsAsErrors via repo Directory.Build.props)
    → test → upload TRX results as artifact
  - concurrency.cancel-in-progress=true (latest commit wins)
  - permissions.contents=read (least-privilege)

.github/workflows/release.yml (push: tag v*):
  - windows-latest, .NET 10
  - Checkout with fetch-depth=0 for release-notes generation
  - NuGet cache + restore + Release-config test (release blocker on regression)
  - tools/build-whisper-windows.ps1 → installer/payload/whisper/
  - dotnet publish -p:PublishProfile=win-x64 -o publish/win-x64
    (self-contained single-file ~86 MB)
  - Minionguyjpro/Inno-Setup-Action@v1.2.5 installs Inno Setup 6 AND
    compiles installer/KusPus.iss with /DAppVersion=${tag}.
    Single step for both jobs — research-recommended approach.
    Critical: windows-latest migrated to Windows Server 2025 in Sept
    2025; Inno is no longer pre-installed (actions/runner-images#12464).
    The pre-2025 workflows that just called iscc.exe directly silently
    broke at that migration; this action prevents that regression.
  - Verify installer exists at expected path + log SHA-256
  - softprops/action-gh-release@v2 publishes as DRAFT with
    auto-generated release notes + MotW unblock + Smart App Control
    instructions baked into the release body. Friends-only audience
    means the author smoke-tests the produced setup.exe on a clean VM
    before flipping draft → published via the GitHub UI.
  - concurrency.cancel-in-progress=false (never abort a release halfway)
  - permissions.contents=write (needed to create the GitHub Release)

Third-party action pinning: actions/{checkout,setup-dotnet,cache} pinned
to major version (@v5/@v4) — official GitHub actions. Third-party
(Minionguyjpro, softprops) pinned to release tag (@v1.2.5/@v2) with a
TODO to swap to commit SHA + add Dependabot for auto-bumps in a Phase 12+
follow-up.

Validation: both YAMLs are tab-free (GitHub Actions tab = parse error)
and structurally clean. Real-CI validation deferred to first push.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Phase 12 Cluster 5: SHA propagation from SHA256SUMS to runtime check

Closes the loop on the WhisperRunner integrity check. Previously
AppPaths.ExpectedWhisperSha256 only read KUSPUS_WHISPER_SHA256 from the
environment, which was an empty string on every dev + release build —
the runtime check was effectively a no-op everywhere. Per the running
deviation log: "empty expectedWhisperSha256 now skips the integrity
check (dev mode). Phase 12 release builds populate the SHA from
installer/payload/whisper/SHA256SUMS."

Implementation:

1. KusPus.App.csproj — new EmitWhisperShaConstant MSBuild target running
   BeforeTargets="BeforeCompile;CoreCompile":
     - Reads installer/payload/whisper/SHA256SUMS via
       File::ReadAllText (only if it exists)
     - Regex-extracts the whisper.exe line's hash:
         ([0-9a-f]{64})\s+whisper\.exe
     - Writes obj/$(Configuration)/$(TargetFramework)/WhisperSha.g.cs
       with:
         namespace KusPus.App;
         internal static class BuildConstants {
             public const string ExpectedWhisperSha256 = "<sha>";
         }
     - Adds the generated file to @(Compile) FROM INSIDE the target
       (not via an outer <ItemGroup> Compile Include) so it bypasses
       the parse-time Exists() chicken-and-egg that would have
       Condition'd the file out on the first clean build before the
       target ran.
   No Inputs/Outputs on the target — runs every build so the @(Compile)
   item is reliably added even on incremental builds. WriteLinesToFile
   itself is a fast no-op when content matches, so per-build cost is
   negligible.

2. AppPaths.ExpectedWhisperSha256 — three-step resolution:
     a) KUSPUS_WHISPER_SHA256 env override (debug-only escape hatch)
     b) BuildConstants.ExpectedWhisperSha256 (build-time)
     c) Empty string fallback (unit tests, hosts where the type isn't
        compiled in)

Validation:
  - SHA256SUMS present (v1.8.4 payload) → constant =
    4833684778081ec8c9f47975f71eb31c1d3724410751a6dc850d6787f3a23b3d
    (whisper.exe hash from the v1.8.4 prebuilt release)
  - SHA256SUMS absent → constant = "" (dev-mode skip preserved;
    WhisperRunner sees empty expectedSha and short-circuits the check
    per src/KusPus.Whisper/WhisperRunner.cs)

Phase 12 code is now complete (Clusters 1-5). Cluster 6 (manual
milestone smoke per PRD §11.3 M-01..M-37) is yours to walk on a
real Windows install.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Inno installer: fix invalid RestartApplicationsIfNeeded directive

The first CI release build (v0.1.0-rc1) failed at the Inno compile step:

  Error on line 91 in D:\a\kuspus\kuspus\installer\KusPus.iss:
    Unrecognized [Setup] section directive "RestartApplicationsIfNeeded"
  Compile aborted.

I invented that name during Phase 12 Cluster 2 — there's no such directive
in Inno Setup. The actual directive is RestartApplications=yes/no (default
yes). We want it off because (a) no in-process update flow yet,
(b) an installer-spawned launch wouldn't pick up the new files cleanly in
self-contained-single-file mode anyway.

Fix: rename RestartApplicationsIfNeeded → RestartApplications.

Validated against the Inno Setup 6 ISHelp directive list. Other directives
in the file (CloseApplications, PrivilegesRequiredOverridesAllowed,
ArchitecturesAllowed=x64compatible, MinVersion, LZMAUseSeparateProcess)
all check out.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* build-whisper-windows.ps1: prefer whisper-cli.exe over deprecation-stub main.exe

Root cause of "whisper.exe exited with code 1 (empty stderr)" smoke
failure on installed v0.1.0-rc1:

whisper.cpp v1.8.x ships TWO binaries in the Windows release zip:
  - whisper-cli.exe — the real CLI (~28 KB launcher + DLLs)
  - main.exe        — a deprecation stub that prints
                      "The binary 'whisper.exe' is deprecated.
                       Please use 'whisper-whisper.exe' instead."
                      and exits 1, with the message going to stdout
                      (so KusPus's "stderr preview" was empty).

The previous picker used:
  Where-Object { $_.Name -in @('whisper-cli.exe', 'main.exe') } |
  Select-Object -First 1

which gave whichever appeared first in the enumeration — `main.exe` for
v1.8.4. We renamed that stub to whisper.exe, shipped it, and KusPus tried
to transcribe with it. Whisper "exited code 1" because the stub literally
always exits 1. No model was ever loaded.

Fix: prefer whisper-cli.exe; fall back to main.exe only when
whisper-cli.exe is genuinely absent (pre-v1.7 releases).

New whisper.exe SHA: d4c598cf97de103f888d1a53b8abddc85bf27ab752f785ca69318cedc8a2cf64
(replaces 4833684778... which was main.exe's hash).

Also: switched the smoke test from `& $exe -h 2>$null` to Start-Process
with redirect-to-temp-file. Reason: PowerShell 5.1 wraps native-command
stderr as ErrorRecords which trips $ErrorActionPreference='Stop' even
when the exe exits 0 — Start-Process bypasses that wrapping by going
through Process.Start directly. Worked fine on CI (pwsh / PS 7) but
broke local dev re-runs in Windows PowerShell. Now shell-version-
agnostic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* rc3 diagnostic: log AppPaths + ModelManager.Resolve internals on failure

Pure-diagnostic build. No behaviour change. Adds:

1. App.OnStartup logs all AppPaths values + Directory.Exists for each:
     SettingsDir, LocalDataDir, ModelsDir, LogsDir, WhisperDir
   So we can see what the installed single-file app actually resolves
   for SpecialFolder.LocalApplicationData vs what dev / PowerShell sees.

2. ModelManager.Resolve, when File.Exists returns false, now logs:
     - Raw _modelsDirectory string
     - Directory.Exists(_modelsDirectory)
     - The constructed file path
     - File.Exists(path) (the failing check)
     - The directory's actual contents via Directory.GetFiles
   Isolates path-construction vs file-access vs sandbox-redirect causes.

Background: rc1 (installed earlier today) successfully resolved
ggml-base.en at C:\Users\kumaw\AppData\Local\KusPus\models\ggml-base.en.bin
at 18:50:33. rc2 (installed after the whisper-cli fix) reports
"Model file missing on disk" at the same path at 19:03:39. PowerShell,
[System.IO.File]::Exists, and Test-Path all say the file is there with
matching SHA-256 and Everyone-FullControl ACL. No Mark-of-the-Web.
Nothing in the code between rc1 and rc2 touched path resolution — only
tools/build-whisper-windows.ps1 was modified.

The diagnostic output from rc3 will pinpoint whether:
  - The path string itself differs (encoding / sandboxing)
  - Directory.Exists is true but File.Exists is false (unlikely .NET bug)
  - Directory enumeration shows different files than what's actually there
    (AV / Defender / SmartScreen blocking specific files)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* rc4: move ModelsDir to install dir (CFA fix) + bundle tiny.en in installer

Two coupled fixes shipping together because they're the same architectural
move — get model files out of %LOCALAPPDATA%\KusPus\ (CFA-sensitive) and
into the install directory (CFA-trusted).

1. AppPaths.ModelsDir: %LOCALAPPDATA%\KusPus\models → {app}\whisper\models

   Root cause from rc3 diagnostic logs (commit 8a54e45):
     [WRN] Resolve diagnostic — couldn't enumerate
       C:\Users\kumaw\AppData\Local\KusPus\models

   Windows Defender Controlled Folder Access (CFA) silently blocks
   unsigned binaries from listing files in user-data folders even when
   the ACL grants the user FullControl. Directory.Exists returns true,
   File.Exists returns false, Directory.GetFiles throws
   UnauthorizedAccessException. PowerShell can read the dir (Microsoft-
   signed binary, CFA trusts it). Unsigned KusPus.exe cannot.

   CFA almost never blocks an app from reading its OWN install directory.
   Moving ModelsDir to {app}\whisper\models sidesteps the issue without
   per-machine CFA whitelisting. KUSPUS_MODELS_DIR env-var override
   stays for tests/portable layouts.

   No migration: existing dogfooders re-download tiny.en (~30 s) and
   base.en (~1 min). The old %LOCALAPPDATA%\KusPus\models\ stays orphaned
   for manual cleanup — explicit user choice, "no migration" branch.

2. tools/build-whisper-windows.ps1 now bundles tiny.en in the installer.

   PRD §6.4 calls for tiny.en bundled pre-installed so first-launch works
   offline. The 14-line .iss stub never actually shipped any .bin file
   despite models.json claiming bundled=true — the "Bundled" UI badge was
   a lie since rc1. Users on a clean machine hit the Models tab, saw
   "Bundled" + a download button + an I/O error when HF rate-limited.

   The script now:
     - Reads URL + expected SHA from src/KusPus.Whisper/Resources/models.json
       (single source of truth — same manifest the runtime ModelManager
       verifies against)
     - Downloads ggml-tiny.en.bin into .local-temp/model-cache/ keyed by
       SHA so successful downloads are reused
     - Verifies actual SHA matches manifest before placing in payload
     - Copies to installer/payload/whisper/models/ggml-tiny.en.bin
     - SHA256SUMS lines now use relative paths (whisper.exe at root,
       models/ggml-tiny.en.bin in subdir) so the manifest stays
       build-machine-independent

   installer/KusPus.iss [Files] adds:
     Source: "payload\whisper\models\*.bin"; DestDir: "{app}\whisper\models";
       Flags: ignoreversion uninsneveruninstall

   uninsneveruninstall keeps the file across reinstalls — Inno wouldn't
   delete user-downloaded .bin files anyway (not in [Files]), but this
   explicitly preserves tiny.en too.

   Installer size impact: ~80 MB → ~155 MB. Acceptable for friends-only.
   First-launch UX after this: hotkey → speak → transcript pastes,
   ZERO downloads required for English dictation.

3. Removed rc3 diagnostic logging from App.OnStartup + ModelManager.Resolve.

   The diagnostic served its purpose (caught the CFA blocking pattern).
   Per user request: "make sure the code is clean so it is not jargoned
   or contains old code for this fix." Reverted to pre-rc3 versions.

4. Docs aligned with new ModelsDir location:
   - CLAUDE.md: 12 new deviation entries covering all Phase 12 work
     (rc1 main.exe stub bug, PS5.1 smoke fix, KusPus.iss creation,
     PublishProfile, IL3000 fix, EmitWhisperShaConstant target, CI/release
     workflows, ModelsDir move, bundled tiny.en).
   - docs/TECH_SPEC.md §18: updated download path reference with link to
     CLAUDE.md deviation.
   - docs/PRD.md §10 data-flow table: model files row updated.

   Per CLAUDE.md hard rule, these edits to PRD/TECH_SPEC are authorized
   — user explicitly said "update other docs also".

Validation:
  - dotnet build: 0/0
  - tools/build-whisper-windows.ps1: completes clean, downloads + verifies
    tiny.en, places at correct path. EXIT: 0.
  - installer/payload/whisper/models/ggml-tiny.en.bin exists at 75 MB
    with SHA 921e4cf8686fdd993dcd081a5da5b6c365bfde1162e72b08d75ac75289920b1f
    matching models.json.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@devangk003

Copy link
Copy Markdown
Owner Author

@copilot resolve the merge conflicts in this pull request
The files in feat/ui-fixes are what I want as it is

Co-authored-by: devangk003 <107274680+devangk003@users.noreply.github.com>

Copilot AI commented May 17, 2026

Copy link
Copy Markdown
Contributor

@copilot resolve the merge conflicts in this pull request
The files in feat/ui-fixes are what I want as it is

Done — I resolved the PR merge conflicts by merging main and keeping the feat/ui-fixes versions for conflicted files as requested. Commit: 76e3305. No UI files were modified in content as part of this conflict-resolution merge, so there is no visual delta screenshot.

@devangk003 devangk003 merged commit cbaee51 into main May 17, 2026
1 check passed
devangk003 added a commit that referenced this pull request May 17, 2026
* fix(hotkey): don't consume LWin keyup — kept Win stuck-down in OS state

Consuming the LWin keyup left Windows thinking Win was still held, so
PasteEngine's SendInput(Ctrl+V) read as Win+Ctrl+V (Action Center / Quick
Settings) and every subsequent keystroke became a Win+key system shortcut.
The Ctrl-tap injection (AHK #MenuMaskKey idiom) still runs to suppress the
Start menu; we just let the real LWin keyup reach the OS so its key-held
state clears.

Spec §13 prescribes the old (buggy) behavior; flagged for revision in
CLAUDE.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Phase 8 pill polish: full PILL_DESIGN.md + drag + multi-monitor sticky

Surface (docs/PILL_DESIGN.md §1, §3):
- 200×56 with 8 px DWM-rounded corners, Mica backdrop on Win11 22H2+
  (DWMWA_SYSTEMBACKDROP_TYPE = DWMSBT_TRANSIENTWINDOW), dark-tinted via
  DWMWA_USE_IMMERSIVE_DARK_MODE. Falls back to the §3.1 dark gradient
  on older Windows.
- §3.3 1 px hairline border + drop shadow + inner top highlight.

Five-state machine (§2):
- Recording: 20-bar visualizer + RECORDING micro-label.
- Transcribing: 14 px ¾-arc spinner (0.9 s loop, rotated via direct
  BeginAnimation on the RotateTransform) + "Transcribing…" text.
- Confirmed: "Pasted into <App>" with the bold app name, 1 s hold.
- Error: 5 px red dot + reason text, 2 s hold, instant accent shift to
  red (§5).
- Idle: PRD G4 dev override — pill stays visible between dictations
  showing the app icon + "KusPus" label. Will revert to spec §6.1
  hidden-when-not-in-use once Settings exposes the close path.

Visualizer (§4):
- 20 bars × 3 px wide × 4 px gap × 4–26 px tall (136 px track).
- Damped target/value motion model per §4.2: center-weighted speak
  envelope, per-bar damp rates, real audio levels from IAudioRecorder
  override the simulation when present. Runs on CompositionTarget.
  Rendering for display-refresh smoothness.

Accent line (§3.4):
- 136 × 1.5 mint gradient with glow, opacity per state.

Motion (§5):
- 120 ms pill appear/disappear, 150 ms content crossfade between
  states. Cubic easing.

Hover-extend override (PILL_DESIGN.md §10):
- Width animates 200 → 280 over 150 ms on hover, Settings + Close
  buttons fade in. WS_EX_TRANSPARENT intentionally NOT applied
  (overrides §1.2 click-through) so buttons work; WS_EX_NOACTIVATE
  preserved so focus doesn't move.

Draggable pill (beyond spec):
- MouseLeftButtonDown anywhere on the pill body → DragMove. Skips
  when click is on a Button (Settings / Close).
- Session-only per-monitor remembered positions via
  Dictionary<deviceName, Point> keyed by MONITORINFOEX.szDevice.
  Cleared on every fresh process start.

Multi-monitor option C (hybrid sticky):
- On state transition into Armed/Recording, jump to the foreground
  window's monitor at its remembered position (or default
  bottom-center if first time). No-op when pill is already on the
  right monitor or while user is dragging.

Coordinator snapshot extension:
- CoordinatorSnapshot.PostPaste:PostPasteInfo carries (Pasted,
  TargetApp, ErrorReason). AppCoordinator emits one post-paste
  snapshot from DeliverAsync / HandleFailureAsync so the pill knows
  whether to show Confirmed or Error.

Icon pipeline:
- tools/IconBuilder: one-shot SVG → multi-frame ICO converter.
- icons/icon.svg as the single source of truth. ViewBox tightened to
  "272 246 480 480" so the 5-bar content fills ~94 % of every
  rendered frame (tray, taskbar, Task Manager, .exe icon).
- icons/icon.ico regenerated, embedded as ApplicationIcon + WPF
  Resource. TrayManager loads via pack URI.
- SharpVectors.Wpf 1.8.5 renders the SVG directly inside the pill
  Idle state — no hand-converted XAML to drift from the source.

Deferred from spec (not blockers):
- §3.2 light theme + WM_SETTINGCHANGE live switching.
- §3.4 accent variants beyond Mint (needs Settings UI from Phase 9).
- §5.3 reduced-motion gating of fades.
- Win10 acrylic fallback.

Tests: 117/117 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Phase 9: MainWindow + 6 tabs + theming + pill flips with theme

Surface:
- New MainWindow per docs/APP_DESIGN.md §3. 880×620 (820×600 min) system-
  chromed window. Sidebar nav (6 RadioButton tabs styled per §3.2 — mint
  stripe + elevated bg on select). Close hides (§3.1 / §8.5); only the
  tray's Quit fully exits. Tray menu gains "Preferences…" → MainWindow.
- Dark title-bar tint via DwmSetWindowAttribute DWMWA_USE_IMMERSIVE_DARK_MODE
  + SetWindowPos(SWP_FRAMECHANGED) to force the non-client repaint on
  runtime theme flips (validated against Microsoft Q&A "DWMWA_USE_
  IMMERSIVE_DARK_MODE won't update").

Six tabs (§3.3):
- General: Hotkey hero card with live listen-mode rebind (suspends LL hook,
  captures held-keys snapshot, commits on full release, conflict warning
  against known Windows shortcuts), Startup toggle → HKCU\Run, Appearance
  Auto/Light/Dark segmented control.
- Audio: device label + Discord-style 200×6 track+fill meter (validated
  fix against naudio/NAudio#160 #347 #507 — MMDevice.AudioMeterInformation
  reports zero without an active capture session, so we open a WasapiCapture
  and compute peak from samples in DataAvailable). Peak-hold tick that
  decays slower than fill.
- Models: active-model row + manifest list with install state (file
  existence per device), radio-select writes ActiveModelId to PrefsStore;
  download wiring deferred to Phase 11.
- History: last 50 transcripts via IHistoryStore.SearchAsync; status dot
  (mint = ok, red = failed), relative time, app name, model + duration.
- Privacy: offline + crash-reports toggles to PrefsStore, logs path +
  Open in Explorer, local-first mint promise card.
- About: 80px brand mark + version line (AssemblyInformationalVersion) +
  Cascadia-Mono build line, Resources card group (GitHub link + logs +
  Re-run onboarding placeholder), MIT/local-first license blurb.

Theming infrastructure:
- ThemeApply (new) resolves "auto"/"light"/"dark" against AppsUseLightTheme
  registry, applies DWM dark-mode + SWP_FRAMECHANGED.
- ThemeTokens (new) — 23-entry map of (dark, light) Color pairs covering
  AppBg, Sidebar, Surface, SurfaceElevated, BorderSubtle/Strong/Divider,
  Primary/Secondary/Muted/DisabledText, HoverSubtle, KeycapBg/Border,
  Mint/MintTint/MintBorder, ErrorRed, WarningAmber/Tint/Border, plus
  pill-specific PillBorder/PillInnerHighlight/VisualizerBarActive/Idle
  and MeterTrack/ButtonHoverBg. Plus a LinearGradientBrush builder for
  the pill's two-stop surface gradient.
- ThemeTokens.Apply uses REPLACEMENT (not mutation) — WPF freezes
  Freezable resources in Application.Resources (x:Shared semantics) so
  brush.Color mutation throws InvalidOperationException at startup.
  Replacement fires ResourcesChanged; every {DynamicResource} consumer
  re-resolves.
- MainWindow.xaml + FloatingPillWindow.xaml refactored end-to-end to
  use {DynamicResource Token} for every brush/foreground/border. Code-
  built UI (Models rows, History rows, hotkey keycaps) uses a Theme(key)
  helper that returns the current resource brush; theme changes re-
  render visible dynamic tabs so they pull fresh brushes.
- Pill visualizer bars use SetResourceReference(FillProperty) instead
  of a frozen explicit brush so bars re-theme on switch.

Deviations from spec — flagged in CLAUDE.md (no MainWindow.xaml hex
literals migrated; everything is now token-based). Body theming for
the pill required new PillSurface gradient resource installed per-
theme. Multiple WPF parse-time gotchas worked around: IsChecked="True"
on TabGeneral triggers Checked event before content panels are bound
to fields — fixed with a _loaded guard in OnTabChecked.

Tests: 117/117 pass.

docs/APP_DESIGN.md: new authoritative UI spec from the user, referenced
from CLAUDE.md source-of-truth list. Where it conflicts with PILL_DESIGN
§2.1 (click-through) the §10 hover-extend override still wins.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Phase 10: Onboarding modal — 7 steps + first-launch trigger

OnboardingWindow per docs/APP_DESIGN.md §4. 720×520 chromeless rounded card,
12 px corners, draggable from the progress-dots header. Theme-aware via the
same ThemeApply + DynamicResource brushes that flip MainWindow / pill.

Seven steps (linear nav with Back / Next / Skip / Finish):
- 1 Welcome: stylized desktop preview + 3-column value-prop grid.
- 2 Hotkey: listen-mode + capture + commit to PrefsStore.Hotkey, with the
  same Win+L-class conflict warning. Duplicates MainWindow's listen-mode
  state machine (extract to UserControl when there's a third consumer).
- 3 Mic check: WasapiCapture on default device, Discord-style track+fill
  meter, success/error variants, Open Settings → ms-settings:privacy-microphone.
- 4 Autostart: clickable ToggleCard → HKCU\Run via AutostartRegistry.Set.
- 5 Crash reports: ToggleCard + Local-first promise card.
- 6 Try it: 120-DIP transcript surface, "Simulate dictation" runs 1.8 s
  Listening… then surfaces one of three canned sentences with mint border.
- 7 Done: corner-of-screen tray-diagram + Finish.

Finish path writes PrefsStore.Onboarding.Completed = true. Skip / Esc /
window-close leave Completed = false so the modal re-pops on next launch.
Re-runnable any time from About → "Run again" (replaces the disabled
Phase 9 placeholder button).

First-launch trigger in App.OnStartup: after _coordinator.Start(), queue
ShowDialog at DispatcherPriority.Background so OnStartup returns before
the modal's nested dispatcher frame begins. Pill + coordinator already
running by then, so the modal's hotkey picker can suspend the live LL
hook cleanly.

Deferred to a polish cluster: §4.1 dimmed-desktop backdrop, hotkey-picker
UserControl extraction, value-card hover states.

Tests: 117/117 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Phase 11 + UX audit W1: crash reporter, egress killswitch, sidebar binding

Phase 11 (TECH_SPEC §19, PRD §10.2/§10.3)
- CrashReporter: Sentry init/shutdown gated on (CrashReportsOptIn && !OfflineMode).
  Embeds project DSN (env-var override) and routes Sentry's own transport through
  EgressAllowlistHandler so PRD §10.2 holds for SDK uploads too.
- EgressAllowlistHandler + EgressPolicy: v1 allowlist. Accepts huggingface.co and
  regional Sentry ingest hosts (*.ingest[.<region>].sentry.io); Offline Mode and
  non-HTTPS block everything. Pure policy decision in Core, IO handler in App.
- CrashScrubber: drops events whose Tags/Extra contain transcript/clipboard/text/
  password/target_app/hwnd keys; replaces %TEMP%, %LOCALAPPDATA%, %APPDATA%,
  %USERPROFILE% prefixes (start-anchored) and Environment.UserName occurrences
  (mid-string) in messages, exception text, stack-frame paths, breadcrumbs.
- 30 new unit tests (167/167 total).

UI/UX W1 — "stop lying" (APP_DESIGN §13 audit findings)
- Crash Reports toggle visually disables when Offline Mode is on; subtitle swaps
  to "Disabled while Offline Mode is on." The toggle's IsChecked is preserved.
- Replace stale "Phase X" + Win32 jargon copy across General/Audio/Models/About.
- Remove permanently-disabled "Test transcription" section; restore in W3 when wired.
- Sidebar footer bound live: status label from AppCoordinator snapshots, chord
  glyph from PrefsStore (compact short form: "Ctrl+Win", not unicode soup).
- Inline [ESC] keycap in hotkey-listen hint (APP_DESIGN §13.4).
- Audit findings appended to docs/APP_DESIGN.md as §13 with a progress ledger.

Build: 0/0. Tests: 157 + 10 new CrashScrubber + extended EgressPolicy = 167/167.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* UX audit W2: design-system extraction (typography, dot, button, focus, spacing)

APP_DESIGN.md §13.5 P1-6/-7/-1/-9/-8/-P2-5. No user-visible scope change —
this is pure styling consolidation. Five new shared style sets, four
inline-styled buttons replaced, one fragile negative-margin layout fixed.

New: src/KusPus.App/Styles/
- Typography.xaml — SectionHeader, Type.RowTitle, Type.RowSubtitle, Type.Eyebrow,
  Type.MonoSm, Type.MonoXs, Type.Display, Type.WarningEmphasis. Replaces ~25
  inline TextBlock FontFamily / FontSize / FontWeight / Foreground quadruplets
  across MainWindow.xaml.
- Dot.xaml — Dot.Mint / Dot.Amber / Dot.Red ellipse styles with the spec's
  7 px + 6 px coloured glow. Replaces 4 hand-rolled ellipses in MainWindow.xaml
  + 1 code-behind ellipse in the history row renderer.
- Buttons.xaml — Btn.Primary / Secondary / Ghost / Danger × Sm / Md / Lg per
  APP_DESIGN §3.4. All buttons now share one template (border + content + hover
  opacity ladder + disabled-state); inline Padding / Background / Foreground /
  BorderThickness gone from every call site.
- Focus.xaml — Focus.Mint: 1.5 px mint dashed outline at 2 px inset. Wired into
  SidebarTab, Toggle, SegmentButton, and Btn.Base — keyboard-nav now has a
  visible focus ring that reads as a design choice rather than the WPF default
  dotted-Aero ring.

Modified: src/KusPus.App/MainWindow.xaml
- All TextBlock declarations matching repeated patterns use a Style key.
- All Buttons use Btn.Secondary (only kind currently needed; the rest of the
  set arrives when W3's purge / download flows land).
- ConflictRow refactored: wraps HotkeyCard + ConflictRow in a single 440 px
  StackPanel with bottom Margin 28. ConflictRow gets `Margin="0,1,0,0"` (1 px
  gap below HotkeyCard) instead of the previous `Margin="0,-20,0,28"` negative-
  margin tuck. Section gap now lives on the parent, not on each child.

Modified: src/KusPus.App/App.xaml
- Application.Resources now merges the four Styles/*.xaml dictionaries so
  every keyed style is reachable app-wide (including future OnboardingWindow
  reuse).

Build: 0/0. Tests: 167/167 still green. Smoke: clean launch.

P1-10 (per-tab UserControl extraction) deferred to a post-W3 cleanup — doing
it now would mean reshuffling every W3 addition into newly-created files. The
ledger in docs/APP_DESIGN.md §13.5 reflects this.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* UX audit W3: missing UX — history search/purge, log clear, model downloads, contrast

APP_DESIGN.md §13.5 P0-3, P0-5, P1-2, P1-3, P1-4. Ships the four user-facing
gaps the audit flagged. All wired against existing services — no new layers.

P0-3 — DisabledText contrast
- ThemeTokens.cs: dark #5CFFFFFF → #80FFFFFF (~3.0:1 → ~5.1:1 vs AppBg).
- Light #50141414 → #A0141414 (~1.9:1 → ~4.7:1 vs AppBg).
- Both clear WCAG AA normal-text 4.5:1. Used for hotkey hint, model "Not
  installed" label, disabled buttons.

P0-5 — Mic-active disclosure (already in W1)
- The Audio "Live level" subtitle reads "Active only while this tab is open.
  Audio is never recorded." Tracking the ledger to "done" — no code change.

P1-3 — Privacy Logs row
- Two-row card: "Log size · {size}" with Clear logs ghost-danger button, then
  "Log folder · {path}" with Open in Explorer secondary button.
- RefreshLogsSize enumerates LOCALAPPDATA\KusPus\logs\*.log on Loaded.
- OnClearLogsClick confirms via MessageBox (No default), then File.Delete each
  *.log. Today's open log is held by Serilog's FileSink — skipped without
  error, count reported in log.
- FormatBytes handles bytes / KB / MB.

P1-2 — History search + bulk footer
- Search box at top with Segoe Fluent icon, placeholder overlay, clear "×"
  button. 250 ms DispatcherTimer debounces TextChanged → HistoryStore.SearchAsync
  (FTS5 backing). Empty query reverts to "most recent first".
- Footer above 1px divider: live row count, "{n} matches for '…'" when filtered,
  Purge all history Btn.Danger with MessageBox confirm. Calls HistoryStore.PurgeAllAsync.
- PurgeAllButton.IsEnabled gates on row count > 0 || query is not null.

P1-4 — Models download flow
- Per-model state machine in _modelDownloads dictionary keyed by id.
- BuildModelStatusRegion dispatches on state: Active / Installed / Downloading
  (180 × 4 px mint progress bar + percent in mono + Cancel ghost) / Error
  (red message + Retry secondary) / Not installed (Download secondary).
- OnModelDownloadClick: pre-checks OfflineMode (clearer message than letting
  EgressAllowlistHandler throw mid-stream), spawns Task.Run with cancellable
  cts. IProgress<DownloadProgress> marshals to UI thread, throttled to 0.5 %
  steps so the StackPanel rebuild doesn't dominate CPU on a fast link.
- OnModelCancelClick: cts.Cancel(). Completion continuation handles cleanup
  for both cancellation (silent) and failure (sticky error + Retry).
- ShortenDownloadError strips ModelManager's "HTTP error downloading …:" prefix.

Build: 0/0. Tests: 167/167.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* UX follow-up: History as a table · pill Settings → Preferences · spacing rhythm

History tab — table layout (audit follow-up)
- Replace card-list rendering with a Grid-based table: header row in
  Type.Eyebrow style above, 6-column body rows below. Columns: status (14) /
  time (78) / app (110) / transcript (*) / model (72) / duration (52).
- Per the skill's number-tabular rule: time, model, duration use Cascadia
  Mono so columns stay aligned across rows.
- Per truncation-strategy: transcript and app truncate with ellipsis +
  ToolTip exposing the full text on hover. Time column ToolTip shows the
  full timestamp.
- Per gridline-subtle: 1 px BorderDivider between body rows, 1 px
  BorderSubtle between header and body. No row striping.
- Row hover: HoverSubtle background via Style trigger on the HistoryRow
  Border (Cursor=Hand for affordance).
- Right-click context menu per row: "Copy text" (Clipboard.SetText) and
  "Delete" (HistoryStore.DeleteAsync + ReloadHistoryAsync).
- ShortModelId strips "ggml-" prefix to match the sidebar's compact form.
- Failed transcripts: red dot + italic red transcript column; rest of the
  row stays normal so the failure mode reads as one cell, not the row.

Pill Settings button wired to Preferences modal
- Add SetSettingsAction(Action) to FloatingPillWindow, mirroring
  SetCloseAction's pattern.
- App.OnStartup wires it to _mainWindow.ShowOn("general") AFTER MainWindow
  is constructed (the existing pill setup runs before MainWindow exists).
- Tooltip "Settings — coming soon" → "Open Preferences".

Spacing rhythm normalization
- Models tab: list bottom margin 12 → 16 (align with the 8-grid inter-block
  rhythm; "16 = block gap" is now consistent across History search bar and
  Models list).
- About tab: header StackPanel bottom 32 → 28 (matches the section gap
  rhythm used everywhere else, instead of being one-off heavier).
- History footer: top margin 18 → 16 (16 is the canonical block gap).

Build: 0/0. Tests: 167/167. Smoke: clean launch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* UX audit Round 2: tokens, surfaces, expanded typography, History single-card

Parallelised Phase 1 (5 sub-agents wrote disjoint style files concurrently),
then serial Phase 2 sweep of MainWindow + App.xaml + APP_DESIGN.md.

NEW style files (Phase 1, parallel)
- Styles/Tokens.xaml   — Space.xs..xxl + Pad.Tight/Default/Hero + Radius.Sm/Md/Lg
- Styles/Surfaces.xaml — Surface.Default/Hero/Tight/Warning/Mint (5 Border styles)
- Styles/Inputs.xaml   — Input.Search TextBox style
- Styles/Typography.xaml extended — 10 new Type.* roles, dead Type.MonoXs removed
- Styles/Buttons.xaml extended — new Btn.IconGhost; header docs call-site inventory

Phase 2 — App.xaml wires the 3 new dictionaries (Tokens first so others can
reference its tokens), then sweep MainWindow.xaml + MainWindow.xaml.cs:

MainWindow.xaml migrations
- Hotkey card    Border → Surface.Hero (drops inline Padding 22,20 override)
- ConflictRow    Border → Surface.Warning (collapses 5 inline attrs)
- Local-first    Border → Surface.Mint    (collapses 5 inline attrs)
- HotkeyHint        TextBlock → Type.HintItalic
- ConflictText      TextBlock → Type.WarningBody
- AboutVersion      TextBlock → Type.Body
- AboutBuildLine    Margin 0,4,0,0 → 0,3,0,0 (matches Type.RowSubtitle rhythm)
- Local-first head  TextBlock → Type.MintHeadline
- Local-first body  TextBlock → Type.BodySmall
- MIT licensed      TextBlock → Type.Footnote
- "Press a hotkey"  TextBlock → Type.HintItalic
- StatusLabel       TextBlock → Type.SidebarStatus (was Type.MonoSm-with-override misuse)
- History search bar magnifier → Type.IconSm
- History search box  TextBox → Input.Search
- History search clear Button → Btn.IconGhost
- About re-run card    Margin 0,0,0,32 → 0,0,0,28 (matches section gap)
- Sidebar footer Grid  Margin 18,8,18,14 → 14,8,14,14 (matches sidebar 14)

History tab — unified single composed card (Q3 from user audit decisions)
- Outer RowCard Padding="0" wraps a StackPanel of inner Borders.
- Search bar (Padding 14,8, bottom 1 px divider), table header (Padding 14,10,
  bottom 1 px divider), HistoryList (HistoryRow style provides per-row bottom
  divider), bulk footer (Padding 14,12, no top border — last row's bottom
  border IS the separator → no double line).
- Reads as one "history widget" instead of four separately-styled blocks.

MainWindow.xaml.cs code-behind sweep
- New TypeStyle(string) helper (mirrors Theme()) to pull Type.* styles from
  Application.Resources for code-built TextBlocks.
- BuildModelRow title/subtitle → Type.RowTitle / Type.RowSubtitle
- BuildBundledBadge child TextBlock → Type.BadgeMint
- BuildModelDownloadingRegion percent → Type.MonoSm
- BuildModelErrorRegion error text → Type.ErrorInline
- BuildHistoryRow TIME / MODEL / DUR columns → Type.MonoSm (transcript + app
  columns + Installed/Active status stay inline because Foreground flips per
  state — no single Type.* role covers both colour states).
- Empty-state TextBlock in ReloadHistoryAsync → Type.HintItalic
- Dropped unreachable "(none)" branch in RenderModelsTab (audit P2-8).

Docs: APP_DESIGN.md §13.5 ledger updated (P2-8/-9 done, P2-10 won't-fix with
reason); new §13.6 documents the Round 2 work — token system, surface variants,
typography role catalogue, inputs/IconGhost, spacing fixes, History
unification, code-behind cleanup, dead-code policy.

Parallelisation safety
- 5 sub-agents in Phase 1 each owned exactly one file (disjoint writes — no
  lost-update risk). None ran dotnet build (avoids bin/obj corruption). The
  orchestrator does all building serially in Phase 3 after killing any running
  KusPus.exe to avoid output-DLL locks.

Build: 0/0. Tests: 167/167. Smoke: clean launch + Sentry init OK.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* About tab: byline + 4 social icons · Models/About card spacing · 1px→8px

Author byline (About → bottom-right)
- "Made by Devang Kumawat" + LinkedIn / X / GitHub / Portfolio icon row at
  the bottom-right of the About tab, sitting directly on AppBg (no card —
  reads as a personal touch, not part of the design-system surface inventory).
- Text uses Type.Footnote (12 Medium SecondaryText) — matches the existing
  "MIT licensed" line on the left. Per UI UX Pro Max rule weight-hierarchy:
  text carries the byline weight, icons are visually subordinate.
- Each icon: 14×14 Viewbox inside a Btn.IconGhost (28×28 click target).
  Aspect ratio locked by the Viewbox's default Uniform Stretch and the
  underlying 24×24 viewBox.
- Theme tinting: Fill="{DynamicResource MutedText}" (filled paths) or
  Stroke="{DynamicResource MutedText}" (Lucide globe) — no per-theme assets,
  one shared rendering for dark+light.

Icon sources (saved to icons/social/ + LICENSE.md attribution)
- LinkedIn / X / GitHub: Simple Icons (CC0) via jsDelivr simple-icons@v11
  and raw.githubusercontent.com/simple-icons/simple-icons/develop/icons/.
- Portfolio (globe): Lucide (ISC) from raw.githubusercontent.com/lucide-icons/lucide.
- Saved as .svg files for documentation/license tracking; actual rendering
  inlines the path data in MainWindow.xaml so the fill binds to theme tokens
  (SharpVectors SvgViewbox can't easily theme-tint).

Each icon button OpenUrl(...) → Process.Start with UseShellExecute=true.
Single helper handles Win32Exception + FileNotFoundException for missing
default browser without crashing.

Links wired
- LinkedIn → https://www.linkedin.com/in/devangk003/
- X        → https://x.com/devang_kumawat
- GitHub   → https://github.com/devangk003
- Portfolio → https://lnk.bio/devangk003

Spacing (user audit feedback — 1 px stacking felt cramped)
- Models tab BuildModelRow inter-row Margin 1 → 8 (per model row reads as
  its own card now, not a grouped 1-px-divider stack).
- About tab Resources/Log-folder cards Margin 0,0,0,1 → 0,0,0,8. Re-run
  card already at 0,0,0,28 (section gap below it).

Build: 0/0. Tests: 167/167. Smoke: clean launch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Sentry: forward unhandled exceptions to CaptureException

Audit gap closed. Previously the three top-level handlers
(OnUnhandledException / OnDispatcherUnhandled / OnUnobservedTask) only
wrote to Serilog — Sentry's own AppDomain auto-hook fires in parallel but
WPF dispatcher exceptions were swallowed by e.Handled=true before Sentry
could see them. Now each handler logs first, then forwards via the new
TryReportToSentry helper.

TryReportToSentry is gated on _crashReporter?.IsActive so the call no-ops
when the user hasn't opted in (or Offline Mode killed the SDK). The Sentry
call itself is wrapped in try/catch so a Sentry failure can't recurse into
another unhandled exception.

Behaviour summary
- Crash Reports OFF: handlers log locally, no network. Same as before.
- Crash Reports ON, Offline Mode OFF: every unhandled exception (AppDomain,
  WPF dispatcher, unobserved Task) reaches your Sentry EU project with the
  scrubbing pipeline applied.
- Crash Reports ON, Offline Mode ON: CrashReporter shuts the SDK down →
  IsActive==false → handlers skip the Sentry call. Local logging still runs.

Build: 0/0. Tests: 167/167. Smoke: clean launch + Sentry init OK.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Fix: OnboardingWindow black corners + model download pinned to real HF commit

Onboarding rounded corners (APP_DESIGN §13.7)
- Root cause: WindowStyle=None + AllowsTransparency=False + Background=Transparent
  renders the area outside the inner Border's CornerRadius as black. The
  inner <Border CornerRadius=12> shows but the 4 corner triangles around it
  fill with WPF's solid black for "transparent-but-not-actually-transparent".
- Fix per Microsoft's "Apply rounded corners in desktop apps for Windows 11"
  guidance:
  - XAML: Background="Transparent" → Background="{DynamicResource AppBg}".
    Corners blend on Win10 fallback.
  - Code-behind: DwmSetWindowAttribute(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE=33,
    DWMWCP_ROUND=2, sizeof(int)) in OnSourceInitialized. Win11 rounds the
    OS-level window edge; Win10 is a silent no-op.
- Corner-radius spec deviation 12 → 8 px (Radius.Lg). The DWM API only
  supports DWMWCP_ROUND (~8 px) or DWMWCP_ROUNDSMALL (~4 px) — no path to
  custom 12 px without AllowsTransparency=True (loses Mica + reintroduces
  the cutout bug). 8 px is also MainWindow's curvature → both surfaces share
  one canonical radius via the Radius.Lg token. APP_DESIGN §4.1 updated;
  full rationale in §13.7.

Model download pinned + verified
- Replaced models.json placeholders (TODO_PIN commit + TODO_FILL SHAs) with
  real values fetched from HuggingFace's tree API:
    commit  = 5359861c739e955e79d9a303bcbc70fb988958b1 (2024-10-29)
    sha256  = LFS digests pulled per file from /api/models/.../tree/<commit>
    sizeBytes = corrected to HF's actual sizes (placeholder bytes were
                slightly off, would have shown wrong progress-bar totals)
- 5 models wired: ggml-tiny.en (77.7 MB · bundled), ggml-base.en (148 MB),
  ggml-small.en (488 MB), ggml-medium.en (1.53 GB), ggml-large-v3 (3.10 GB).
- Models tab Download button now hits real URLs → HuggingFace serves the
  .bin → ModelManager verifies SHA-256 against the manifest entry → File.Move
  to %LOCALAPPDATA%\KusPus\models\. Behaviour matches TECH_SPEC §18.

Build: 0/0. Tests: 167/167. Smoke: clean launch + Sentry init OK.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Audio tab: fix mic-always-on + add LIVE indicator + restore Test transcription

P0 mic-always-on bug
- Root cause: SelectTab's StartAudioMeter/StopAudioMeter only ran on tab
  switches. Closing the Preferences window with X (hide-instead-of-close per
  §3.1) left the WasapiCapture open → mic icon stayed in the system tray
  indefinitely.
- Fix: hooked Window.IsVisibleChanged. When IsVisible=false → StopAudioMeter().
  When IsVisible=true AND Audio tab is currently showing → StartAudioMeter()
  to resume.
- Also added StopAudioMeter() to the OnClosing _allowClose path so app exit
  releases the mic too. StopAudioMeter now resets meter visuals (fill width +
  peak tick opacity) so a paused meter doesn't show stale levels on resume.

● LIVE indicator (privacy affordance — UI UX Pro Max progressive-disclosure)
- Small mint dot + LIVE eyebrow shown next to "Microphone level" only while
  the WasapiCapture is open. Toggled in StartAudioMeter / StopAudioMeter.

Test transcription — fully functional (restored from W1 placeholder)
- State machine: Idle → Recording (5 s countdown) → Transcribing (spinner) →
  Result (transcript shown inline) or Error (red message + Retry).
- Single button doubles as Cancel mid-flight (CancellationTokenSource).
- Mic contention handled: StopAudioMeter() before AudioRecorder.StartAsync;
  StartAudioMeter() resumes after completion / cancellation IF window is
  still visible AND Audio tab is still showing.
- MainWindow constructor now takes IAudioRecorder + IWhisperRunner (added to
  the App.xaml.cs DI wire-up). The active model is resolved via
  IModelManager.Resolve before the mic opens — fast-fail if the model is
  missing.
- Result text rendered in a SurfaceInput-tinted Border with BodySmall
  typography; error text overrides Foreground to ErrorRed.
- Temp WAV from AudioRecorder.StopAsync deleted after transcription
  (best-effort; IOException swallowed).
- CA1001 suppression added to MainWindow with rationale (mirrors App's
  suppression — Window owns its lifecycle, _testCts disposed in OnClosing).

Build: 0/0. Tests: 167/167. Smoke: clean launch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* History hover-actions + Models redesign (action buttons, no radios)

History tab — hover-revealed row actions
- Per UI UX Pro Max convention for productivity data tables (Gmail / Notion /
  Linear pattern): on row hover, the model + duration cells are replaced
  with Copy + Delete Btn.IconGhost buttons. No permanently-visible button
  clutter in the read-heavy table.
- Border MouseEnter / MouseLeave toggles the action StackPanel Visibility;
  Background=Surface paints over the model+duration columns when shown.
- Right-click ContextMenu retained as the keyboard / power-user path. Both
  paths now route through shared helpers CopyTranscriptToClipboard +
  DeleteTranscriptAsync, eliminating duplicated try/catch blocks.
- Icons: Segoe Fluent Icons "Copy" (E8C8) + "Delete" (E74D). Delete icon
  tinted ErrorRed.

Models tab — radio buttons replaced with state-driven action CTAs
- New row layout: 4 px left-edge accent strip + title row (name + Bundled +
  ACTIVE badge if applicable) + state-driven button on the right.
- Five visual states per UI UX Pro Max state-clarity rule:
    Active        — MintTint card bg + mint accent + ACTIVE badge, no button
                    (action already performed — primary-action rule).
    Installed     — neutral card, no accent, "Use this model" Btn.Primary.
    Not installed — neutral card, no accent, "Download" Btn.Secondary
                    (heavier commitment than primary).
    Downloading   — neutral card, mint accent, progress + percent + Cancel
                    Btn.Ghost (existing BuildModelDownloadingRegion reused).
    Failed        — neutral card, red accent, error text + Retry Btn.Secondary
                    (existing BuildModelErrorRegion reused).
- ACTIVE badge: small mint-tinted Border with dark "ACTIVE" text — pulls the
  user's eye to the in-use model at a glance.
- Dead code removed: OnModelRadioChecked (radio gone), BuildModelStatusRegion
  (replaced by BuildModelActionRegion). BuildActiveBadge marked static
  (CA1822 compliance).

Build: 0/0. Tests: 167/167. Smoke: clean launch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Audio: input-device picker — user-selectable microphone

User-facing: a styled ComboBox sits next to the Microphone row in the Audio
tab. First entry is "Default device (follows Windows)"; remaining entries are
every active capture endpoint enumerated via MMDeviceEnumerator. Selection
persists as Audio.InputDeviceId in settings.json and takes effect immediately
for the live meter, Test transcription, and live dictation.

Wiring (no new layer dependencies):
- IAudioRecorder gains SetInputDeviceId(string?). AudioRecorder holds the
  preferred id in a volatile field. StartAsync now goes through a
  ResolveCaptureDevice helper: look up the preferred id; if it's missing /
  inactive / not a capture endpoint, log a warning and fall back to the OS
  default. KusPus.Audio still doesn't reference KusPus.Persistence.
- App.OnStartup pushes the initial id from PrefsStore + subscribes to
  PrefsStore.Changes to propagate further updates. Composition-root pattern.
- MainWindow's level meter (separate WasapiCapture from AudioRecorder) gets
  the same ResolveLevelMeterDevice helper so the meter shows the picked
  device's levels, not the OS default's. Restarts on selection change.

UI (Styles/Inputs.xaml + MainWindow.xaml + .xaml.cs):
- New ComboBox.Surface style — SurfaceInput bg + BorderStrong border + 7 px
  radius matching the SegmentButton wrapper aesthetic. Fully restyled
  ToggleButton template (Fluent Icons chevron) and Popup template (dark/
  light-themed Surface + DropShadowEffect) so the default WPF chrome doesn't
  leak through. Items use MintTint for the selected row + HoverSubtle for
  hover, matching the rest of the design system.
- AudioDeviceTitle TextBlock removed; replaced by the ComboBox + a new
  AudioDeviceSubtitle that doubles as the error surface for "no mic" / "mic
  busy" states (writes to subtitle instead of overwriting the title).
- PopulateInputDeviceCombo runs on tab open + every dropdown open — cheap
  enumeration picks up hot-plug USB mics without restarting the app. Combo
  selection matched to the persisted preference; falls back to "Default"
  silently if the saved id is no longer present.

Build: 0/0. Tests: 167/167. Smoke: clean launch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Audio mic picker: fix dropdown lag (remove DropShadowEffect + per-open re-enum)

Two root causes per Microsoft Learn "Optimize control performance" +
dotnet/wpf#9881:

1. DropShadowEffect on the Popup's inner Border (BlurRadius=14) was the
   dominant cost — every dropdown open triggered a per-pixel blur pass.
   Removed; replaced with the existing BorderStrong stroke + Surface tint
   which read as elevation without the GPU work.
2. MainWindow.OnInputDeviceDropDownOpened was re-enumerating MMDevices via
   MMDeviceEnumerator.EnumerateAudioEndPoints on every open — a Win32 COM
   round-trip + a full ItemsSource rebuild + a visual-tree teardown. Removed
   the handler. Population now happens ONCE when the Audio tab opens
   (already wired). Hot-plugged devices appear on next tab visit, which is
   an acceptable trade-off vs the 150 ms perceptual lag every open.

Belt-and-suspenders: ComboBox.Surface now declares VirtualizingStackPanel
as its ItemsPanel + IsVirtualizing=True + VirtualizationMode=Recycling.
Negligible for 5-10 mics but bombproof if someone has 20+ capture devices.

Build: 0/0. Smoke: clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Pill Phase 1: chrome restructure — dock drawer + pin + magic-wand buttons

Restructures the floating pill per the Organic Pill spec (Phase 1 — chrome
only; halo, hue-drift, breath, hover-visualizer arrive in Phases 2-4).

Geometry — dynamic window size
- Collapsed: 200×56 (pill only).
- Open / pinned: 320×78 (pill 320 wide + 22 px dock peek). Mica stays tight
  to the visible chrome so the area around the pill doesn't render a
  rectangular Mica frame. Window animates both Width and Height on hover.
- Pill anchor stays on base width 200 so the position math (multi-monitor
  sticky, bottom-center default) doesn't drift center on expand.

New chrome
- Pin button (top-right corner of pill, 18×18). Hidden by default at -12°
  rotation. On pill hover: fades in + rotates to 0° (180 ms / 220 ms). Click
  toggles "pinned" — dock + corner buttons stay visible after the cursor
  leaves, glyph + bg tint to mint.
- Magic-wand button (top-right, left of Pin, 18×18). Dormant — ToolTip
  "Refine text", no Click handler. We will wire it next iteration.
- Dock drawer (22 px row below the pill, slides down + fades in on hover).
  Background matches the pill so the two read as one continuous chrome.
  Border CornerRadius=0,0,8,8 to share the pill's bottom rounding.

Dock contents (left → right)
- Record toggle (22×18). Red dot glyph. Click currently logs a TODO — the
  real wire-up needs a public AppCoordinator.ToggleTapMode() that doesn't
  exist yet; the hotkey chord remains the canonical entry point for v1.
- Mic chooser (flex-grow). [mic icon] [device name] [chevron-down] on a
  subtle button bg. Click opens a real popup picker — a styled <Popup>
  containing a ScrollViewer + a StackPanel of per-device <Button>s. Click a
  device → SetInputDeviceIdAsync via the bridge → popup closes → label
  updates. Mint-tinted selected item.
- Settings (22×18). Fluent gear, opens Preferences (existing wire).
- Dismiss (22×18). Fluent X, red hover bg, calls _onClose → Shutdown.

Layer-friendly bridges
- FloatingPillWindow defines two tiny interfaces (IPrefsStoreBridge,
  IAudioRecorderBridge) and a SetBridges(prefs, audio) hook. App.xaml.cs
  implements them via PrefsStoreBridge (wraps IPrefsStore for the device id
  get/set) and AudioDeviceBridge (calls MMDeviceEnumerator). Keeps
  KusPus.App as the only layer that knows about both Persistence and NAudio.

Removed
- Old side-only hover-extend (ButtonPanel + AnimateWidth/AnimateButtonPanel).
  Replaced by the dock drawer + corner-button animation pair.

Build: 0/0. Tests: 167/167. Smoke: clean launch + pill transitions to Idle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Pill Phase 2: idle content swaps to visualizer + IDLE label on hover

Default idle (no hover) — unchanged: SVG voice-stack icon + "KusPus" wordmark,
just like today. The pill reads as a tiny brand mark when the user isn't
intentionally interacting with it.

On hover (still Idle) — swap to:
  - 20-bar visualizer running the low-amplitude traveling-sine motion model
    from the Organic Pill §3 idle-visualizer cue: amplitude 0.06-0.14,
    per-bar phase offset 0.18 rad, damping k≈3.5/s, full traversal every
    ~2.4 s. Quiet and slow enough to disappear from peripheral vision.
  - Label "IDLE · HOLD TO DICTATE" replaces "RECORDING" in the same slot.

State + hover form an orthogonal grid:
  (Idle, !hover)     → IdleContent (SVG + KusPus)         · viz Off
  (Idle, hover)      → VisualizerContent (bars + IDLE)   · viz HoverIdle
  (Recording, *)     → VisualizerContent (bars + RECORDING) · viz Recording
  (other states, *)  → that state's panel                · viz Off

Refactor
- RecordingContent renamed to VisualizerContent (now serves both Recording
  and HoverIdle modes — same Canvas, label swaps).
- New VisualizerLabel x:Name so the label text can change per mode.
- FadeContent + new ApplyIdleContent + small static FadeElement helper:
  TransitionTo delegates idle-content rendering to ApplyIdleContent, which
  re-evaluates IsMouseOver every time it's called.
- OnPillMouseEnter / OnPillMouseLeave call ApplyIdleContent so the swap
  happens on every hover transition while in Idle.
- New VisualizerMode enum (Off / Recording / HoverIdle). OnVisualizerTick
  switches motion math by mode — HoverIdle runs the sine wave; Recording
  keeps the existing voice-envelope target-rolling; Off targets all bars
  to 0.05 (silent).

Build: 0/0. Tests: 167/167. Smoke: clean launch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Pill Phase 3: breath + hue drift animations · Accessibility toggle

Personality animations
- Breath: ±0.6% scale pulse on PillSurface via ScaleTransform, 4 s sine cycle
  (2 s in + 2 s out, AutoReverse + RepeatForever, SineEase). Subtle enough
  to disappear from peripheral vision — gives the pill a "living organism"
  presence without intruding.
- Hue drift: AccentBrush's middle gradient stop cycles mint #4DDBA6 →
  seafoam #4DCDC2 → soft cyan #4DB8DB → back over 14 s, constant R=0x4D
  band so perceived brightness stays flat (manual approximation of the
  spec's OKLCH constant-L=0.84/C=0.14 constraint; WPF has no native OKLCH).
- Both wired as long-lived Storyboards (built once on Loaded, Begin/Stop
  via SetReduceAnimations) so toggling is cheap.

Deferred to follow-up
- Halo: needs a backbuffer larger than the pill bounds — incompatible with
  the current Mica setup (Mica would paint a rectangular tint around the
  halo area). Decision point: keep Mica + skip halo, OR drop Mica for
  AllowsTransparency=true + custom translucent gradient.
- Heartbeat blink: depends on accent-line opacity which is state-driven
  (TransitionTo sets it per state). Multiplying onto state-driven base
  needs a layered opacity model — deferred until heartbeat semantics are
  pinned down.

Accessibility toggle (new Settings.Privacy.ReducePillAnimations field)
- New Accessibility section in Privacy tab: "Reduce pill animations" Toggle.
  Default off. Saves to settings.json on flip.
- App.UpdatePillReduceAnimations combines the user toggle with
  SystemParameters.ClientAreaAnimation — if either says reduce, pill pauses
  personality animations (state transitions + dock slide remain active).
- Initial state applied at startup + on every PrefsStore.Changes emit.

Build: 0/0. Tests: 167/167. Smoke: clean launch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Pill Phase 4: light-theme mint gradient on visualizer bars

Per user audit feedback that the bars should echo the icon.svg's pearly-
mint gradient.

Dark theme: unchanged — solid #EBFFFFFF SolidColorBrush (the historical
token). A mint gradient over a dark pill surface would lose the
visualizer's "voice on top" reading.

Light theme: three-stop vertical LinearGradientBrush, alpha climbs top→
bottom so each bar reads as "lit from below":
  0.0  →  #664DDBA6  (subtle mint, 40% alpha)
  0.5  →  #994DDBA6  (mid mint, 60% alpha)
  1.0  →  #CC1F8762  (deeper mint, 80% alpha — bottom anchors)

Implementation: VisualizerBarActive is removed from the ThemeTokens.Map
dictionary and installed via a dedicated BuildVisualizerBarActive(mode)
helper alongside the existing BuildPillSurfaceGradient. ThemeTokens.Apply
now calls both special-case builders after the simple-Color-pair loop.

The bars in FloatingPillWindow use SetResourceReference for their Fill, so
the swap fires on theme flip with no other code touching needed.

Build: 0/0. Smoke: clean launch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Pill follow-ups: 6 bug fixes (center-expand, height recovery, picker, theming, inset)

1. Center-expand on hover — Width + Left animate together (Left -= ΔW/2)
   so the pill grows symmetrically instead of right-only.
2. Height recovery on dock close — DoubleAnimations use FillBehavior.Stop
   and on Completed call BeginAnimation(prop, null) + SetValue(prop, to),
   freeing the animated values so the pill collapses cleanly with no black
   gap underneath.
3. Mic picker now design-system styled — Popup uses Surface/BorderStrong
   tokens with a 4-px-padded ScrollViewer (PanningMode=VerticalOnly,
   HorizontalScrollBarVisibility=Disabled). Item template adds a hover
   trigger that paints HoverSubtle on each row.
4. Picker pins the dock open — _pickerOpen flag gates OnPillMouseLeave so
   the dock stays open while the picker popup is open; OnMicChooserPopupClosed
   restores normal hover behavior afterward.
5. Light-theme pill carries the icon's mint — BuildPillSurfaceGradient
   light stops shift from #F8F8FA/#EEEEEF2 to #F4F8F4/#E0F0E6 (subtle top
   shift + slightly mintier bottom), echoing icon.svg's pearl-to-mint
   gradient without changing dark-theme look.
6. Dock visually narrower than pill — DockDrawer carries Margin="24,0,24,0"
   so it reads as a nested sub-element instead of a flush continuation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Pill follow-ups round 2: black strips + picker lag + audio tab lag

1. Black strips beside dock — DockDrawer.Margin removed. The pill window is
   AllowsTransparency=False because Mica (DWMWA_SYSTEMBACKDROP_TYPE) requires
   it, so any inset between the dock and the window edge renders opaque
   window-background black instead of click-through. The prior 24px margin
   was the "narrower than pill" aesthetic from the last batch — reverting it
   here since the side-effect (black strips) is worse than the cohesive look.

2. Pill mic-picker lag — cache the device list in FloatingPillWindow. On
   SetBridges we warm the cache via Task.Run + Dispatcher.BeginInvoke; each
   subsequent OnMicChooserClick reads from cache (instant) and fires a
   background RefreshMicCacheAsync so hot-plugged devices appear on next open.
   Same root cause as the audio-tab combo lag fixed in f834cc5: MMDeviceEnumerator
   .EnumerateAudioEndPoints is a synchronous Win32 COM round-trip (~150ms).
   UpdateMicChooserLabel uses the same cache fall-through.

3. Audio tab loading lag — OpenAudioTabAsync runs the heavy init off the
   dispatcher. EnumerateInputDeviceItems (COM) and the WasapiCapture
   ctor (driver shared-mode negotiation, ~150-500ms on some hardware) both
   await Task.Run, then the combo's ItemsSource is set + StartRecording
   fires on the UI thread. The Audio panel paints immediately; the device
   combo + LIVE meter populate as each piece completes.

   Surface kept stable: synchronous StartAudioMeter() façade still exists
   so the 3 non-tab-open callers (visibility change, mid-test resume,
   device-change restart) read unchanged.

Build: 0/0. Smoke: pill places + hook installs cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Pill: pin = compact-mode toggle + mint idle wordmark

User-spec rewire of the pin semantics. Previously "latch dock open"; now
"compact mode" — clicking pin contracts the pill back to 200×56, slides
the dock back, but keeps the pin button visible at all times so the user
can unpin.

Behavior matrix:

  Pinned OFF (default):
    hover     → expand 200→320, slide dock down, fade pin+wand in
    leave     → contract, slide dock up, fade pin+wand out
    pin click → enter pinned + contract immediately (if already expanded)

  Pinned ON:
    hover     → swap SVG+wordmark → visualizer+IDLE label (NO resize, NO dock)
    leave     → swap visualizer → SVG+wordmark (NO resize, NO dock)
    pin click → exit pinned; if currently hovered, expand back to hover view
    pin button stays visible the entire time (mint-tinted)

Implementation:
- OnPinClick — inverted: becoming pinned calls CloseDock; becoming unpinned
  + hovered calls OpenDock. Unpinned + not-hovered stays put.
- OnPillMouseEnter/Leave — gate OpenDock/CloseDock on !_isPinned so hover
  doesn't trigger the expand/contract while pinned. ApplyIdleContent still
  runs in both branches so the content swap (SVG ↔ visualizer) works.
- AnimateCornerButtons — effectiveVisible = visible || _isPinned. Keeps
  the pin button at opacity=1 and angle=0 while pinned regardless of what
  the caller asked for.

Plus: idle KusPus wordmark now Mint instead of MutedText — picks up the
brand accent so the resting pill carries the product's color cue.

Build: 0/0. Smoke: pill places + hook installs clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Tray redesign + record-toggle wiring + nudge fix

Three landed-together changes for the tray + pill experience.

1. Pill record-toggle wired (Cluster A)
   - SetRecordToggleAction(Action) on FloatingPillWindow; App binds to
     AppCoordinator.ToggleFromTray. Per user spec the toggle does NOT
     auto-capture a foreground target — the post-transcribe paste lands
     wherever focus happens to be at the time.
   - On toggle-start a RecordNudgePopup balloon appears above the RecordButton
     ("Click into your text field") for 6s. Auto-dismisses when state moves
     to Recording. Previous 3s window was too short to read — user feedback.
   - RecordGlyph changed from Ellipse to Rectangle that morphs dot ↔ rounded
     square depending on FSM state.

2. Custom WPF tray right-click menu (Cluster B)
   Replaces WinForms ContextMenuStrip with TrayMenuWindow.xaml — a
   borderless, transparent, design-system-styled popup matching
   Tray_light.png / Tray_dark.png:
   - KusPus header with state-aware "Version 1.0.0 · {Idle|Recording|Transcribing}"
   - Toggle recorder row with hotkey keycap (live-bound to PrefsStore.Hotkey)
   - Active model: <name> row with chevron, opens models tab
   - Preferences… opens general tab
   - History… opens history tab
   - Quit in ErrorRed
   Shows at cursor on NotifyIcon.MouseClick (right). Closes on Deactivated
   (focus lost) or any item click. WS_EX_TOOLWINDOW so it's hidden from
   Alt-Tab/taskbar.

3. State-aware tray icons (Cluster C)
   icons/icon-{idle,recording,error}.svg generated to .ico via tools/IconBuilder.
   Recording overlays a red dot + glow on the bars; Error overlays a red
   warning triangle. TrayManager subscribes to AppCoordinator.State and
   swaps NotifyIcon.Icon based on the FSM state (treating a failed PostPaste
   snapshot as Error for its hold duration). All three .ico files are
   Resources in KusPus.App.csproj.

Build: 0/0. Smoke: pill places + tray icon visible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Pill UX polish: BETA labels + pin lock + audit pass

Three concerns folded into one commit because they share the pill XAML/code-behind:

1. UX pass (per user spec):
   - Pill record button + tray menu both labelled "Toggle Recording [BETA]"
     (verb-form + dogfood expectation-setting). Tray chip is mint-coloured.
   - Magic wand is dormant — rendered at 0.35 opacity with Arrow cursor +
     tooltip "Refine text — coming soon" so the disabled state is legible
     visually, not just in the tooltip.
   - Pin semantics extended: now also locks the pill's screen position. Drag
     short-circuits when _isPinned. Drag cursor (SizeAll) flips to Arrow when
     pinned so the lock is telegraphed.
   - Added CompactRecordButton at the pill's top-LEFT corner, visible only
     when pinned. Pinned mode hides the dock, so without this the user would
     have to unpin just to record. Sits opposite Pin/Wand on the right for
     visual balance.

2. Nudge bug fix (the 6s timer was a red herring — TransitionTo's
   "dismiss-on-Recording" rule was firing within ~ms of click since the FSM
   moves to Recording immediately after ToggleFromTray. Dropped that rule;
   timer bumped 6s→10s as the sole dismissal path. Comment explains why.

3. Full UX audit pass (10 items from the 2026-05-17 self-audit):
   - #1 CompactRecord 22×18 r=5 → 18×18 r=4 (matches Pin/Wand cluster)
   - #2 Design-system icon size tokens added to Styles/Tokens.xaml:
        Icon.Glyph=11, Icon.Chevron=9. Bound on Wand, Pin, Settings, Close,
        MicChooser icon (was 10), MicChooser chevron (was 8).
   - #3 MicChooser hover: Opacity=1.4 (silent no-op — WPF clamps at 1) →
        Background=SurfaceElevated. Real, theme-aware lift.
   - #4 Error text margin 6→8 px (matches Idle/Transcribing rhythm)
   - #5 Dock vertical centering moved to parent Grid (Margin=6,2,6,2);
        mic chooser drops its per-button vertical compensation.
   - #6 Wand opacity 0.5→0.35 (clearer subordinate read)
   - #7 Dropped the 1px PillInnerHighlight — only existed on the pill, not
        the dock, creating a seam at the drawer junction.
   - #8 PillSurface Cursor=SizeAll at rest (telegraphs drag), Arrow when pinned
   - #9 Drop shadow: Direction=270 Depth=2 Blur=32 Op=0.45 →
        Depth=0 Blur=14 Op=0.25. Omnidirectional soft halo, no dock bleed.
   - #10 All chrome gutters standardized at 6 px (was 5 on corners, 6 on dock).

Build: 0/0. Smoke: clean (verified locally with real recording + paste).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Default theme dark + Light [BETA] + onboarding step 6 = real dictation

Three changes from the 2026-05-17 dogfood batch:

1. Default theme flipped "auto" → "dark" (AppSettings.UiSettings.Theme).
   Light theme is still in beta polish, so new installs land on the polished
   dark surface. DefaultSettingsTests assertion updated with rationale.

2. Preferences theme picker: "Light" → "Light [BETA]" with tooltip explaining
   the beta state. Sets dogfood expectations that light surfaces may not be
   fully tuned yet.

3. Onboarding step 6 (Try it) replaced fake SimulatedSentences random-pick
   with a real IAudioRecorder + IWhisperRunner pipeline. 5 s countdown
   recording → transcribe with active model → render actual transcript (or
   error if mic/model missing). Mirrors the existing Test Transcription
   pattern from the Audio tab. Threaded audio/whisper/models services through
   the OnboardingWindow constructor + both call sites (App.OnStartup +
   MainWindow.OnRerunOnboarding).

Prior behaviour was misleading — onboarding "tested" dictation by picking a
canned sentence from a hard-coded list, so a broken mic / missing model
didn't surface until after onboarding finished. Now the failure modes
surface during setup where the user can act on them.

Build: 0/0. Core/Persistence/Whisper/Audio test suites all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Roadmap: add R1.2-10 long-mode chunk-on-VAD streaming on second hotkey

User dogfood feedback (2026-05-17) asked for continuous "speak-pause-paste"
loop on top of the existing push-to-talk model. Researched 3 architectures
(sliding-window, chunk-on-VAD, library-binding); recommended chunk-on-VAD
+ second hotkey ("Option B") to keep the existing UX intact while giving
power users opt-in long-mode dictation.

Deferred to v1.2 per user choice — ~2 weeks build + 1 week dogfood, too
large for the current pre-v1 polish window. Entry captures full 8-cluster
plan, top-3 risks (hallucination on silence, mid-word VAD cuts, paste-into-
wrong-app race), realistic latency (~0.7-1 s per pause with tiny.en), and
the rejected-for-now soft-cap alternative.

Also clarified LT-07 (streaming partial results) as a distinct UX
hypothesis — sliding-window for visible live caption in the pill, NOT a
paste pipeline. Different architecture from R1.2-10; both can ship in
principle but R1.2-10 lands first because it answers a real dogfood ask.

Per CLAUDE.md, this edit to docs/ROADMAP.md is authorized — user
explicitly said "keep it in the roadmap for later versions".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Onboarding step 3: mic chooser dropdown + CLAUDE.md deviation log update

User dogfood ask: let the user pick their mic during onboarding (not just
see the meter for the OS default), and persist that choice until they
change it from Preferences.

Step 3 gets an OnbInputDeviceCombo above the live meter card. It writes
to PrefsStore.Audio.InputDeviceId — the same field Preferences → Audio
uses — so the selection survives onboarding-exit and stays put until the
user changes it from either surface. ResolveOnbMicDevice mirrors
MainWindow.ResolveLevelMeterDevice: looks up by saved id, falls back to
the OS default if the device is unplugged. SelectionChanged restarts the
meter capture so the user sees the level for whichever mic they just
picked.

No shared base class with MainWindow's combo — onboarding is short-lived
and a single helper would pull in more ceremony than it removes. Logic
is a faithful mirror; if a future refactor extracts a shared
HotkeyPickerControl / InputDevicePickerControl UserControl, this and the
MainWindow combo + the Audio-tab one would all collapse to one consumer.

Also updated CLAUDE.md "Deviations" with 11 new entries covering this
session's UX work (pin = compact mode + position lock, BETA labels,
tray menu redesign + state-aware icons, dark default theme, real
onboarding dictation, mic chooser in onboarding, icon-size tokens,
shadow softening, mic-chooser hover fix, roadmap R1.2-10 entry).

Build: 0/0. Smoke: clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
devangk003 added a commit that referenced this pull request May 17, 2026
* fix(hotkey): don't consume LWin keyup — kept Win stuck-down in OS state

Consuming the LWin keyup left Windows thinking Win was still held, so
PasteEngine's SendInput(Ctrl+V) read as Win+Ctrl+V (Action Center / Quick
Settings) and every subsequent keystroke became a Win+key system shortcut.
The Ctrl-tap injection (AHK #MenuMaskKey idiom) still runs to suppress the
Start menu; we just let the real LWin keyup reach the OS so its key-held
state clears.

Spec §13 prescribes the old (buggy) behavior; flagged for revision in
CLAUDE.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Phase 8 pill polish: full PILL_DESIGN.md + drag + multi-monitor sticky

Surface (docs/PILL_DESIGN.md §1, §3):
- 200×56 with 8 px DWM-rounded corners, Mica backdrop on Win11 22H2+
  (DWMWA_SYSTEMBACKDROP_TYPE = DWMSBT_TRANSIENTWINDOW), dark-tinted via
  DWMWA_USE_IMMERSIVE_DARK_MODE. Falls back to the §3.1 dark gradient
  on older Windows.
- §3.3 1 px hairline border + drop shadow + inner top highlight.

Five-state machine (§2):
- Recording: 20-bar visualizer + RECORDING micro-label.
- Transcribing: 14 px ¾-arc spinner (0.9 s loop, rotated via direct
  BeginAnimation on the RotateTransform) + "Transcribing…" text.
- Confirmed: "Pasted into <App>" with the bold app name, 1 s hold.
- Error: 5 px red dot + reason text, 2 s hold, instant accent shift to
  red (§5).
- Idle: PRD G4 dev override — pill stays visible between dictations
  showing the app icon + "KusPus" label. Will revert to spec §6.1
  hidden-when-not-in-use once Settings exposes the close path.

Visualizer (§4):
- 20 bars × 3 px wide × 4 px gap × 4–26 px tall (136 px track).
- Damped target/value motion model per §4.2: center-weighted speak
  envelope, per-bar damp rates, real audio levels from IAudioRecorder
  override the simulation when present. Runs on CompositionTarget.
  Rendering for display-refresh smoothness.

Accent line (§3.4):
- 136 × 1.5 mint gradient with glow, opacity per state.

Motion (§5):
- 120 ms pill appear/disappear, 150 ms content crossfade between
  states. Cubic easing.

Hover-extend override (PILL_DESIGN.md §10):
- Width animates 200 → 280 over 150 ms on hover, Settings + Close
  buttons fade in. WS_EX_TRANSPARENT intentionally NOT applied
  (overrides §1.2 click-through) so buttons work; WS_EX_NOACTIVATE
  preserved so focus doesn't move.

Draggable pill (beyond spec):
- MouseLeftButtonDown anywhere on the pill body → DragMove. Skips
  when click is on a Button (Settings / Close).
- Session-only per-monitor remembered positions via
  Dictionary<deviceName, Point> keyed by MONITORINFOEX.szDevice.
  Cleared on every fresh process start.

Multi-monitor option C (hybrid sticky):
- On state transition into Armed/Recording, jump to the foreground
  window's monitor at its remembered position (or default
  bottom-center if first time). No-op when pill is already on the
  right monitor or while user is dragging.

Coordinator snapshot extension:
- CoordinatorSnapshot.PostPaste:PostPasteInfo carries (Pasted,
  TargetApp, ErrorReason). AppCoordinator emits one post-paste
  snapshot from DeliverAsync / HandleFailureAsync so the pill knows
  whether to show Confirmed or Error.

Icon pipeline:
- tools/IconBuilder: one-shot SVG → multi-frame ICO converter.
- icons/icon.svg as the single source of truth. ViewBox tightened to
  "272 246 480 480" so the 5-bar content fills ~94 % of every
  rendered frame (tray, taskbar, Task Manager, .exe icon).
- icons/icon.ico regenerated, embedded as ApplicationIcon + WPF
  Resource. TrayManager loads via pack URI.
- SharpVectors.Wpf 1.8.5 renders the SVG directly inside the pill
  Idle state — no hand-converted XAML to drift from the source.

Deferred from spec (not blockers):
- §3.2 light theme + WM_SETTINGCHANGE live switching.
- §3.4 accent variants beyond Mint (needs Settings UI from Phase 9).
- §5.3 reduced-motion gating of fades.
- Win10 acrylic fallback.

Tests: 117/117 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Phase 9: MainWindow + 6 tabs + theming + pill flips with theme

Surface:
- New MainWindow per docs/APP_DESIGN.md §3. 880×620 (820×600 min) system-
  chromed window. Sidebar nav (6 RadioButton tabs styled per §3.2 — mint
  stripe + elevated bg on select). Close hides (§3.1 / §8.5); only the
  tray's Quit fully exits. Tray menu gains "Preferences…" → MainWindow.
- Dark title-bar tint via DwmSetWindowAttribute DWMWA_USE_IMMERSIVE_DARK_MODE
  + SetWindowPos(SWP_FRAMECHANGED) to force the non-client repaint on
  runtime theme flips (validated against Microsoft Q&A "DWMWA_USE_
  IMMERSIVE_DARK_MODE won't update").

Six tabs (§3.3):
- General: Hotkey hero card with live listen-mode rebind (suspends LL hook,
  captures held-keys snapshot, commits on full release, conflict warning
  against known Windows shortcuts), Startup toggle → HKCU\Run, Appearance
  Auto/Light/Dark segmented control.
- Audio: device label + Discord-style 200×6 track+fill meter (validated
  fix against naudio/NAudio#160 #347 #507 — MMDevice.AudioMeterInformation
  reports zero without an active capture session, so we open a WasapiCapture
  and compute peak from samples in DataAvailable). Peak-hold tick that
  decays slower than fill.
- Models: active-model row + manifest list with install state (file
  existence per device), radio-select writes ActiveModelId to PrefsStore;
  download wiring deferred to Phase 11.
- History: last 50 transcripts via IHistoryStore.SearchAsync; status dot
  (mint = ok, red = failed), relative time, app name, model + duration.
- Privacy: offline + crash-reports toggles to PrefsStore, logs path +
  Open in Explorer, local-first mint promise card.
- About: 80px brand mark + version line (AssemblyInformationalVersion) +
  Cascadia-Mono build line, Resources card group (GitHub link + logs +
  Re-run onboarding placeholder), MIT/local-first license blurb.

Theming infrastructure:
- ThemeApply (new) resolves "auto"/"light"/"dark" against AppsUseLightTheme
  registry, applies DWM dark-mode + SWP_FRAMECHANGED.
- ThemeTokens (new) — 23-entry map of (dark, light) Color pairs covering
  AppBg, Sidebar, Surface, SurfaceElevated, BorderSubtle/Strong/Divider,
  Primary/Secondary/Muted/DisabledText, HoverSubtle, KeycapBg/Border,
  Mint/MintTint/MintBorder, ErrorRed, WarningAmber/Tint/Border, plus
  pill-specific PillBorder/PillInnerHighlight/VisualizerBarActive/Idle
  and MeterTrack/ButtonHoverBg. Plus a LinearGradientBrush builder for
  the pill's two-stop surface gradient.
- ThemeTokens.Apply uses REPLACEMENT (not mutation) — WPF freezes
  Freezable resources in Application.Resources (x:Shared semantics) so
  brush.Color mutation throws InvalidOperationException at startup.
  Replacement fires ResourcesChanged; every {DynamicResource} consumer
  re-resolves.
- MainWindow.xaml + FloatingPillWindow.xaml refactored end-to-end to
  use {DynamicResource Token} for every brush/foreground/border. Code-
  built UI (Models rows, History rows, hotkey keycaps) uses a Theme(key)
  helper that returns the current resource brush; theme changes re-
  render visible dynamic tabs so they pull fresh brushes.
- Pill visualizer bars use SetResourceReference(FillProperty) instead
  of a frozen explicit brush so bars re-theme on switch.

Deviations from spec — flagged in CLAUDE.md (no MainWindow.xaml hex
literals migrated; everything is now token-based). Body theming for
the pill required new PillSurface gradient resource installed per-
theme. Multiple WPF parse-time gotchas worked around: IsChecked="True"
on TabGeneral triggers Checked event before content panels are bound
to fields — fixed with a _loaded guard in OnTabChecked.

Tests: 117/117 pass.

docs/APP_DESIGN.md: new authoritative UI spec from the user, referenced
from CLAUDE.md source-of-truth list. Where it conflicts with PILL_DESIGN
§2.1 (click-through) the §10 hover-extend override still wins.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Phase 10: Onboarding modal — 7 steps + first-launch trigger

OnboardingWindow per docs/APP_DESIGN.md §4. 720×520 chromeless rounded card,
12 px corners, draggable from the progress-dots header. Theme-aware via the
same ThemeApply + DynamicResource brushes that flip MainWindow / pill.

Seven steps (linear nav with Back / Next / Skip / Finish):
- 1 Welcome: stylized desktop preview + 3-column value-prop grid.
- 2 Hotkey: listen-mode + capture + commit to PrefsStore.Hotkey, with the
  same Win+L-class conflict warning. Duplicates MainWindow's listen-mode
  state machine (extract to UserControl when there's a third consumer).
- 3 Mic check: WasapiCapture on default device, Discord-style track+fill
  meter, success/error variants, Open Settings → ms-settings:privacy-microphone.
- 4 Autostart: clickable ToggleCard → HKCU\Run via AutostartRegistry.Set.
- 5 Crash reports: ToggleCard + Local-first promise card.
- 6 Try it: 120-DIP transcript surface, "Simulate dictation" runs 1.8 s
  Listening… then surfaces one of three canned sentences with mint border.
- 7 Done: corner-of-screen tray-diagram + Finish.

Finish path writes PrefsStore.Onboarding.Completed = true. Skip / Esc /
window-close leave Completed = false so the modal re-pops on next launch.
Re-runnable any time from About → "Run again" (replaces the disabled
Phase 9 placeholder button).

First-launch trigger in App.OnStartup: after _coordinator.Start(), queue
ShowDialog at DispatcherPriority.Background so OnStartup returns before
the modal's nested dispatcher frame begins. Pill + coordinator already
running by then, so the modal's hotkey picker can suspend the live LL
hook cleanly.

Deferred to a polish cluster: §4.1 dimmed-desktop backdrop, hotkey-picker
UserControl extraction, value-card hover states.

Tests: 117/117 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Phase 11 + UX audit W1: crash reporter, egress killswitch, sidebar binding

Phase 11 (TECH_SPEC §19, PRD §10.2/§10.3)
- CrashReporter: Sentry init/shutdown gated on (CrashReportsOptIn && !OfflineMode).
  Embeds project DSN (env-var override) and routes Sentry's own transport through
  EgressAllowlistHandler so PRD §10.2 holds for SDK uploads too.
- EgressAllowlistHandler + EgressPolicy: v1 allowlist. Accepts huggingface.co and
  regional Sentry ingest hosts (*.ingest[.<region>].sentry.io); Offline Mode and
  non-HTTPS block everything. Pure policy decision in Core, IO handler in App.
- CrashScrubber: drops events whose Tags/Extra contain transcript/clipboard/text/
  password/target_app/hwnd keys; replaces %TEMP%, %LOCALAPPDATA%, %APPDATA%,
  %USERPROFILE% prefixes (start-anchored) and Environment.UserName occurrences
  (mid-string) in messages, exception text, stack-frame paths, breadcrumbs.
- 30 new unit tests (167/167 total).

UI/UX W1 — "stop lying" (APP_DESIGN §13 audit findings)
- Crash Reports toggle visually disables when Offline Mode is on; subtitle swaps
  to "Disabled while Offline Mode is on." The toggle's IsChecked is preserved.
- Replace stale "Phase X" + Win32 jargon copy across General/Audio/Models/About.
- Remove permanently-disabled "Test transcription" section; restore in W3 when wired.
- Sidebar footer bound live: status label from AppCoordinator snapshots, chord
  glyph from PrefsStore (compact short form: "Ctrl+Win", not unicode soup).
- Inline [ESC] keycap in hotkey-listen hint (APP_DESIGN §13.4).
- Audit findings appended to docs/APP_DESIGN.md as §13 with a progress ledger.

Build: 0/0. Tests: 157 + 10 new CrashScrubber + extended EgressPolicy = 167/167.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* UX audit W2: design-system extraction (typography, dot, button, focus, spacing)

APP_DESIGN.md §13.5 P1-6/-7/-1/-9/-8/-P2-5. No user-visible scope change —
this is pure styling consolidation. Five new shared style sets, four
inline-styled buttons replaced, one fragile negative-margin layout fixed.

New: src/KusPus.App/Styles/
- Typography.xaml — SectionHeader, Type.RowTitle, Type.RowSubtitle, Type.Eyebrow,
  Type.MonoSm, Type.MonoXs, Type.Display, Type.WarningEmphasis. Replaces ~25
  inline TextBlock FontFamily / FontSize / FontWeight / Foreground quadruplets
  across MainWindow.xaml.
- Dot.xaml — Dot.Mint / Dot.Amber / Dot.Red ellipse styles with the spec's
  7 px + 6 px coloured glow. Replaces 4 hand-rolled ellipses in MainWindow.xaml
  + 1 code-behind ellipse in the history row renderer.
- Buttons.xaml — Btn.Primary / Secondary / Ghost / Danger × Sm / Md / Lg per
  APP_DESIGN §3.4. All buttons now share one template (border + content + hover
  opacity ladder + disabled-state); inline Padding / Background / Foreground /
  BorderThickness gone from every call site.
- Focus.xaml — Focus.Mint: 1.5 px mint dashed outline at 2 px inset. Wired into
  SidebarTab, Toggle, SegmentButton, and Btn.Base — keyboard-nav now has a
  visible focus ring that reads as a design choice rather than the WPF default
  dotted-Aero ring.

Modified: src/KusPus.App/MainWindow.xaml
- All TextBlock declarations matching repeated patterns use a Style key.
- All Buttons use Btn.Secondary (only kind currently needed; the rest of the
  set arrives when W3's purge / download flows land).
- ConflictRow refactored: wraps HotkeyCard + ConflictRow in a single 440 px
  StackPanel with bottom Margin 28. ConflictRow gets `Margin="0,1,0,0"` (1 px
  gap below HotkeyCard) instead of the previous `Margin="0,-20,0,28"` negative-
  margin tuck. Section gap now lives on the parent, not on each child.

Modified: src/KusPus.App/App.xaml
- Application.Resources now merges the four Styles/*.xaml dictionaries so
  every keyed style is reachable app-wide (including future OnboardingWindow
  reuse).

Build: 0/0. Tests: 167/167 still green. Smoke: clean launch.

P1-10 (per-tab UserControl extraction) deferred to a post-W3 cleanup — doing
it now would mean reshuffling every W3 addition into newly-created files. The
ledger in docs/APP_DESIGN.md §13.5 reflects this.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* UX audit W3: missing UX — history search/purge, log clear, model downloads, contrast

APP_DESIGN.md §13.5 P0-3, P0-5, P1-2, P1-3, P1-4. Ships the four user-facing
gaps the audit flagged. All wired against existing services — no new layers.

P0-3 — DisabledText contrast
- ThemeTokens.cs: dark #5CFFFFFF → #80FFFFFF (~3.0:1 → ~5.1:1 vs AppBg).
- Light #50141414 → #A0141414 (~1.9:1 → ~4.7:1 vs AppBg).
- Both clear WCAG AA normal-text 4.5:1. Used for hotkey hint, model "Not
  installed" label, disabled buttons.

P0-5 — Mic-active disclosure (already in W1)
- The Audio "Live level" subtitle reads "Active only while this tab is open.
  Audio is never recorded." Tracking the ledger to "done" — no code change.

P1-3 — Privacy Logs row
- Two-row card: "Log size · {size}" with Clear logs ghost-danger button, then
  "Log folder · {path}" with Open in Explorer secondary button.
- RefreshLogsSize enumerates LOCALAPPDATA\KusPus\logs\*.log on Loaded.
- OnClearLogsClick confirms via MessageBox (No default), then File.Delete each
  *.log. Today's open log is held by Serilog's FileSink — skipped without
  error, count reported in log.
- FormatBytes handles bytes / KB / MB.

P1-2 — History search + bulk footer
- Search box at top with Segoe Fluent icon, placeholder overlay, clear "×"
  button. 250 ms DispatcherTimer debounces TextChanged → HistoryStore.SearchAsync
  (FTS5 backing). Empty query reverts to "most recent first".
- Footer above 1px divider: live row count, "{n} matches for '…'" when filtered,
  Purge all history Btn.Danger with MessageBox confirm. Calls HistoryStore.PurgeAllAsync.
- PurgeAllButton.IsEnabled gates on row count > 0 || query is not null.

P1-4 — Models download flow
- Per-model state machine in _modelDownloads dictionary keyed by id.
- BuildModelStatusRegion dispatches on state: Active / Installed / Downloading
  (180 × 4 px mint progress bar + percent in mono + Cancel ghost) / Error
  (red message + Retry secondary) / Not installed (Download secondary).
- OnModelDownloadClick: pre-checks OfflineMode (clearer message than letting
  EgressAllowlistHandler throw mid-stream), spawns Task.Run with cancellable
  cts. IProgress<DownloadProgress> marshals to UI thread, throttled to 0.5 %
  steps so the StackPanel rebuild doesn't dominate CPU on a fast link.
- OnModelCancelClick: cts.Cancel(). Completion continuation handles cleanup
  for both cancellation (silent) and failure (sticky error + Retry).
- ShortenDownloadError strips ModelManager's "HTTP error downloading …:" prefix.

Build: 0/0. Tests: 167/167.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* UX follow-up: History as a table · pill Settings → Preferences · spacing rhythm

History tab — table layout (audit follow-up)
- Replace card-list rendering with a Grid-based table: header row in
  Type.Eyebrow style above, 6-column body rows below. Columns: status (14) /
  time (78) / app (110) / transcript (*) / model (72) / duration (52).
- Per the skill's number-tabular rule: time, model, duration use Cascadia
  Mono so columns stay aligned across rows.
- Per truncation-strategy: transcript and app truncate with ellipsis +
  ToolTip exposing the full text on hover. Time column ToolTip shows the
  full timestamp.
- Per gridline-subtle: 1 px BorderDivider between body rows, 1 px
  BorderSubtle between header and body. No row striping.
- Row hover: HoverSubtle background via Style trigger on the HistoryRow
  Border (Cursor=Hand for affordance).
- Right-click context menu per row: "Copy text" (Clipboard.SetText) and
  "Delete" (HistoryStore.DeleteAsync + ReloadHistoryAsync).
- ShortModelId strips "ggml-" prefix to match the sidebar's compact form.
- Failed transcripts: red dot + italic red transcript column; rest of the
  row stays normal so the failure mode reads as one cell, not the row.

Pill Settings button wired to Preferences modal
- Add SetSettingsAction(Action) to FloatingPillWindow, mirroring
  SetCloseAction's pattern.
- App.OnStartup wires it to _mainWindow.ShowOn("general") AFTER MainWindow
  is constructed (the existing pill setup runs before MainWindow exists).
- Tooltip "Settings — coming soon" → "Open Preferences".

Spacing rhythm normalization
- Models tab: list bottom margin 12 → 16 (align with the 8-grid inter-block
  rhythm; "16 = block gap" is now consistent across History search bar and
  Models list).
- About tab: header StackPanel bottom 32 → 28 (matches the section gap
  rhythm used everywhere else, instead of being one-off heavier).
- History footer: top margin 18 → 16 (16 is the canonical block gap).

Build: 0/0. Tests: 167/167. Smoke: clean launch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* UX audit Round 2: tokens, surfaces, expanded typography, History single-card

Parallelised Phase 1 (5 sub-agents wrote disjoint style files concurrently),
then serial Phase 2 sweep of MainWindow + App.xaml + APP_DESIGN.md.

NEW style files (Phase 1, parallel)
- Styles/Tokens.xaml   — Space.xs..xxl + Pad.Tight/Default/Hero + Radius.Sm/Md/Lg
- Styles/Surfaces.xaml — Surface.Default/Hero/Tight/Warning/Mint (5 Border styles)
- Styles/Inputs.xaml   — Input.Search TextBox style
- Styles/Typography.xaml extended — 10 new Type.* roles, dead Type.MonoXs removed
- Styles/Buttons.xaml extended — new Btn.IconGhost; header docs call-site inventory

Phase 2 — App.xaml wires the 3 new dictionaries (Tokens first so others can
reference its tokens), then sweep MainWindow.xaml + MainWindow.xaml.cs:

MainWindow.xaml migrations
- Hotkey card    Border → Surface.Hero (drops inline Padding 22,20 override)
- ConflictRow    Border → Surface.Warning (collapses 5 inline attrs)
- Local-first    Border → Surface.Mint    (collapses 5 inline attrs)
- HotkeyHint        TextBlock → Type.HintItalic
- ConflictText      TextBlock → Type.WarningBody
- AboutVersion      TextBlock → Type.Body
- AboutBuildLine    Margin 0,4,0,0 → 0,3,0,0 (matches Type.RowSubtitle rhythm)
- Local-first head  TextBlock → Type.MintHeadline
- Local-first body  TextBlock → Type.BodySmall
- MIT licensed      TextBlock → Type.Footnote
- "Press a hotkey"  TextBlock → Type.HintItalic
- StatusLabel       TextBlock → Type.SidebarStatus (was Type.MonoSm-with-override misuse)
- History search bar magnifier → Type.IconSm
- History search box  TextBox → Input.Search
- History search clear Button → Btn.IconGhost
- About re-run card    Margin 0,0,0,32 → 0,0,0,28 (matches section gap)
- Sidebar footer Grid  Margin 18,8,18,14 → 14,8,14,14 (matches sidebar 14)

History tab — unified single composed card (Q3 from user audit decisions)
- Outer RowCard Padding="0" wraps a StackPanel of inner Borders.
- Search bar (Padding 14,8, bottom 1 px divider), table header (Padding 14,10,
  bottom 1 px divider), HistoryList (HistoryRow style provides per-row bottom
  divider), bulk footer (Padding 14,12, no top border — last row's bottom
  border IS the separator → no double line).
- Reads as one "history widget" instead of four separately-styled blocks.

MainWindow.xaml.cs code-behind sweep
- New TypeStyle(string) helper (mirrors Theme()) to pull Type.* styles from
  Application.Resources for code-built TextBlocks.
- BuildModelRow title/subtitle → Type.RowTitle / Type.RowSubtitle
- BuildBundledBadge child TextBlock → Type.BadgeMint
- BuildModelDownloadingRegion percent → Type.MonoSm
- BuildModelErrorRegion error text → Type.ErrorInline
- BuildHistoryRow TIME / MODEL / DUR columns → Type.MonoSm (transcript + app
  columns + Installed/Active status stay inline because Foreground flips per
  state — no single Type.* role covers both colour states).
- Empty-state TextBlock in ReloadHistoryAsync → Type.HintItalic
- Dropped unreachable "(none)" branch in RenderModelsTab (audit P2-8).

Docs: APP_DESIGN.md §13.5 ledger updated (P2-8/-9 done, P2-10 won't-fix with
reason); new §13.6 documents the Round 2 work — token system, surface variants,
typography role catalogue, inputs/IconGhost, spacing fixes, History
unification, code-behind cleanup, dead-code policy.

Parallelisation safety
- 5 sub-agents in Phase 1 each owned exactly one file (disjoint writes — no
  lost-update risk). None ran dotnet build (avoids bin/obj corruption). The
  orchestrator does all building serially in Phase 3 after killing any running
  KusPus.exe to avoid output-DLL locks.

Build: 0/0. Tests: 167/167. Smoke: clean launch + Sentry init OK.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* About tab: byline + 4 social icons · Models/About card spacing · 1px→8px

Author byline (About → bottom-right)
- "Made by Devang Kumawat" + LinkedIn / X / GitHub / Portfolio icon row at
  the bottom-right of the About tab, sitting directly on AppBg (no card —
  reads as a personal touch, not part of the design-system surface inventory).
- Text uses Type.Footnote (12 Medium SecondaryText) — matches the existing
  "MIT licensed" line on the left. Per UI UX Pro Max rule weight-hierarchy:
  text carries the byline weight, icons are visually subordinate.
- Each icon: 14×14 Viewbox inside a Btn.IconGhost (28×28 click target).
  Aspect ratio locked by the Viewbox's default Uniform Stretch and the
  underlying 24×24 viewBox.
- Theme tinting: Fill="{DynamicResource MutedText}" (filled paths) or
  Stroke="{DynamicResource MutedText}" (Lucide globe) — no per-theme assets,
  one shared rendering for dark+light.

Icon sources (saved to icons/social/ + LICENSE.md attribution)
- LinkedIn / X / GitHub: Simple Icons (CC0) via jsDelivr simple-icons@v11
  and raw.githubusercontent.com/simple-icons/simple-icons/develop/icons/.
- Portfolio (globe): Lucide (ISC) from raw.githubusercontent.com/lucide-icons/lucide.
- Saved as .svg files for documentation/license tracking; actual rendering
  inlines the path data in MainWindow.xaml so the fill binds to theme tokens
  (SharpVectors SvgViewbox can't easily theme-tint).

Each icon button OpenUrl(...) → Process.Start with UseShellExecute=true.
Single helper handles Win32Exception + FileNotFoundException for missing
default browser without crashing.

Links wired
- LinkedIn → https://www.linkedin.com/in/devangk003/
- X        → https://x.com/devang_kumawat
- GitHub   → https://github.com/devangk003
- Portfolio → https://lnk.bio/devangk003

Spacing (user audit feedback — 1 px stacking felt cramped)
- Models tab BuildModelRow inter-row Margin 1 → 8 (per model row reads as
  its own card now, not a grouped 1-px-divider stack).
- About tab Resources/Log-folder cards Margin 0,0,0,1 → 0,0,0,8. Re-run
  card already at 0,0,0,28 (section gap below it).

Build: 0/0. Tests: 167/167. Smoke: clean launch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Sentry: forward unhandled exceptions to CaptureException

Audit gap closed. Previously the three top-level handlers
(OnUnhandledException / OnDispatcherUnhandled / OnUnobservedTask) only
wrote to Serilog — Sentry's own AppDomain auto-hook fires in parallel but
WPF dispatcher exceptions were swallowed by e.Handled=true before Sentry
could see them. Now each handler logs first, then forwards via the new
TryReportToSentry helper.

TryReportToSentry is gated on _crashReporter?.IsActive so the call no-ops
when the user hasn't opted in (or Offline Mode killed the SDK). The Sentry
call itself is wrapped in try/catch so a Sentry failure can't recurse into
another unhandled exception.

Behaviour summary
- Crash Reports OFF: handlers log locally, no network. Same as before.
- Crash Reports ON, Offline Mode OFF: every unhandled exception (AppDomain,
  WPF dispatcher, unobserved Task) reaches your Sentry EU project with the
  scrubbing pipeline applied.
- Crash Reports ON, Offline Mode ON: CrashReporter shuts the SDK down →
  IsActive==false → handlers skip the Sentry call. Local logging still runs.

Build: 0/0. Tests: 167/167. Smoke: clean launch + Sentry init OK.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Fix: OnboardingWindow black corners + model download pinned to real HF commit

Onboarding rounded corners (APP_DESIGN §13.7)
- Root cause: WindowStyle=None + AllowsTransparency=False + Background=Transparent
  renders the area outside the inner Border's CornerRadius as black. The
  inner <Border CornerRadius=12> shows but the 4 corner triangles around it
  fill with WPF's solid black for "transparent-but-not-actually-transparent".
- Fix per Microsoft's "Apply rounded corners in desktop apps for Windows 11"
  guidance:
  - XAML: Background="Transparent" → Background="{DynamicResource AppBg}".
    Corners blend on Win10 fallback.
  - Code-behind: DwmSetWindowAttribute(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE=33,
    DWMWCP_ROUND=2, sizeof(int)) in OnSourceInitialized. Win11 rounds the
    OS-level window edge; Win10 is a silent no-op.
- Corner-radius spec deviation 12 → 8 px (Radius.Lg). The DWM API only
  supports DWMWCP_ROUND (~8 px) or DWMWCP_ROUNDSMALL (~4 px) — no path to
  custom 12 px without AllowsTransparency=True (loses Mica + reintroduces
  the cutout bug). 8 px is also MainWindow's curvature → both surfaces share
  one canonical radius via the Radius.Lg token. APP_DESIGN §4.1 updated;
  full rationale in §13.7.

Model download pinned + verified
- Replaced models.json placeholders (TODO_PIN commit + TODO_FILL SHAs) with
  real values fetched from HuggingFace's tree API:
    commit  = 5359861c739e955e79d9a303bcbc70fb988958b1 (2024-10-29)
    sha256  = LFS digests pulled per file from /api/models/.../tree/<commit>
    sizeBytes = corrected to HF's actual sizes (placeholder bytes were
                slightly off, would have shown wrong progress-bar totals)
- 5 models wired: ggml-tiny.en (77.7 MB · bundled), ggml-base.en (148 MB),
  ggml-small.en (488 MB), ggml-medium.en (1.53 GB), ggml-large-v3 (3.10 GB).
- Models tab Download button now hits real URLs → HuggingFace serves the
  .bin → ModelManager verifies SHA-256 against the manifest entry → File.Move
  to %LOCALAPPDATA%\KusPus\models\. Behaviour matches TECH_SPEC §18.

Build: 0/0. Tests: 167/167. Smoke: clean launch + Sentry init OK.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Audio tab: fix mic-always-on + add LIVE indicator + restore Test transcription

P0 mic-always-on bug
- Root cause: SelectTab's StartAudioMeter/StopAudioMeter only ran on tab
  switches. Closing the Preferences window with X (hide-instead-of-close per
  §3.1) left the WasapiCapture open → mic icon stayed in the system tray
  indefinitely.
- Fix: hooked Window.IsVisibleChanged. When IsVisible=false → StopAudioMeter().
  When IsVisible=true AND Audio tab is currently showing → StartAudioMeter()
  to resume.
- Also added StopAudioMeter() to the OnClosing _allowClose path so app exit
  releases the mic too. StopAudioMeter now resets meter visuals (fill width +
  peak tick opacity) so a paused meter doesn't show stale levels on resume.

● LIVE indicator (privacy affordance — UI UX Pro Max progressive-disclosure)
- Small mint dot + LIVE eyebrow shown next to "Microphone level" only while
  the WasapiCapture is open. Toggled in StartAudioMeter / StopAudioMeter.

Test transcription — fully functional (restored from W1 placeholder)
- State machine: Idle → Recording (5 s countdown) → Transcribing (spinner) →
  Result (transcript shown inline) or Error (red message + Retry).
- Single button doubles as Cancel mid-flight (CancellationTokenSource).
- Mic contention handled: StopAudioMeter() before AudioRecorder.StartAsync;
  StartAudioMeter() resumes after completion / cancellation IF window is
  still visible AND Audio tab is still showing.
- MainWindow constructor now takes IAudioRecorder + IWhisperRunner (added to
  the App.xaml.cs DI wire-up). The active model is resolved via
  IModelManager.Resolve before the mic opens — fast-fail if the model is
  missing.
- Result text rendered in a SurfaceInput-tinted Border with BodySmall
  typography; error text overrides Foreground to ErrorRed.
- Temp WAV from AudioRecorder.StopAsync deleted after transcription
  (best-effort; IOException swallowed).
- CA1001 suppression added to MainWindow with rationale (mirrors App's
  suppression — Window owns its lifecycle, _testCts disposed in OnClosing).

Build: 0/0. Tests: 167/167. Smoke: clean launch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* History hover-actions + Models redesign (action buttons, no radios)

History tab — hover-revealed row actions
- Per UI UX Pro Max convention for productivity data tables (Gmail / Notion /
  Linear pattern): on row hover, the model + duration cells are replaced
  with Copy + Delete Btn.IconGhost buttons. No permanently-visible button
  clutter in the read-heavy table.
- Border MouseEnter / MouseLeave toggles the action StackPanel Visibility;
  Background=Surface paints over the model+duration columns when shown.
- Right-click ContextMenu retained as the keyboard / power-user path. Both
  paths now route through shared helpers CopyTranscriptToClipboard +
  DeleteTranscriptAsync, eliminating duplicated try/catch blocks.
- Icons: Segoe Fluent Icons "Copy" (E8C8) + "Delete" (E74D). Delete icon
  tinted ErrorRed.

Models tab — radio buttons replaced with state-driven action CTAs
- New row layout: 4 px left-edge accent strip + title row (name + Bundled +
  ACTIVE badge if applicable) + state-driven button on the right.
- Five visual states per UI UX Pro Max state-clarity rule:
    Active        — MintTint card bg + mint accent + ACTIVE badge, no button
                    (action already performed — primary-action rule).
    Installed     — neutral card, no accent, "Use this model" Btn.Primary.
    Not installed — neutral card, no accent, "Download" Btn.Secondary
                    (heavier commitment than primary).
    Downloading   — neutral card, mint accent, progress + percent + Cancel
                    Btn.Ghost (existing BuildModelDownloadingRegion reused).
    Failed        — neutral card, red accent, error text + Retry Btn.Secondary
                    (existing BuildModelErrorRegion reused).
- ACTIVE badge: small mint-tinted Border with dark "ACTIVE" text — pulls the
  user's eye to the in-use model at a glance.
- Dead code removed: OnModelRadioChecked (radio gone), BuildModelStatusRegion
  (replaced by BuildModelActionRegion). BuildActiveBadge marked static
  (CA1822 compliance).

Build: 0/0. Tests: 167/167. Smoke: clean launch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Audio: input-device picker — user-selectable microphone

User-facing: a styled ComboBox sits next to the Microphone row in the Audio
tab. First entry is "Default device (follows Windows)"; remaining entries are
every active capture endpoint enumerated via MMDeviceEnumerator. Selection
persists as Audio.InputDeviceId in settings.json and takes effect immediately
for the live meter, Test transcription, and live dictation.

Wiring (no new layer dependencies):
- IAudioRecorder gains SetInputDeviceId(string?). AudioRecorder holds the
  preferred id in a volatile field. StartAsync now goes through a
  ResolveCaptureDevice helper: look up the preferred id; if it's missing /
  inactive / not a capture endpoint, log a warning and fall back to the OS
  default. KusPus.Audio still doesn't reference KusPus.Persistence.
- App.OnStartup pushes the initial id from PrefsStore + subscribes to
  PrefsStore.Changes to propagate further updates. Composition-root pattern.
- MainWindow's level meter (separate WasapiCapture from AudioRecorder) gets
  the same ResolveLevelMeterDevice helper so the meter shows the picked
  device's levels, not the OS default's. Restarts on selection change.

UI (Styles/Inputs.xaml + MainWindow.xaml + .xaml.cs):
- New ComboBox.Surface style — SurfaceInput bg + BorderStrong border + 7 px
  radius matching the SegmentButton wrapper aesthetic. Fully restyled
  ToggleButton template (Fluent Icons chevron) and Popup template (dark/
  light-themed Surface + DropShadowEffect) so the default WPF chrome doesn't
  leak through. Items use MintTint for the selected row + HoverSubtle for
  hover, matching the rest of the design system.
- AudioDeviceTitle TextBlock removed; replaced by the ComboBox + a new
  AudioDeviceSubtitle that doubles as the error surface for "no mic" / "mic
  busy" states (writes to subtitle instead of overwriting the title).
- PopulateInputDeviceCombo runs on tab open + every dropdown open — cheap
  enumeration picks up hot-plug USB mics without restarting the app. Combo
  selection matched to the persisted preference; falls back to "Default"
  silently if the saved id is no longer present.

Build: 0/0. Tests: 167/167. Smoke: clean launch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Audio mic picker: fix dropdown lag (remove DropShadowEffect + per-open re-enum)

Two root causes per Microsoft Learn "Optimize control performance" +
dotnet/wpf#9881:

1. DropShadowEffect on the Popup's inner Border (BlurRadius=14) was the
   dominant cost — every dropdown open triggered a per-pixel blur pass.
   Removed; replaced with the existing BorderStrong stroke + Surface tint
   which read as elevation without the GPU work.
2. MainWindow.OnInputDeviceDropDownOpened was re-enumerating MMDevices via
   MMDeviceEnumerator.EnumerateAudioEndPoints on every open — a Win32 COM
   round-trip + a full ItemsSource rebuild + a visual-tree teardown. Removed
   the handler. Population now happens ONCE when the Audio tab opens
   (already wired). Hot-plugged devices appear on next tab visit, which is
   an acceptable trade-off vs the 150 ms perceptual lag every open.

Belt-and-suspenders: ComboBox.Surface now declares VirtualizingStackPanel
as its ItemsPanel + IsVirtualizing=True + VirtualizationMode=Recycling.
Negligible for 5-10 mics but bombproof if someone has 20+ capture devices.

Build: 0/0. Smoke: clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Pill Phase 1: chrome restructure — dock drawer + pin + magic-wand buttons

Restructures the floating pill per the Organic Pill spec (Phase 1 — chrome
only; halo, hue-drift, breath, hover-visualizer arrive in Phases 2-4).

Geometry — dynamic window size
- Collapsed: 200×56 (pill only).
- Open / pinned: 320×78 (pill 320 wide + 22 px dock peek). Mica stays tight
  to the visible chrome so the area around the pill doesn't render a
  rectangular Mica frame. Window animates both Width and Height on hover.
- Pill anchor stays on base width 200 so the position math (multi-monitor
  sticky, bottom-center default) doesn't drift center on expand.

New chrome
- Pin button (top-right corner of pill, 18×18). Hidden by default at -12°
  rotation. On pill hover: fades in + rotates to 0° (180 ms / 220 ms). Click
  toggles "pinned" — dock + corner buttons stay visible after the cursor
  leaves, glyph + bg tint to mint.
- Magic-wand button (top-right, left of Pin, 18×18). Dormant — ToolTip
  "Refine text", no Click handler. We will wire it next iteration.
- Dock drawer (22 px row below the pill, slides down + fades in on hover).
  Background matches the pill so the two read as one continuous chrome.
  Border CornerRadius=0,0,8,8 to share the pill's bottom rounding.

Dock contents (left → right)
- Record toggle (22×18). Red dot glyph. Click currently logs a TODO — the
  real wire-up needs a public AppCoordinator.ToggleTapMode() that doesn't
  exist yet; the hotkey chord remains the canonical entry point for v1.
- Mic chooser (flex-grow). [mic icon] [device name] [chevron-down] on a
  subtle button bg. Click opens a real popup picker — a styled <Popup>
  containing a ScrollViewer + a StackPanel of per-device <Button>s. Click a
  device → SetInputDeviceIdAsync via the bridge → popup closes → label
  updates. Mint-tinted selected item.
- Settings (22×18). Fluent gear, opens Preferences (existing wire).
- Dismiss (22×18). Fluent X, red hover bg, calls _onClose → Shutdown.

Layer-friendly bridges
- FloatingPillWindow defines two tiny interfaces (IPrefsStoreBridge,
  IAudioRecorderBridge) and a SetBridges(prefs, audio) hook. App.xaml.cs
  implements them via PrefsStoreBridge (wraps IPrefsStore for the device id
  get/set) and AudioDeviceBridge (calls MMDeviceEnumerator). Keeps
  KusPus.App as the only layer that knows about both Persistence and NAudio.

Removed
- Old side-only hover-extend (ButtonPanel + AnimateWidth/AnimateButtonPanel).
  Replaced by the dock drawer + corner-button animation pair.

Build: 0/0. Tests: 167/167. Smoke: clean launch + pill transitions to Idle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Pill Phase 2: idle content swaps to visualizer + IDLE label on hover

Default idle (no hover) — unchanged: SVG voice-stack icon + "KusPus" wordmark,
just like today. The pill reads as a tiny brand mark when the user isn't
intentionally interacting with it.

On hover (still Idle) — swap to:
  - 20-bar visualizer running the low-amplitude traveling-sine motion model
    from the Organic Pill §3 idle-visualizer cue: amplitude 0.06-0.14,
    per-bar phase offset 0.18 rad, damping k≈3.5/s, full traversal every
    ~2.4 s. Quiet and slow enough to disappear from peripheral vision.
  - Label "IDLE · HOLD TO DICTATE" replaces "RECORDING" in the same slot.

State + hover form an orthogonal grid:
  (Idle, !hover)     → IdleContent (SVG + KusPus)         · viz Off
  (Idle, hover)      → VisualizerContent (bars + IDLE)   · viz HoverIdle
  (Recording, *)     → VisualizerContent (bars + RECORDING) · viz Recording
  (other states, *)  → that state's panel                · viz Off

Refactor
- RecordingContent renamed to VisualizerContent (now serves both Recording
  and HoverIdle modes — same Canvas, label swaps).
- New VisualizerLabel x:Name so the label text can change per mode.
- FadeContent + new ApplyIdleContent + small static FadeElement helper:
  TransitionTo delegates idle-content rendering to ApplyIdleContent, which
  re-evaluates IsMouseOver every time it's called.
- OnPillMouseEnter / OnPillMouseLeave call ApplyIdleContent so the swap
  happens on every hover transition while in Idle.
- New VisualizerMode enum (Off / Recording / HoverIdle). OnVisualizerTick
  switches motion math by mode — HoverIdle runs the sine wave; Recording
  keeps the existing voice-envelope target-rolling; Off targets all bars
  to 0.05 (silent).

Build: 0/0. Tests: 167/167. Smoke: clean launch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Pill Phase 3: breath + hue drift animations · Accessibility toggle

Personality animations
- Breath: ±0.6% scale pulse on PillSurface via ScaleTransform, 4 s sine cycle
  (2 s in + 2 s out, AutoReverse + RepeatForever, SineEase). Subtle enough
  to disappear from peripheral vision — gives the pill a "living organism"
  presence without intruding.
- Hue drift: AccentBrush's middle gradient stop cycles mint #4DDBA6 →
  seafoam #4DCDC2 → soft cyan #4DB8DB → back over 14 s, constant R=0x4D
  band so perceived brightness stays flat (manual approximation of the
  spec's OKLCH constant-L=0.84/C=0.14 constraint; WPF has no native OKLCH).
- Both wired as long-lived Storyboards (built once on Loaded, Begin/Stop
  via SetReduceAnimations) so toggling is cheap.

Deferred to follow-up
- Halo: needs a backbuffer larger than the pill bounds — incompatible with
  the current Mica setup (Mica would paint a rectangular tint around the
  halo area). Decision point: keep Mica + skip halo, OR drop Mica for
  AllowsTransparency=true + custom translucent gradient.
- Heartbeat blink: depends on accent-line opacity which is state-driven
  (TransitionTo sets it per state). Multiplying onto state-driven base
  needs a layered opacity model — deferred until heartbeat semantics are
  pinned down.

Accessibility toggle (new Settings.Privacy.ReducePillAnimations field)
- New Accessibility section in Privacy tab: "Reduce pill animations" Toggle.
  Default off. Saves to settings.json on flip.
- App.UpdatePillReduceAnimations combines the user toggle with
  SystemParameters.ClientAreaAnimation — if either says reduce, pill pauses
  personality animations (state transitions + dock slide remain active).
- Initial state applied at startup + on every PrefsStore.Changes emit.

Build: 0/0. Tests: 167/167. Smoke: clean launch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Pill Phase 4: light-theme mint gradient on visualizer bars

Per user audit feedback that the bars should echo the icon.svg's pearly-
mint gradient.

Dark theme: unchanged — solid #EBFFFFFF SolidColorBrush (the historical
token). A mint gradient over a dark pill surface would lose the
visualizer's "voice on top" reading.

Light theme: three-stop vertical LinearGradientBrush, alpha climbs top→
bottom so each bar reads as "lit from below":
  0.0  →  #664DDBA6  (subtle mint, 40% alpha)
  0.5  →  #994DDBA6  (mid mint, 60% alpha)
  1.0  →  #CC1F8762  (deeper mint, 80% alpha — bottom anchors)

Implementation: VisualizerBarActive is removed from the ThemeTokens.Map
dictionary and installed via a dedicated BuildVisualizerBarActive(mode)
helper alongside the existing BuildPillSurfaceGradient. ThemeTokens.Apply
now calls both special-case builders after the simple-Color-pair loop.

The bars in FloatingPillWindow use SetResourceReference for their Fill, so
the swap fires on theme flip with no other code touching needed.

Build: 0/0. Smoke: clean launch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Pill follow-ups: 6 bug fixes (center-expand, height recovery, picker, theming, inset)

1. Center-expand on hover — Width + Left animate together (Left -= ΔW/2)
   so the pill grows symmetrically instead of right-only.
2. Height recovery on dock close — DoubleAnimations use FillBehavior.Stop
   and on Completed call BeginAnimation(prop, null) + SetValue(prop, to),
   freeing the animated values so the pill collapses cleanly with no black
   gap underneath.
3. Mic picker now design-system styled — Popup uses Surface/BorderStrong
   tokens with a 4-px-padded ScrollViewer (PanningMode=VerticalOnly,
   HorizontalScrollBarVisibility=Disabled). Item template adds a hover
   trigger that paints HoverSubtle on each row.
4. Picker pins the dock open — _pickerOpen flag gates OnPillMouseLeave so
   the dock stays open while the picker popup is open; OnMicChooserPopupClosed
   restores normal hover behavior afterward.
5. Light-theme pill carries the icon's mint — BuildPillSurfaceGradient
   light stops shift from #F8F8FA/#EEEEEF2 to #F4F8F4/#E0F0E6 (subtle top
   shift + slightly mintier bottom), echoing icon.svg's pearl-to-mint
   gradient without changing dark-theme look.
6. Dock visually narrower than pill — DockDrawer carries Margin="24,0,24,0"
   so it reads as a nested sub-element instead of a flush continuation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Pill follow-ups round 2: black strips + picker lag + audio tab lag

1. Black strips beside dock — DockDrawer.Margin removed. The pill window is
   AllowsTransparency=False because Mica (DWMWA_SYSTEMBACKDROP_TYPE) requires
   it, so any inset between the dock and the window edge renders opaque
   window-background black instead of click-through. The prior 24px margin
   was the "narrower than pill" aesthetic from the last batch — reverting it
   here since the side-effect (black strips) is worse than the cohesive look.

2. Pill mic-picker lag — cache the device list in FloatingPillWindow. On
   SetBridges we warm the cache via Task.Run + Dispatcher.BeginInvoke; each
   subsequent OnMicChooserClick reads from cache (instant) and fires a
   background RefreshMicCacheAsync so hot-plugged devices appear on next open.
   Same root cause as the audio-tab combo lag fixed in f834cc5: MMDeviceEnumerator
   .EnumerateAudioEndPoints is a synchronous Win32 COM round-trip (~150ms).
   UpdateMicChooserLabel uses the same cache fall-through.

3. Audio tab loading lag — OpenAudioTabAsync runs the heavy init off the
   dispatcher. EnumerateInputDeviceItems (COM) and the WasapiCapture
   ctor (driver shared-mode negotiation, ~150-500ms on some hardware) both
   await Task.Run, then the combo's ItemsSource is set + StartRecording
   fires on the UI thread. The Audio panel paints immediately; the device
   combo + LIVE meter populate as each piece completes.

   Surface kept stable: synchronous StartAudioMeter() façade still exists
   so the 3 non-tab-open callers (visibility change, mid-test resume,
   device-change restart) read unchanged.

Build: 0/0. Smoke: pill places + hook installs cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Pill: pin = compact-mode toggle + mint idle wordmark

User-spec rewire of the pin semantics. Previously "latch dock open"; now
"compact mode" — clicking pin contracts the pill back to 200×56, slides
the dock back, but keeps the pin button visible at all times so the user
can unpin.

Behavior matrix:

  Pinned OFF (default):
    hover     → expand 200→320, slide dock down, fade pin+wand in
    leave     → contract, slide dock up, fade pin+wand out
    pin click → enter pinned + contract immediately (if already expanded)

  Pinned ON:
    hover     → swap SVG+wordmark → visualizer+IDLE label (NO resize, NO dock)
    leave     → swap visualizer → SVG+wordmark (NO resize, NO dock)
    pin click → exit pinned; if currently hovered, expand back to hover view
    pin button stays visible the entire time (mint-tinted)

Implementation:
- OnPinClick — inverted: becoming pinned calls CloseDock; becoming unpinned
  + hovered calls OpenDock. Unpinned + not-hovered stays put.
- OnPillMouseEnter/Leave — gate OpenDock/CloseDock on !_isPinned so hover
  doesn't trigger the expand/contract while pinned. ApplyIdleContent still
  runs in both branches so the content swap (SVG ↔ visualizer) works.
- AnimateCornerButtons — effectiveVisible = visible || _isPinned. Keeps
  the pin button at opacity=1 and angle=0 while pinned regardless of what
  the caller asked for.

Plus: idle KusPus wordmark now Mint instead of MutedText — picks up the
brand accent so the resting pill carries the product's color cue.

Build: 0/0. Smoke: pill places + hook installs clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Tray redesign + record-toggle wiring + nudge fix

Three landed-together changes for the tray + pill experience.

1. Pill record-toggle wired (Cluster A)
   - SetRecordToggleAction(Action) on FloatingPillWindow; App binds to
     AppCoordinator.ToggleFromTray. Per user spec the toggle does NOT
     auto-capture a foreground target — the post-transcribe paste lands
     wherever focus happens to be at the time.
   - On toggle-start a RecordNudgePopup balloon appears above the RecordButton
     ("Click into your text field") for 6s. Auto-dismisses when state moves
     to Recording. Previous 3s window was too short to read — user feedback.
   - RecordGlyph changed from Ellipse to Rectangle that morphs dot ↔ rounded
     square depending on FSM state.

2. Custom WPF tray right-click menu (Cluster B)
   Replaces WinForms ContextMenuStrip with TrayMenuWindow.xaml — a
   borderless, transparent, design-system-styled popup matching
   Tray_light.png / Tray_dark.png:
   - KusPus header with state-aware "Version 1.0.0 · {Idle|Recording|Transcribing}"
   - Toggle recorder row with hotkey keycap (live-bound to PrefsStore.Hotkey)
   - Active model: <name> row with chevron, opens models tab
   - Preferences… opens general tab
   - History… opens history tab
   - Quit in ErrorRed
   Shows at cursor on NotifyIcon.MouseClick (right). Closes on Deactivated
   (focus lost) or any item click. WS_EX_TOOLWINDOW so it's hidden from
   Alt-Tab/taskbar.

3. State-aware tray icons (Cluster C)
   icons/icon-{idle,recording,error}.svg generated to .ico via tools/IconBuilder.
   Recording overlays a red dot + glow on the bars; Error overlays a red
   warning triangle. TrayManager subscribes to AppCoordinator.State and
   swaps NotifyIcon.Icon based on the FSM state (treating a failed PostPaste
   snapshot as Error for its hold duration). All three .ico files are
   Resources in KusPus.App.csproj.

Build: 0/0. Smoke: pill places + tray icon visible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Pill UX polish: BETA labels + pin lock + audit pass

Three concerns folded into one commit because they share the pill XAML/code-behind:

1. UX pass (per user spec):
   - Pill record button + tray menu both labelled "Toggle Recording [BETA]"
     (verb-form + dogfood expectation-setting). Tray chip is mint-coloured.
   - Magic wand is dormant — rendered at 0.35 opacity with Arrow cursor +
     tooltip "Refine text — coming soon" so the disabled state is legible
     visually, not just in the tooltip.
   - Pin semantics extended: now also locks the pill's screen position. Drag
     short-circuits when _isPinned. Drag cursor (SizeAll) flips to Arrow when
     pinned so the lock is telegraphed.
   - Added CompactRecordButton at the pill's top-LEFT corner, visible only
     when pinned. Pinned mode hides the dock, so without this the user would
     have to unpin just to record. Sits opposite Pin/Wand on the right for
     visual balance.

2. Nudge bug fix (the 6s timer was a red herring — TransitionTo's
   "dismiss-on-Recording" rule was firing within ~ms of click since the FSM
   moves to Recording immediately after ToggleFromTray. Dropped that rule;
   timer bumped 6s→10s as the sole dismissal path. Comment explains why.

3. Full UX audit pass (10 items from the 2026-05-17 self-audit):
   - #1 CompactRecord 22×18 r=5 → 18×18 r=4 (matches Pin/Wand cluster)
   - #2 Design-system icon size tokens added to Styles/Tokens.xaml:
        Icon.Glyph=11, Icon.Chevron=9. Bound on Wand, Pin, Settings, Close,
        MicChooser icon (was 10), MicChooser chevron (was 8).
   - #3 MicChooser hover: Opacity=1.4 (silent no-op — WPF clamps at 1) →
        Background=SurfaceElevated. Real, theme-aware lift.
   - #4 Error text margin 6→8 px (matches Idle/Transcribing rhythm)
   - #5 Dock vertical centering moved to parent Grid (Margin=6,2,6,2);
        mic chooser drops its per-button vertical compensation.
   - #6 Wand opacity 0.5→0.35 (clearer subordinate read)
   - #7 Dropped the 1px PillInnerHighlight — only existed on the pill, not
        the dock, creating a seam at the drawer junction.
   - #8 PillSurface Cursor=SizeAll at rest (telegraphs drag), Arrow when pinned
   - #9 Drop shadow: Direction=270 Depth=2 Blur=32 Op=0.45 →
        Depth=0 Blur=14 Op=0.25. Omnidirectional soft halo, no dock bleed.
   - #10 All chrome gutters standardized at 6 px (was 5 on corners, 6 on dock).

Build: 0/0. Smoke: clean (verified locally with real recording + paste).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Default theme dark + Light [BETA] + onboarding step 6 = real dictation

Three changes from the 2026-05-17 dogfood batch:

1. Default theme flipped "auto" → "dark" (AppSettings.UiSettings.Theme).
   Light theme is still in beta polish, so new installs land on the polished
   dark surface. DefaultSettingsTests assertion updated with rationale.

2. Preferences theme picker: "Light" → "Light [BETA]" with tooltip explaining
   the beta state. Sets dogfood expectations that light surfaces may not be
   fully tuned yet.

3. Onboarding step 6 (Try it) replaced fake SimulatedSentences random-pick
   with a real IAudioRecorder + IWhisperRunner pipeline. 5 s countdown
   recording → transcribe with active model → render actual transcript (or
   error if mic/model missing). Mirrors the existing Test Transcription
   pattern from the Audio tab. Threaded audio/whisper/models services through
   the OnboardingWindow constructor + both call sites (App.OnStartup +
   MainWindow.OnRerunOnboarding).

Prior behaviour was misleading — onboarding "tested" dictation by picking a
canned sentence from a hard-coded list, so a broken mic / missing model
didn't surface until after onboarding finished. Now the failure modes
surface during setup where the user can act on them.

Build: 0/0. Core/Persistence/Whisper/Audio test suites all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Roadmap: add R1.2-10 long-mode chunk-on-VAD streaming on second hotkey

User dogfood feedback (2026-05-17) asked for continuous "speak-pause-paste"
loop on top of the existing push-to-talk model. Researched 3 architectures
(sliding-window, chunk-on-VAD, library-binding); recommended chunk-on-VAD
+ second hotkey ("Option B") to keep the existing UX intact while giving
power users opt-in long-mode dictation.

Deferred to v1.2 per user choice — ~2 weeks build + 1 week dogfood, too
large for the current pre-v1 polish window. Entry captures full 8-cluster
plan, top-3 risks (hallucination on silence, mid-word VAD cuts, paste-into-
wrong-app race), realistic latency (~0.7-1 s per pause with tiny.en), and
the rejected-for-now soft-cap alternative.

Also clarified LT-07 (streaming partial results) as a distinct UX
hypothesis — sliding-window for visible live caption in the pill, NOT a
paste pipeline. Different architecture from R1.2-10; both can ship in
principle but R1.2-10 lands first because it answers a real dogfood ask.

Per CLAUDE.md, this edit to docs/ROADMAP.md is authorized — user
explicitly said "keep it in the roadmap for later versions".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Onboarding step 3: mic chooser dropdown + CLAUDE.md deviation log update

User dogfood ask: let the user pick their mic during onboarding (not just
see the meter for the OS default), and persist that choice until they
change it from Preferences.

Step 3 gets an OnbInputDeviceCombo above the live meter card. It writes
to PrefsStore.Audio.InputDeviceId — the same field Preferences → Audio
uses — so the selection survives onboarding-exit and stays put until the
user changes it from either surface. ResolveOnbMicDevice mirrors
MainWindow.ResolveLevelMeterDevice: looks up by saved id, falls back to
the OS default if the device is unplugged. SelectionChanged restarts the
meter capture so the user sees the level for whichever mic they just
picked.

No shared base class with MainWindow's combo — onboarding is short-lived
and a single helper would pull in more ceremony than it removes. Logic
is a faithful mirror; if a future refactor extracts a shared
HotkeyPickerControl / InputDevicePickerControl UserControl, this and the
MainWindow combo + the Audio-tab one would all collapse to one consumer.

Also updated CLAUDE.md "Deviations" with 11 new entries covering this
session's UX work (pin = compact mode + position lock, BETA labels,
tray menu redesign + state-aware icons, dark default theme, real
onboarding dictation, mic chooser in onboarding, icon-size tokens,
shadow softening, mic-chooser hover fix, roadmap R1.2-10 entry).

Build: 0/0. Smoke: clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Onboarding fixes: Skip=Completed, pill defer, step 3 async, apartment-marshalling bug

Four bugs / behaviors corrected in one session of dogfood feedback.

1. Skip now marks Completed=true (was: Completed=false). Onboarding modal
   opens once-ever per install. Closing via Skip is honoured the same as
   Finish — modal does not re-appear on next launch. Re-runnable via
   About → "Run again". Prior "skip-on-skip keeps Completed=false" rule
   was hostile (the user just wanted to dismiss); replaced with show-once-
   ever.

2. Pill is now invisible while onboarding is open. Bind() / BindLevels()
   moved out of App.OnStartup inline construction and into a new
   BindPillAndShow() helper that runs AFTER ShowDialog() returns (or
   immediately if no onboarding). The first BehaviorSubject snapshot
   subscribes to coordinator.State which triggers the pill's FadePillIn
   → Show(), so deferring Bind is what hides the pill. Existing users
   (Completed=true) get pill instantly; new users get pill after Finish/Skip.

3. Step 3 mic now loads async (mirrors MainWindow.OpenAudioTabAsync).
   New OpenMicStepAsync orchestrator: page paints immediately with
   "Loading microphones…" placeholder + "LOADING…" label; MMDevice enum
   and WasapiCapture init run on Task.Run; UI populates when ready.
   Previously the entire dispatcher blocked for ~250 ms on first step 3
   entry (driver shared-mode negotiation).

4. Cross-apartment MMDevice access bug (fix-of-fix). The first async pass
   returned the MMDevice from the Task.Run lambda and then read
   .FriendlyName on the dispatcher — NAudio's IMMDevice doesn't support
   standard COM cross-apartment proxy marshalling, so the property getter
   threw InvalidCastException → E_NOINTERFACE. That landed in
   UnobservedTaskException (silent) and the user saw "Microphone blocked"
   even though nothing was using the mic. Fix: read FriendlyName INSIDE
   the Task.Run lambda (MTA where the device was created), return only
   the string + WasapiCapture across the await. MMDevice never crosses
   thread boundaries. WasapiCapture is fine cross-thread because it
   caches its WaveFormat internally before its ctor returns — that's
   why MainWindow.OpenAudioTabAsync (which only returns the capture)
   never had this bug.

   Validated from the live log:
     System.InvalidCastException: Unable to cast COM object ...
     to interface type IMMDevice ... E_NOINTERFACE
     at NAudio.CoreAudioApi.MMDevice.GetPropertyInformation
     at OnboardingWindow.StartMicCheckAsync()  line 641

   Also broadened the Task.Run catch from
     COMException + MmException
   to general Exception, since NAudio's WasapiCapture can throw a wider
   set (InvalidOperationException on busy device, ArgumentException on
   malformed format, etc.). Added an outer try/catch on OpenMicStepAsync
   so any unhandled error surfaces as ShowMicError instead of silent
   stuck-Loading.

Build: 0/0. Cross-apartment fix validated by code trace + matched
against MainWindow.OpenAudioTabAsync (which doesn't return MMDevice
across threads and works correctly).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* About tab: globe icon matches LinkedIn visual size

The four social icons (LinkedIn, X, GitHub, Portfolio-globe) all use a
14×14 Viewbox wrapping 24×24 vector content. LinkedIn/X/GitHub paths
fill their viewBox edge-to-edge (0–24 on both axes), so they render at
the full 14×14 visual size. The globe was drawn in a 24×24 Canvas with
the ellipse at (2,2) W=20 H=20 plus a stroke=2 outline — that left 2 px
of padding around the geometry, so the globe rendered at ~20/24 ≈ 83%
of the other icons' visual size.

Fix: expand the geometry to fill the full 24×24 box.
  Ellipse: (1,1) W=22 H=22 + stroke=2 → visible ink spans 0–24.
  Meridian arc: radius 14.5 → 15.95 (×22/20 scale factor); endpoints
    move from y=2/22 to y=1/23.
  Equator line: M2 12 h20 → M1 12 h22.

All four icons now render at the same effective 14×14 visual size. No
aspect-ratio change — geometry preserved, only the bounds expanded.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Pill compact-record grey + nudge 2s + social icon size parity

Four small UX tweaks from dogfood feedback (2026-05-17).

1. CompactRecord glyph (the corner record button visible while pinned) is
   now grey (MutedText) when idle, red (#EF5350) when recording. Previously
   it was always red — looked like "recording in progress" even at rest.
   Grey reads as "available, tap to start"; red reserved for active state.

2. CompactRecord glyph bumped 8×8 → 10×10 (RadiusX 4 → 5 idle, 1.5 → 2
   recording). The visible footprint now roughly matches the pin glyph's
   ascent at FontSize=Icon.Glyph (11), so left/right corner clusters look
   visually balanced. Button itself stays 18×18 with the same 6 px margin
   from the pill edge as the pin StackPanel — positions were already
   symmetric; the parity issue was glyph size.

3. UpdateRecordGlyph now swaps CompactRecord.Fill on state change (grey
   ↔ red) in addition to the existing radius morph. Dock RecordGlyph
   stays always-red (it's the dock's record identifier; grey would lose
   its affordance).

4. Nudge timer 10 s → 2 s. The "Click into your text field" hint is now
   a brief flash, not a lingering popup. User feedback: 10 s sat there
   long after they had already moved on.

5. About-tab social icons: wrapped each Path in a fixed-size 24×24 Canvas
   so the Viewbox uses the canvas bounds (always 24×24) rather than the
   path's computed bbox. Path bboxes vary subtly — GitHub's "M12 .297"
   start offset, Bezier control points extending past the visible curve,
   X's 0.258 left edge — which caused uneven rendered sizes when Viewbox
   uniformly stretched each to 14×14. With the Canvas wrapper, all four
   (LinkedIn, X, GitHub, Globe) are guaranteed to render at exactly the
   same effective visual size.

Build: 0/0. Smoke: clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* About tagline + pill bottom-corners squared while dock is open

Two surgical fixes.

1. About-tab tagline changed from "Press a hotkey. Speak. Get pasted."
   to "Local Privacy First" — direct product-pillar wording per user
   ask. Same Type.HintItalic style, same position; just the string.

2. PillSurface.CornerRadius drops from 8 → (8,8,0,0) when the dock
   slides into view, and back to 8 when the dock slides away. The
   pill's bottom edge is flat while the dock is visible, so the seam
   between pill bottom and dock top (which has CornerRadius=0,0,8,8)
   reads as one continuous shape instead of two stacked rounded
   rectangles with visible "ears" at the seam.

   Implementation: the corner-radius swap lives inside OpenDock() and
   CloseDock(). OnPillMouseEnter/Leave + OnPinClick already gate
   OpenDock/CloseDock on !_isPinned (pinned mode uses content-swap
   without expanding), so pinned compact-mode never enters OpenDock
   and the pill keeps its full 8 px rounded corners — exactly the
   "no corner-radius changes in pinned state" constraint.

   Snap (not animate) since WPF's CornerRadius isn't a natively
   animatable DependencyProperty. The snap happens at the START of
   each method so the bottom edge is flat the full time the dock is
   becoming visible (OpenDock case) and the round-back happens just
   as the dock starts going away (CloseDock case — the brief
   round-bottom-over-still-visible-dock artifact is during the
   subordinate "going away" animation).

   No XAML changes to the PillSurface element; the default
   CornerRadius="8" stays as the initial / fully-collapsed value.

Build: 0/0. Smoke: clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@…
@devangk003 devangk003 deleted the feat/ui-fixes branch May 17, 2026 15:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants