Phase 12: CI/CD + Inno installer + bundled tiny.en + CFA-friendly ModelsDir#2
Merged
Conversation
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>
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>
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>
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>
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>
…ub 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>
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>
…aller
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>
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 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>
* 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
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@…
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Phase 12 complete and verified working on a clean install (rc4).
Adds:
rc1..rc4 history captured in CLAUDE.md deviation log.
Closes the Phase 12 milestone per docs/PROCESS.md.