From 39a511fcce87782dbe2585906c97e8845711a965 Mon Sep 17 00:00:00 2001 From: Devang Kumawat Date: Sat, 16 May 2026 20:16:09 +0530 Subject: [PATCH 01/28] =?UTF-8?q?fix(hotkey):=20don't=20consume=20LWin=20k?= =?UTF-8?q?eyup=20=E2=80=94=20kept=20Win=20stuck-down=20in=20OS=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 1 + src/KusPus.Native/HotkeyEngine.cs | 23 ++++++++++++++----- test/KusPus.Native.Tests/HotkeyEngineTests.cs | 8 +++++-- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2ed340b..8731c4c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,6 +110,7 @@ When a gate forces a deliberate deviation from TECH_SPEC's literal text, log it - **`src/KusPus.Native/PasteEngine.cs`** — `IClipboardWriter` injection point. Spec §16 calls `Clipboard.SetText` directly, which requires WPF in the Native project. Decoupling via an interface keeps `KusPus.Native` free of WPF deps; the WPF impl ships in Phase 6's `KusPus.App` composition root. - **`src/KusPus.Native/JobObjectContainer.cs`** — owns the Job Object handle. Spec §15 says "created in AppCoordinator and injected into WhisperRunner via DI"; in practice the handle lives in this class which implements `IProcessContainer` and is injected as the `WhisperRunner._onProcessStarted` callback. Functionally equivalent. - **`src/KusPus.Native/HotkeyEngine.cs`** — Spec §13 step 6 prescribes a `Channel` between the LL callback and the subject. Real impl emits directly to `Subject` AFTER releasing the state lock, which avoids the deadlock-under-callback risk without the channel allocation/dispatch overhead. At v1's input rate (a handful of events per chord activation) this is well within the `LowLevelHooksTimeout` budget. Revisit if the watchdog observes timeouts. +- **`src/KusPus.Native/HotkeyEngine.cs` — LWin keyup is NOT consumed** (spec §13 says it should be; that turned out to be a bug). The Ctrl-tap injection still runs (AHK `#MenuMaskKey` idiom — masks the Start menu) but the real LWin keyup is allowed to reach the OS so its internal "Win is held" state clears. Consuming the keyup left Win stuck-down — every later `SendInput(Ctrl+V)` from PasteEngine read as `Win+Ctrl+V` (opens Action Center / Quick Settings) and every later keystroke became a `Win+key` system shortcut. Validated by AutoHotkey community docs + PowerToys issue #35345 / #18175. **Spec §13 needs an update** when the user is reviewing — flagged in conversation, not edited here per CLAUDE.md rule. - **`src/KusPus.Whisper/WhisperRunner.cs`** — empty `expectedWhisperSha256` now skips the integrity check (dev mode). Phase 12 release builds populate the SHA from `installer/payload/whisper/SHA256SUMS`. - **`src/KusPus.App/`** — Phase 6 milestone code. Several v1-only simplifications, all to be revisited in their planned phases: - Pill: plain `Topmost`/click-through/`WS_EX_NOACTIVATE` window with a single status text; no acrylic, no animations, no visualizer (Phase 8). Position math uses `System.Windows.Forms.Screen.FromPoint` (good enough for one-monitor dev; Phase 8 switches to `MonitorFromPoint` + `GetDpiForMonitor` for per-monitor DPI). diff --git a/src/KusPus.Native/HotkeyEngine.cs b/src/KusPus.Native/HotkeyEngine.cs index 92fb147..64e46c3 100644 --- a/src/KusPus.Native/HotkeyEngine.cs +++ b/src/KusPus.Native/HotkeyEngine.cs @@ -31,8 +31,14 @@ namespace KusPus.Native; /// See CLAUDE.md deviation log. /// /// LWin-suppression: when the user releases LWin while we own the chord, Windows -/// would normally open the Start menu. We inject a stray VK_CONTROL keydown -/// just before consuming the LWin keyup. Textbook hotkey-utility idiom. +/// would normally open the Start menu. We inject a stray Ctrl tap right before the +/// LWin keyup so the OS sees "other input between LWin↓ and LWin↑" and skips the +/// Start menu — this is AutoHotkey's #MenuMaskKey idiom. We do NOT consume +/// the LWin keyup itself: the OS must see it so its internal "Win is held" state +/// clears. Consuming it (the original §13 phrasing) left Win stuck-down, which made +/// subsequent SendInput(Ctrl+V) read as Win+Ctrl+V (opens Action +/// Center / Clipboard History) and turned every later keystroke into a Win+key +/// system shortcut. /// /// Hook self-heal watchdog (TECH_SPEC §13) is NOT implemented in Phase 5. Deferred. /// @@ -264,13 +270,18 @@ internal bool ProcessKey(CoreVK key, bool isKeyDown) _chordEngaged = false; (toEmit ??= []).Add(new ChordReleased()); - // LWin-suppression: if the chord just disengaged because the user - // released a Windows key, inject a stray Ctrl tap and consume the - // keyup so Windows doesn't open the Start menu. + // LWin-suppression (menu-mask-key idiom, AHK #MenuMaskKey): if the + // chord just disengaged because the user released a Windows key, + // inject a stray Ctrl tap so Windows sees "other input between + // LWin↓ and LWin↑" and skips the Start menu. The LWin keyup itself + // is NOT consumed — the OS needs to see it so its internal "Win is + // held" state clears. Otherwise Win stays stuck-down in OS state + // and the next SendInput(Ctrl+V) reads as Win+Ctrl+V (opens Action + // Center / Clipboard History) and every subsequent keystroke + // becomes a Win+key system shortcut. if (isKeyUp && (key == CoreVK.LeftWin || key == CoreVK.RightWin)) { InjectControlTap(); - consume = true; } } else if (engaged && isKeyDown && !isChordModifier && !isChordKey) diff --git a/test/KusPus.Native.Tests/HotkeyEngineTests.cs b/test/KusPus.Native.Tests/HotkeyEngineTests.cs index 60daf39..62febbb 100644 --- a/test/KusPus.Native.Tests/HotkeyEngineTests.cs +++ b/test/KusPus.Native.Tests/HotkeyEngineTests.cs @@ -46,7 +46,7 @@ public void Releasing_a_modifier_after_engage_emits_ChordReleased() } [Fact] - public void Releasing_LWin_after_engage_consumes_the_keyup() + public void Releasing_LWin_after_engage_passes_keyup_through() { var engine = new HotkeyEngine(); engine.ProcessKey(VirtualKey.LeftCtrl, isKeyDown: true); @@ -54,7 +54,11 @@ public void Releasing_LWin_after_engage_consumes_the_keyup() bool consumed = engine.ProcessKey(VirtualKey.LeftWin, isKeyDown: false); - consumed.Should().BeTrue(because: "LWin-suppression must consume the keyup"); + consumed.Should().BeFalse(because: + "The LWin keyup must reach the OS so its 'Win is held' state clears — " + + "otherwise SendInput(Ctrl+V) reads as Win+Ctrl+V and every later " + + "keystroke becomes a Win+key system shortcut. The injected Ctrl tap " + + "(menu-mask-key idiom) still suppresses the Start menu."); } [Fact] From dc7a2926be8d429edcbf7f53672605e9e3971883 Mon Sep 17 00:00:00 2001 From: Devang Kumawat Date: Sat, 16 May 2026 21:21:51 +0530 Subject: [PATCH 02/28] Phase 8 pill polish: full PILL_DESIGN.md + drag + multi-monitor sticky MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 " 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 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) --- CLAUDE.md | 9 +- docs/PILL_DESIGN.md | 381 +++++++++ icons/icon.ico | Bin 0 -> 29853 bytes icons/icon.svg | 50 ++ src/KusPus.App/App.xaml.cs | 2 + src/KusPus.App/AppCoordinator.cs | 20 + src/KusPus.App/FloatingPillWindow.xaml | 295 ++++++- src/KusPus.App/FloatingPillWindow.xaml.cs | 799 +++++++++++++++++-- src/KusPus.App/KusPus.App.csproj | 15 + src/KusPus.App/TrayManager.cs | 27 +- src/KusPus.Core/State/CoordinatorSnapshot.cs | 17 +- tools/IconBuilder/IconBuilder.csproj | 22 + tools/IconBuilder/Program.cs | 145 ++++ 13 files changed, 1710 insertions(+), 72 deletions(-) create mode 100644 docs/PILL_DESIGN.md create mode 100644 icons/icon.ico create mode 100644 icons/icon.svg create mode 100644 tools/IconBuilder/IconBuilder.csproj create mode 100644 tools/IconBuilder/Program.cs diff --git a/CLAUDE.md b/CLAUDE.md index 8731c4c..66de6b5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,8 +14,9 @@ Read these IN ORDER before writing any code in a new phase: 1. **`docs/PRD.md`** — what we're building and why. Authoritative for scope, non-goals, acceptance criteria. The product contract. 2. **`docs/TECH_SPEC.md`** — how. Prescriptive — every architectural decision lives here (file layout, threading, P/Invoke, naming, dependencies). -3. **`docs/ROADMAP.md`** — what's deferred past v1.0. "Phase 2+", "deferred to ROADMAP", or any v1.1+ heading = **NOT in scope right now.** -4. **`docs/PROCESS.md`** — the gate-driven workflow. Follow it exactly. +3. **`docs/PILL_DESIGN.md`** — visual/motion spec for the floating pill (Phase 8+). **Supersedes TECH_SPEC §24** where they conflict (geometry, state machine, motion model, accent line, choreography). +4. **`docs/ROADMAP.md`** — what's deferred past v1.0. "Phase 2+", "deferred to ROADMAP", or any v1.1+ heading = **NOT in scope right now.** +5. **`docs/PROCESS.md`** — the gate-driven workflow. Follow it exactly. **Conflict resolution:** PRD §scope decisions outrank TECH_SPEC §implementation decisions outrank anything else. If you find a contradiction between docs, surface it before picking a side. Never quietly resolve a conflict. @@ -100,7 +101,7 @@ When a gate forces a deliberate deviation from TECH_SPEC's literal text, log it - **`src/KusPus.Audio/AudioRecorder.cs`** — same CA1848/CA1873 suppression as the other layers (start/stop/cap logging only). - **`src/KusPus.Audio/IAudioRecorder.cs`** — exposes `event EventHandler? DefaultDeviceChanged` rather than the spec §14 prose "surface error in pill" (the spec describes a behaviour, not a mechanism). Event lets the Coordinator subscribe in Phase 6 and translate to pill state. - **`src/KusPus.Audio/AudioRecorder.cs` (REVERTED 2026-05-16)** — Phase 4 originally used `MediaFoundationResampler` instead of the spec §14 `WdlResamplingSampleProvider` chain. Phase 6 manual smoke uncovered why MF was the wrong choice: it pads the output buffer with zeros when the source has no fresh data, so the worker writes target-format bytes at SSD speed (~500× realtime) until the disk fills. Reverted to the spec-prescribed `ToSampleProvider → ToMono → WdlResamplingSampleProvider(16k) → SampleToWaveProvider16` pull-based chain — WDL returns 0 when source is empty, so the worker correctly stalls. -- **`src/KusPus.App/FloatingPillWindow.xaml.cs`** — **PRD G4 deviation (intentional, dev-only):** pill is visible at ALL times during this dev window, showing the current FSM state ("KusPus (idle)", "Armed…", "Recording…", "Transcribing…", "Cancelled"). PRD G4 says "visible only when in use" and the spec'd behaviour returns in Phase 8 alongside acrylic + visualizer + fade animations. Always-visible is intentional now so the developer can verify the FSM is reaching the right states without parsing logs. +- **`src/KusPus.App/FloatingPillWindow.xaml.cs`** — **PRD G4 deviation (intentional, dev-only, narrowing):** pill is visible at ALL times during Phase 8 development, showing the current FSM state. PRD G4 says "visible only when in use" — Cluster 8F restores that behaviour once the paste-confirmation overlay (8D) and acrylic/DPI work (8B + 8E) land. As of Cluster 8A the pill is back to the spec'd compact 360×64 bottom-center dark rounded shape (no longer the debug red center rectangle); only the always-visible aspect is still a deviation. Apply `SetWindowPos(SWP_FRAMECHANGED)` after toggling extended styles so Win 11 25H2 picks them up without the layered window going invisible. - **`src/KusPus.Audio/AudioRecorder.cs`** — RMS is computed on the worker thread over each 1/15s output frame rather than "in the WASAPI callback over 20 chunks per buffer" per §14. User-visible behaviour is identical (20-channel array at 15 Hz). The worker location avoids doing math on the capture thread (NAudio holds a lock that delays the next callback). - **`src/KusPus.Audio/RecordedFile.cs`** — adds `bool CappedAtLimit = false` so Phase 6 can surface the spec §14 "Recording capped at 50 min" pill message. Optional ctor parameter; pre-existing callers continue to compile. - **`test/KusPus.Native.Tests/`** — 5th test project (spec §1 lists 3). Pure-helper coverage for `PasteEngine` (terminal-detect, friendly-name, SendInput payload) and `HotkeyEngine.ProcessKey` chord state machine. LL-hook itself and end-to-end paste are Phase 6 manual smoke per §33. @@ -113,7 +114,7 @@ When a gate forces a deliberate deviation from TECH_SPEC's literal text, log it - **`src/KusPus.Native/HotkeyEngine.cs` — LWin keyup is NOT consumed** (spec §13 says it should be; that turned out to be a bug). The Ctrl-tap injection still runs (AHK `#MenuMaskKey` idiom — masks the Start menu) but the real LWin keyup is allowed to reach the OS so its internal "Win is held" state clears. Consuming the keyup left Win stuck-down — every later `SendInput(Ctrl+V)` from PasteEngine read as `Win+Ctrl+V` (opens Action Center / Quick Settings) and every later keystroke became a `Win+key` system shortcut. Validated by AutoHotkey community docs + PowerToys issue #35345 / #18175. **Spec §13 needs an update** when the user is reviewing — flagged in conversation, not edited here per CLAUDE.md rule. - **`src/KusPus.Whisper/WhisperRunner.cs`** — empty `expectedWhisperSha256` now skips the integrity check (dev mode). Phase 12 release builds populate the SHA from `installer/payload/whisper/SHA256SUMS`. - **`src/KusPus.App/`** — Phase 6 milestone code. Several v1-only simplifications, all to be revisited in their planned phases: - - Pill: plain `Topmost`/click-through/`WS_EX_NOACTIVATE` window with a single status text; no acrylic, no animations, no visualizer (Phase 8). Position math uses `System.Windows.Forms.Screen.FromPoint` (good enough for one-monitor dev; Phase 8 switches to `MonitorFromPoint` + `GetDpiForMonitor` for per-monitor DPI). + - Pill: plain `Topmost`/click-through/`WS_EX_NOACTIVATE` window with a single status text; no acrylic, no animations, no visualizer (Phase 8). Position math uses `Screen.FromPoint` + `MonitorFromPoint` + `GetDpiForMonitor` to convert physical work-area pixels to DIPs (moved up from Cluster 8E into 8A because mixing units silently dropped the pill off the bottom of high-DPI screens — see dotnet/wpf #4127). - No MainWindow yet (Phase 9). Tray menu has Toggle + Quit only. - Single-instance "bring main to front" broadcast deferred until MainWindow exists (Phase 9). Second launch just exits silently for now. - `UseWindowsForms=true` alongside `UseWPF=true` to get `NotifyIcon`-style tray and `Screen.FromPoint`. Triggers ambiguous-`Application` references — App uses `using WpfApplication = System.Windows.Application` and qualifies the partial-class base type. diff --git a/docs/PILL_DESIGN.md b/docs/PILL_DESIGN.md new file mode 100644 index 0000000..54fb6e9 --- /dev/null +++ b/docs/PILL_DESIGN.md @@ -0,0 +1,381 @@ +# KusPus — Floating Pill · Design Specification + +A lightweight Windows dictation utility. Press a hotkey, speak, and the transcript is pasted into whatever app is focused. The pill is the only UI the user sees 99 % of the time. + +This document is the source of truth for visuals, geometry, motion, and behavior. It is implementation-agnostic — translate the tokens below into whatever UI framework you are building with. + +> **Relationship to TECH_SPEC §24** — this document supersedes TECH_SPEC §24 where they conflict. Geometry (200×56 vs 360×64), state machine (5 explicit states vs ad-hoc), motion model (damped target/value vs raw amplitude bind), accent line, and confirmation choreography all originate here. TECH_SPEC §24 is to be revised by the user; until then, this file wins for pill work. + +--- + +## 1. Anatomy + +### 1.1 Surface + +| Property | Value | +|---|---| +| Width | **200 px** (logical) | +| Height | **56 px** | +| Corner radius | **8 px** (Win11 surface — *not* iOS super-ellipse) | +| Material | Mica / Acrylic on Win11. Solid translucent fill as fallback. | +| Drop shadow | Two-layer: large diffuse + tight contact (see §3.3) | +| Inner highlight | 1 px top edge, very subtle (see §3.3) | +| Border | 1 px hairline, low-contrast (see §3.3) | + +### 1.2 Position + +| Property | Value | +|---|---| +| Anchor | Bottom-center of the active monitor | +| Vertical offset | **40 px above the taskbar** (or 40 px above the work-area bottom edge if the taskbar is hidden) | +| Re-centering | Every time the pill appears | +| Z-order | Always-on-top | +| Hit testing | **Click-through** — mouse events pass through to the window underneath | +| Focus | Never steals keyboard focus | +| Taskbar / Alt-Tab | Hidden from both | + +### 1.3 Content rules + +- **No chrome.** No titlebar, no close button, no app icon inside the pill, no window-frame shadow. +- **No internal padding bleeds.** Use a single fixed surface size across all states so the pill doesn't jump when content changes. +- **Horizontal inner padding** is **32 px** on each side, so all state content sits within a **136 px content track** (the same width as the visualizer). + +--- + +## 2. The Five States + +The pill has exactly five states. **`hidden` is its resting state** — the pill should be invisible whenever the app is not actively in a dictation cycle. + +### 2.1 State machine + +``` + hotkey pressed + hidden ──────────────────▶ recording + │ + hotkey released / VAD ends + ▼ + transcribing + │ + transcript ready + ▼ + confirmed ── 1 s hold ──▶ hidden + │ + (anything above can short-circuit to) + ▼ + error ── 2 s hold ──▶ hidden +``` + +### 2.2 Hidden +- Nothing on screen. Window not rendered (or fully transparent + non-interactive). + +### 2.3 Recording +- **Background:** dark surface (see §3.1). +- **Accent line:** thin, glowing horizontal line at the top edge, in the brand accent color. **136 px wide**, centered, gradient-faded at both ends. +- **Center content:** 20-bar audio visualizer (see §4). +- **Micro-label:** the word `Recording` in uppercase, 9.5 px, letter-spacing 0.08 em, muted color (`subtleText` token), centered 4 px below the visualizer. + +### 2.4 Transcribing +- **Background:** same surface as Recording. +- **Accent line:** same color, opacity dropped to ~0.55. +- **Center content:** small spinner (14 px, 1.5 px stroke, ~270° arc, 0.9 s rotation) + the word `Transcribing…` to its right (8 px gap). +- **Vibe:** calm, thinking. Not alarming, not busy. + +### 2.5 Paste confirmed +- **Background:** same surface. +- **Accent line:** opacity 0.4. +- **Visualizer:** still present, but at **35 % opacity** with a **radial mask** that fades it to fully transparent through the middle (so it cannot collide with the overlaid text) and back up to full at the bar edges. +- **Center content:** single line of text, fades in (~200 ms): **`Pasted into `** where `` is the bold-weight name of the previously-focused app (e.g. *Slack*, *VS Code*, *Notion*). +- **Hold:** ~1 s, then fade the entire pill out. + +### 2.6 Error +- **Background:** same surface. +- **Accent line:** **shifts immediately to red** (`#FF4D4F`). No fade on color. +- **Center content:** 5 px red dot (with a soft red glow) + brief error text. Examples: `Microphone blocked`, `Disk full`, `No internet`. +- **Hold:** ~2 s, then fade out. +- **Constraint:** error copy must fit on one line within the 136 px content track at the error font size (12.5 px). Truncate with `…` if longer. + +--- + +## 3. Visual Tokens + +### 3.1 Theme — Dark (default) + +| Token | Value | +|---|---| +| Surface base | Vertical gradient: `rgba(38,38,40,0.88)` → `rgba(28,28,30,0.92)` | +| Surface effective fill (Win10 fallback) | `#1E1E1E` at 90 % opacity | +| Border (1 px inset) | `rgba(255,255,255,0.07)` | +| Inner top highlight | `rgba(255,255,255,0.05)` | +| Primary text | `rgba(255,255,255,0.96)` | +| Subtle text | `rgba(255,255,255,0.55)` | +| Visualizer bar (active) | `rgba(255,255,255,0.92)` | +| Visualizer bar (idle) | `rgba(255,255,255,0.28)` | + +### 3.2 Theme — Light + +| Token | Value | +|---|---| +| Surface base | Vertical gradient: `rgba(248,248,250,0.90)` → `rgba(238,238,242,0.92)` | +| Surface effective fill (Win10 fallback) | `#F3F3F3` at 90 % opacity | +| Border (1 px inset) | `rgba(0,0,0,0.06)` | +| Inner top highlight | `rgba(255,255,255,0.70)` | +| Primary text | `rgba(20,20,20,0.90)` | +| Subtle text | `rgba(20,20,20,0.50)` | +| Visualizer bar (active) | `rgba(20,20,20,0.85)` | +| Visualizer bar (idle) | `rgba(20,20,20,0.25)` | + +### 3.3 Surface effects + +| Effect | Value | +|---|---| +| Material | Mica/Acrylic on Win11, solid translucent fallback on Win10. Approximate with a blur of `radius ~40 px` + saturate `~140 %` over the desktop. | +| Shadow (dark) | `0 12 32 rgba(0,0,0,0.45)` + `0 2 6 rgba(0,0,0,0.35)` + 1 px inset border | +| Shadow (light) | `0 12 32 rgba(0,0,0,0.14)` + `0 2 6 rgba(0,0,0,0.08)` + 1 px inset border | + +### 3.4 Brand accent (recording state) + +Default is **Mint**. Three approved alternates are provided for theming. + +| Name | Hex | Use | +|---|---|---| +| **Mint** *(default)* | `#4DDBA6` | Primary brand accent | +| Warm amber | `#FF7A59` | Alt — warmer feel | +| Cool azure | `#4EA3FF` | Alt — cooler / pro | +| Soft violet | `#B48CFF` | Alt — playful | +| Error red | `#FF4D4F` | Reserved — error state only | + +#### Accent line geometry + +| Property | Value | +|---|---| +| Width | **136 px** (matches visualizer) | +| Height | 1.5 px | +| Position | Centered horizontally, flush to top edge of pill (top: 0) | +| Fill | Horizontal gradient: `transparent → accent (50 %) → transparent` | +| Glow | When recording or error: outer blur ring at `accent` × 80 % + offset glow at `accent` × 40 % | +| Opacity by state | `recording` 1.0 · `error` 1.0 · `transcribing` 0.55 · `confirmed` 0.40 · `hidden` 0 | + +### 3.5 Typography + +| Use | Font | Size | Weight | Letter-spacing | +|---|---|---|---|---| +| Body (transcribing, confirmed) | Segoe UI Variable / Segoe UI | 13 px | 500 (600 on app name) | −0.005 em | +| Body (error) | Segoe UI Variable / Segoe UI | 12.5 px | 500 | −0.005 em | +| Micro-label (`Recording`) | Segoe UI Variable / Segoe UI | 9.5 px | 500 uppercase | 0.08 em | + +All text is single-line, `white-space: nowrap`, ellipsis on overflow. + +--- + +## 4. The Visualizer + +A row of vertical bars that respond to microphone input amplitude. **It is the message** in the recording state — no text needed. + +### 4.1 Geometry + +| Property | Value | +|---|---| +| Bar count | **20** | +| Bar width | 3 px | +| Bar corner radius | 2 px | +| Bar gap | 4 px | +| Track total width | **136 px** (20 × 3 + 19 × 4) | +| Track height | 28 px | +| Bar height range | 4 px (min) ↔ 26 px (max) | +| Bar fill | `visualizer bar active` token | +| Bar glow | When a bar's level > 0.7, add `0 0 6 px {accent}@33` shadow | + +### 4.2 Motion model + +The visualizer must feel **alive but not chaotic**. Use a damped target/value model, not a direct level read. + +``` +every animation frame: + dt = clamp(now - lastFrame, 0, 64ms) / 1000 + + if (recording and now > nextTargetAt): + nextTargetAt = now + random(90..150) ms + # voice envelope — louder in center, softer at edges + speak = 0.55 + sin(now / 380) * 0.25 + random(-0.125, +0.125) + for each bar i in 0..19: + center_weight = 1 - abs(i - 9.5) / 9.5 # 1.0 at center, 0 at edges + base = 0.18 + center_weight * 0.45 + jitter = random(-0.30, +0.70) * 0.55 # asymmetric: more chance of louder + target[i] = clamp(base * speak + jitter, 0.05, 1.0) + + if (not recording): + target[i] = 0.05 for all bars + + # damped approach toward target (per-bar rate so they don't move in lockstep) + for each bar i: + rate = recording ? (14 + (i % 3) * 3) : 6 + k = 1 - exp(-rate * dt) + level[i] += (target[i] - level[i]) * k + + # render: bar_height = 4 + level[i] * 22 +``` + +Key properties of this model: +- **Targets re-roll every ~110 ms**, giving a natural breathing cadence regardless of true input rate. +- **Center bars run hotter** than edges — gives the visualizer a "voice shape." +- **Damped approach (exponential decay)** means bars never snap; they ease toward each new target. Different rates per bar break visual lockstep. +- The `speak` envelope rides a slow sine so even a steady voice doesn't produce a flat row. + +If you have real amplitude data, you can replace `target[i]` with `realAmplitude × center_weight × (1 + small_jitter)` — keep the damping pass unchanged. + +### 4.3 Confirmation mask + +When in `confirmed` state, the visualizer renders behind the text at **35 % opacity** *and* with a radial mask: + +``` +mask-image: radial-gradient( + ellipse 60% 140% at 50% 50%, + transparent 0%, + transparent 35%, + rgba(0,0,0,0.55) 60%, + black 88% +); +``` + +Effect: bars are fully invisible through the text region, fade smoothly outward, and reach full visibility at the edges of the track. This lets the eye lock onto the text without losing the "I'm still here" signal. + +--- + +## 5. Motion + +The motion language is **understated, snappy, no bounce.** Think native OSD, not web widget. + +| Transition | Duration | Curve | Notes | +|---|---|---|---| +| Appear (hidden → any) | **120 ms** | ease-out | Opacity 0 → 1. No scale, no slide. | +| Disappear (any → hidden) | **120 ms** | ease-in | Opacity 1 → 0. | +| State content crossfade | **150 ms** | ease | Old content fades out, new fades in. | +| Confirmation choreography | see below | — | Multi-step sequence | +| Error accent color shift | **0 ms** | — | Instant — no fade on the color, only on the surface appearance | + +### 5.1 Confirmation choreography (transcribing → confirmed → hidden) + +``` +t = 0 transcribing showing +t = 0 transcript ready → switch to confirmed state +t = 0 visualizer ghosts to 35 % opacity, mask applies, accent line drops to 0.4 +t = 0..200 "Pasted into " text fades in (kp-fadein: opacity 0 → 1, translateY 2 → 0) +t = 1000 begin pill fade-out (120 ms) +t = 1120 pill fully hidden +``` + +### 5.2 Visualizer entry / exit + +The visualizer doesn't get a separate fade — when the pill appears, all bars start at their idle level (0.05) and **damp up** to live targets over ~80–120 ms naturally via the motion model. + +### 5.3 Reduced motion + +Respect the OS reduced-motion preference: replace the visualizer with a single subtly-pulsing horizontal bar at the same position (or simply hold all bars at the mean level, 0.45). Disable all fade transitions to instant on/off. + +--- + +## 6. Behavior + +### 6.1 Visibility rule +The pill is visible **only** when the app is in one of: `recording`, `transcribing`, `confirmed`, `error`. Hidden otherwise. There is no "idle" pill — if you find yourself drawing a pill outside these four states, the design is wrong. + +### 6.2 Click-through +Mouse events pass through to the underlying window. Implementation: set the window's hit-test region to empty (or use the OS-level transparent-input flag). + +### 6.3 No focus theft +Hotkey activation must not move OS keyboard focus. The previously-focused app must remain the paste target. + +### 6.4 Hotkey +- Hotkey is configured elsewhere (settings — out of scope for this spec). +- The hotkey is **push-to-talk by default**: hold to record, release to stop. Tap-toggle is a secondary mode. + +### 6.5 Multi-monitor +The pill appears on the monitor containing the focused window. If no app is focused, fall back to the primary monitor. + +### 6.6 DPI scaling +All dimensions in this spec are **logical pixels**. Multiply by the current display scale factor when sizing the native window. + +--- + +## 7. Design Principles (Tiebreakers) + +1. **Native to the host OS shell, not a third-party web widget.** Match the surrounding system in proportion, rounding, and material — diverge only where the brief explicitly says so. +2. **Invisible until needed.** The user should forget the app is running until they press the hotkey. +3. **No chrome.** No titlebar, close button, app icon, or window-frame shadow inside the pill. +4. **One-glance readability.** Whatever state the pill is in, a user reading it from 2 feet away should understand it in <200 ms. +5. **Single fixed footprint.** Never resize the surface between states — only the contents change. + +--- + +## 8. Out of Scope + +- Settings window +- Tray icon variations +- Onboarding flow +- Sound-wave squiggles, frequency-domain graphs, oscilloscope views — the **20-bar vertical visualizer is the only audio representation.** + +--- + +## 9. Quick Reference + +``` +Surface 200 × 56 px · radius 8 px · mica +Position bottom-center of active monitor · 40 px above taskbar +Visualizer 20 bars · 3 px wide · 4 px gap · 4–26 px tall · 136 px total +Accent line 136 px × 1.5 px · centered · gradient-faded ends +Body type Segoe UI Variable · 13 px · weight 500 +Micro-label 9.5 px · uppercase · letter-spacing 0.08 em +Mint accent #4DDBA6 +Error red #FF4D4F +Appear / hide 120 ms opacity fade · no scale · no bounce +Confirm hold 1000 ms +Error hold 2000 ms +``` + +--- + +## 10. Hover-Extend Override (user request, 2026-05-16) + +This section overrides specific clauses of the spec above. It exists because the +pill — as the only persistent UI surface — needs an escape hatch to quit the app +without the tray. + +### 10.1 Hover-extend interaction + +- On `MouseEnter` over the pill surface, the pill animates its **width** from + `200 px → 280 px` over **150 ms**, with `CubicEase / EaseOut`. The pill grows + **rightward only** (left edge anchored). +- On `MouseLeave`, the pill animates back to `200 px` over **150 ms** with + `CubicEase / EaseIn`. +- The right `80 px` column reveals two circular icon buttons (28×28 each, 4 px + gap), centred vertically: + - **Settings** (`Segoe Fluent Icons U+E713`, gear). Placeholder — non-functional + until the Settings modal lands. + - **Close** (`Segoe Fluent Icons U+E8BB`, ✕). Calls `Application.Shutdown` on + click. This is the user-facing "Quit" path. +- Hover state on each button: rounded background tint (white @ ~13 % for + Settings; red @ ~20 % for Close). +- The right column's buttons start at `Opacity = 0` and `IsHitTestVisible = false` + when the pill is not hovered. Both animate to `1` / `true` over 150 ms during + the extend. + +### 10.2 Overrides + +- **§1.2 Hit testing — "Click-through":** REMOVED. The pill is fully + hit-test-visible so the user can hover-extend and click the buttons. `WS_EX_TRANSPARENT` + is NOT applied. Clicks on the pill body that aren't on a button are silently + ignored. +- **§1.3 "No chrome — no close button":** REMOVED for the hover-extend column. + The close button is part of the chrome and is intentional. +- **§1.3 "Single fixed footprint":** SOFTENED. The footprint stays fixed across + the five content states; only the hover gesture resizes the surface. +- **§6.2 Click-through:** see §10.2 first bullet. + +### 10.3 Preserved + +- **§6.3 No focus theft** — `WS_EX_NOACTIVATE` still applied. Hovering or + clicking the pill never moves OS keyboard focus. +- **§1.2 Hidden from taskbar/Alt-Tab** — `WS_EX_TOOLWINDOW` still applied. +- **§6.1 Visibility rule** — pill is still hidden whenever the app is not in one + of the four active states (`recording`, `transcribing`, `confirmed`, `error`). + Hover-extend is only reachable while the pill is already shown. diff --git a/icons/icon.ico b/icons/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..1b7c7636c4744fbf0ae44ef751a35b1b1a4b95e2 GIT binary patch literal 29853 zcmeHv2Ut`)w|0hJrHBHefT&n0f)oJ(5d=j6MMacmr71;}j*K8G%?cKz+7J~HsnSuZ zNR^`WB3&4Up-G!R85kVVbFSa{&iU^B?{oL_gzTNH^{$od?5wPn3;Dx|Ec;Cb7)rmqJ5k(}y&chi8Ehn?)QM~2e_NR(R@RYo_U_#aY=4B+H26ul8UKmc zJWx}GXu1iVM3g#lo&3%Cngmn_^*Jz)pSG~}tr#q$?VpU9|!qyBz{<)f$F{v zeKl&r)mkB*rh(d%@YxuZqk6=TgY@v8#E3#@rj2j&lfEey5 zNBvB`5I-y@;)e)7@k4nTs167D$@KGcq|4wip@c9rbQ{%?un-TFk0%hU;d95};2?T$ zzjfh0$m)JMl8;W=#O$M2+4c~D75AaYQGJdFg zi~0dteL&TT?=&Xcm~KJ`2_MbLXl^DYI8P(2?!k_X?{u9AAB|bY!rFB7Z6hqwyQ}N3 zOsemzB-Z1ql3*F2unluIr4P>8$fl6eEIzUgWSLMtNq>lSnS|9}+1NyEqU#>&UQD3_ zaRwjSf?2FSvdw|`B%6cEf54xS3FVXUy9Yam$#|WFPQ@7;GpiTmMffweX~u>@{TfMS zB>Wk>gzOM1L;90tLL1~hQ}!K;WEbHaK4ZUTcu82Zwhxs<`}ZgKWZMUMXYnbq$oOP? zhjKDLMcFJq9`+BigLv3KgCzYS4aqhlS{wmS(w~A4$y7o%0NTkBWRp<-XZUlxe}+Fi z*g>{$_+dO6lhED_L%TfG56ek9AX`bY#}qah>Gu=Xltj)$j{yCYm`jc(sQZ1 zLqptftuZnPd0`^86ZIXrO>iwS`+f%B!7@b( z=|AyM)cx))!a!?WG`1)cbrY^h0IutZKO|fol8@HHl(gs`Va(|c@BfvHp}Z~UR8>z= z&Bw07p!n=3+>n_H#xDdq23v@fsguxzGMfgwDCE!j1hc*YS`#7x zkT!IVeuj@MpEQ==LLVW$G)@r7gozY~tLglpQv0>MA0{G9`LLX%+Yi3SY)lE(&9?vh zdYTlInw3wEC6WCH_)M0M`TVu)E=fjT*+@-K=~0A*t`2;2bO-bS=h8sac2xd@Z$k1b z`sdm|x4wtvr28M_Q|LA;zk8@@@f<#qg>NRs^pG6r7d65d8}f-r3HeE=9IfpspGjy zNJsJqpnOU^O8MW*QA`l=pz;4BJ;@Iujla2A;4B{5C!3M~9SiZ3k_or${`rJ_C|ZDFfYqmp|A@;bTDgGcmy*d>cp)dFJHL$3plB z58{OWP&X%kHkLUX!$k7P^5Gggk?bR&95+0ujq2w4J}CD2d-5q{Abk)Pic?a`faM*i z99?K#Jm>E~zX~oQEQCojEX8TXbC+qP$`HXzBA4O3jABOG!&hzj1R45KW zi3NR1QXHHV|Aym~vY*NFVGiTs-{qs{-)ZX~(V*BnlshswG?YhvZ+|9#CT2gJ2-^*P z@Zm)ASpFmYzf%5Z^8X6nzsfV?(|+$`)ZgU(|5vg83O$N*eD~Q&v5tQ^j`3IIQu2_- z!%Un7jl+(A;yqK!n}3huFlc`J7NWCUhpF zs;i)^s;98#V@ICv_3O?Yuzb!p{GZwYs28TDujI<7z7pc6ud@l>*YuYpp}POUde5m= zKTt8;Fz^w^(0)w+U@UD;_P=`r<8AzNd#LD44rRjCZ25Qb{|0{Y+=#~dPxB&*A;P_F zxUcOFh}1ZWkThsEda$JTk-WUF7|V3n~6mtGV(l2i9xHs)sSeCIRvu`TV?XCYFiF=CIItErj*HE?p6pQ?>!o z{hb%;Gy1CTqI=}{Iw{5upHVqXB>VcS7%VyNHyevZIw4y6iZ2DHs(U|-knyT|J|y40 zdzTH?{UhV34p*TE&xOE!|BuAF{+XO~kE@uA#gbyUgYazHFA_PHiyDApzz}a_K308y zg*&SMC$8qfPa)*E{=7VpEDz~VJ})poN9PB~bWONVR`W2(dj`d1n!h`T-(*G`{4Re= zgAJru43ak!!|s7G>|dpsSTdq(!`He0gfoNPfa?F^g}zfXHc|S?R>HG585C!7$Y*m< zKBqtOf%}HO!g%S=3B@|edd)lEGl%tMu=!W||EXRO^H*~tnj_G6IrlA*&sNT!y+r3P zXH)wR`p?Cv$>(e4#nMq5;J4uZou73kj{C2%x+(QXvPp9)Sub=3YqkvOOdcaN zRBy-I6}YSUIx&;S_U&LGFQf5?aX=IKunr-w0V^4TGBEEQtH zJ%B#W+-OHAMy8q3pW=KQ#l8@EF9*@g^#70hIdgkL zKlT65bma4OWVwH&7kYmbG*JJau_*VF5G~rL`W4+jtN*X{`roGi4|`iPdtzu`?1%9K zpXbgm{y;yw7lq!@?~fmf^Mw@hAnp&}?+kFFFSIX7x#vT9Po_cne~12*y8L+mUH_T=q#yT<&>j-` z{3^K(u-&BJc98pkBL89i@LT==HO&MQ1$oy8$u%aEGe1pxAp&{ zF-vi#^%r}{Gw)zt|IhB*g?`s-sDJ)`Fj+rxJLb2ITo1=z2KoI_=9GKEX#OFMgI~0P zaxV??yHjG!-Orv+Cd(w}q%*+7!!qROzmorV>i<{tozL_Czy6fxzrTM^lzZuD{QNl2 zeczA&dHj6GnEO6w&!5llyT9WZ?bFZ6p_mWI`}au3jQ&5*#b|s}>JRUK*v}{9!gCaP ze}}HW!XTZ|nBCi@m=BtE5I+-`(5-`0@p{?qxd&QOrYHs$#L zPwVx&^8Qc%-|eY?h7OHm@?0`MC;xuxpOO6cp~AVOgknx1&n^A`Q@+vvs--h)S<-KN z=5&Yc`Ty!H&dfL()<$zYT08z%ZT`Rd|LcMOjvnyV(Nbq+T7u5FuVpW+pDeuUY0EFh7UBRTX$~-Aod#b_En^DCP z?O?_hQ`r6xAhne7PC&4ZTXi?ii(RpTOKQwN4bojJ$QkB5lGZt{z=Ns z>_Mtux#lN_M|5>d+RoN|dhdKZmbjI+IdxiQ`M3Pc(<-;NZ)M6DD1AOn&(VxOc4l#> zTJkfZhCg%965qQXrRhb_h+Am}Xg6OYEU`Ji-Jka!ub1H6aO0=bf>^e95R}eDig-Zo)9EFn=FWXr}QLoU{rQ z$6O4r@l$k857-tax3RP*)9>#Vb@#RJ>>t+{C&yRevble`i zcR;`m&Fxc68R1SPp6y`4o3TwBPS|B5C{@rpR6enZ9*xtGgKSU#>~_&?QZ^;{ZdbMhCGL@-ru9J zZz?r$$8)`+^SifW@@*mE5xH&b;l*{l!Q7s-p9Yc?bA~O;f^0YH=7zMI`+gdh+IyR4 zZ0XfD9s>qp1MKznPs7Y^JRGHE9{TB738@II;L1W3DF z+Tyq_(0M1T1>vgSL#dS*8zUA@V4<)%UBOPe=}?%SOJS0TjGp26)25=2J9jaKURPk7 z9LNv3gR5o671!KR^sFh3F?Qc5Tg|rgpl8CDOcMrd3_snH1IL!u7F_&NWpm{r6OamJ zvbeX+IBhf<(EZ@s6tsUFvzt(vqUsfznXk@*`hk_4-@0o9# z7HM3GmTD7U?awW~%U|x$hjp<{5`*hrIGs46dH8VI_LH=GQ_^~M9)VsHRt6gzO-8$E@ zrkixWYz(j3Yfda(bV!#L`)Q#=O=qKP*`eq6J7YVnOQy#;8$;EP)Edd_78)1yy>Pu> zryp|9?sg^Eb{4JzoDNBrvQqqzX6vtV8U9+cJwK_xen2 z_WJTMm`sXq4LFOLF50|3Wx?6b(O+7=Bpds!-I^B#=Y*j<-Z0 zc!k;dv584Vmh(DUQ}nb?vuZeeYJ6T_2@xGh%-0wwDKEvm5nQdv7JPVO4|= z{P{f_0X&n~U{QtPAF^TP4I1QR=B{>t}@<#WZj8`L=q9DEEt zMtvWREgaFlneB0oiT~A?;6NNsrsmQ6CjY7?lNek~&kBK4S8GicT|V9Gh3#(r`o<+8 zcYWOO+nUTfb^}fAbot{Md4$Vtr(StQvG!lSp=Gary62!TUoN)P#8oROcgr&-;S^Q3 z;kI3F`vMD|yKVT&nA`pS{v~OeJJ1k;9co&tFI7xDeqqwEuo_D;Y3~$|6v2mo*`%@Z z&6>0jZ9hJ}W%-6{B5cB2sa6LC*DefLxSwsMOmf2N_p}#f1I}R2s0jL4ET_4AV4s@G zDwR!~8eBdBJNGZ&z_~+nXHsh6Nr$-9M2EIvS)-V~g#3{Y_m%FqZBueFmY4{M?M%N{ zTb-8RYFwn0zKBf@8_G3ArDz*-A*H=M?9z9XF#tjT9m z@|ab;vx4rfojLV~HwEBjsiRyx=E(s_B;4c>I^uSF)UDH}b2Hu4XjiFrTcX7H8rEi~ z>NfRbnP)5g&a@AdIeFcVjFM+e3bWjNmB2I9^wpNpx!t zJ|FfI)~2sJLbcC!%LIGdgVz$u7LpUEGJ>>m^|4yVsBU6y_vrIx5$;|%rki^%+K$KJ zWMClXZt37vQb%bSeCEx1FP~AY`+(J^;&uPQ$ahJH&WVezTTh+LFKKh?y&3gWPuo4U zVBxycScZ5^Wp?tK(^$qpX=R#P&|_i{Af4sLF49tAxpX?+CmtiDVU9hr;=SA5-AiBE z)hY2Nn$yW*sbz53mwVM|u#%2pp(%FAp(DF4kB>)Y zWe+xc+5RJ;Qi(MoELd+Xsyww5CqTz%y8cL?MaQtWk|pIO*t%;${yll>GRC&P9g9wb zbH`b*PFh%L?7CXOSVpI#&L@cB;g;HjRcqVf*EZT&3S?#XzhK;mZ9n8x7w5Q6I0p=E z?!SfIA)!2Gp-T$~fTVI$R&11n10BOEImilo`&5ke#Sz`(9xP><*#3QLe9FrKshmBz}!w+dhRL)PoS&$oh3c_mBP=mhtRBOD*YS;MdRVT1-> zs2W3+2#9-e6x-J(t>ij3LtHet!H2A`m>SZ??!q%)rt;w3?1*3bs z1}~t2gq2pN63zur>QYTo3_~wTf27k9&I3;@L*FSpudq$Q({&|xcqLj6Zi+v6_$_F; z%(WkmMtSNXmZkS4Hqx2B>Dc@sPC{#SBiJ1?b^nq}*2h=4#L(>It>)CK&4)}kTIoy} zO=#Zjq?ugUb}8Nzrxe#2@3`cdR@)vwz5qCCH_{3A#ooU5=D^!6V~g@wqAU{lqvTlE z9Tj4UEPr=*jf8RzwGSFtff#3_QrM3|$G|&tgFM&ugIsxjm7>q}*1afMc!r}b0`{|* zKuQJIy_MAa4o>A>an+2qteJYIH?=g+Z(rfF(1s&YTBqug9`l?kvX~O__B5lucw?h- zpq_T$!Gs4EhF5MHPVwjQMbb-8v^QA~uQz!0xz1nJ)u>BvEJc4>w9hwtc1*rWH*a*l zc}C>$o#m;x_my5lu?C`}CZc!bhsTwSh##K4nTj0l7vax8aIQRF3+{2@8)*wOv-tfY_<4~Xj96#qltjW(Xwoz-DN4qLZs70 zwVCtOhU<;^V>X>w$znp1^TCW-XTjP#*(nM`CAIC6>rWoR^x9rK>Tc`ar~hnurJ6x_ zzUoVc#bNexdF_L(pPvs`32az)YWhfQ0=~i}p~{+QX{qB_e_-h@S<&Mjx2xnDYjXd4e-GSAP)WK&BpH`1HfEAf^1wbUJ_@f7! zF~hqPS(o0Ii?*fXaWXw{{1HZDD4l)N)|=EmQ$p5~D#x*x>N?D`lb^m|f#VNSt{XO^ zzCO^qdAFe|ep@McbWA(wm2SY=0i0UPu`uaEFrnoe3BOJWN$f{b12%&0{yJVomYvmV zmBJ&e{ww{I>A6m_(_d9_74`UNlFuJ*k)Va!Om4P|);l;DIgbI(_JA{dX&NVbRTBI} z6nvx?(=UQNTn2Koyx|?2oDPYL`mkVeb??X04^meKUxKs#<0aPEaF2v+XPVvz2KRTV z(mj=%P!QqaK9O=#aVR6&=OTv16wVlH+p71oh&KpP^OPu}H=1B5*%YC5cV7-)c0OF52`c^rT6kxaT-TSC5DCU?! zrAX=l62UX$++@k<(+7IJAHP@qlt7iInow__A$9kJrq;*4oFyigt3)s5MPB(JF}^D* z$Io}wf%d|U(;gf5uqK6Ar<@MG>PXEM-9u=>uQlOg_1jEXcd&}v*FJD{-ZRB(8}}Rs z!M3_^N~ly`xFn2o`zh|K59aTp6jRLg79^eWv_70|?k(~Tj864k(pehjK6c2m z_wk~*oVMiiI*#6ncMKhcsG9?xuRF%V{zf^jzTWjn?gswWPut>|neW_geCU2$b3-*v zjqb_40k2G6Ulq|e5;c}jtry*3Ajy2W=7UhCddv0cP(^{*oE`Wzwh_-G-!J)Ar{x*B z5-uxV+|Z1#T$|wfYIx*KWSP==QMHWv&xf3eM$Z?AUK2m}C?e9Z!k?A7oh>XpQFvVJ z{Nf8523_x?UOOvp>U?>H<1~=FUHQhKHhGdKggK$I#gyZUU4%h#K$p+v!s;i2D_C`o z3@enpd48(|_*|J(wcootwO_qL^=(%ouQ`}hIv!x#vxkn|*mU*ju6ya!*K-V;S8}c% zVN+zd^zyF6^LB^N{My@cKl@534;ToAQFAda**s`)Qmr+i`_p}8nmvV#LQG1Dx!x=~ zCtVa4mD35Ha2AadE-w^%DRd)i&kn9IZ`B>?GPqZV`1KkBcu#|fV|Ey(P7klJ!qr6C zJ%b)1rU5y7uB`Nyjy!k+V^g(z5hFE2LycYe&T!7>9Z8>0hcb00zQDu_?_TwM{Al5m z_UbQOr`xXa=vnSm)y!*otfzJtFOOEL>t6ThF(qbyjMe;@c1 zuI?CEE1X*`d{>9o*Zsx7*6ESyI@6c^4Pz~rql=ZqmYsSjb}n(JCOw~ZM$^Fw{A2&o z48J;0j(A4lgF}W}6313!#j_1W>vyH7zT2^wjzKES%x#F%P;RsioMmyNzpK1s3-u#q zCF)%la@L2N$uA6%J-AEXBSe8qH{?xR647e*SWUMMpT--c@BfxF*}_t%7P|4mIIA5aTFeR`t0!1@Ex8)_6Z= z_)33NX(gvh!B(wDh8KiRGx*5F`f+=_?i)5TwRh-mu>0J7FVu3>z&Rzw>fJFl-^uNu${4kC(|O+ohd*nnv^YdkQ-G5tjlJ zn()>IRHJXiheR(%)G%!mtKg;Sw`EV}e)noB!Famj8;>LcC?GO zo)?H;3^u?wq&#^l>BPz3ubJisvH+)Axr=8O5hHe-`gYe<4*@nHE~s;X?t+7^ZrXMh zD(hu9Rv;dJF~Y%{JKCv1t)3Bxn=BP@Y1+;GTuKbzMhz}9Y{T*HzHz8(w4u{U3H~_X zVuVaFb7h8ANTbacHw@sql2fI0+sJ-k8vbzb;qo6ln2vlAZLrW#NQ;KTqdNMnDj3V@(8d&-chtS$U`;vZ1}G1awy zU-ei7AFIa#Du8Wm@mjMAp5DheF5l^m;@(hEA&{k^QkqVf8VS1;;m|TIIaV1tP(c(c zr=X4s?B^KGKzWCbQt%SeUR^j%3;vz*)9`m3qtSN9#En7IE3OLwz3i zfpGa)L9e_N^iA>RW#9^6b zB_`M#2ai8$a$0NBU+Rh27l9BqBQL9vYlk_Uw@%PD3Wz@_>ytGGYC8lLw@wu@!?(Vh z7K=+QCE(ZXWHPO%RiHU*G-0_|`9h*&*x+0EE_5;MW$KO}+xFj0oIXTN$Dnl9xOJg% z{1Zk0JXpIBql}w$FcLD)d%k9OW5@-Jm*Rr=t24~jUUbYHM1yf5MsPTT<>RLYULKvv zSB9O34>3Ak7+ueXl;Z?YrDEAdG1)Ta+2Cf74X@^&bTogGMM*^@gOVhd}9z}_;$Jr=<$S(du4v)Q*5eB!`> zOCEceL5y9|Ix#B1LdyW;;o#H3f>$dj9+fd!OFjQGr8L-n4eFag{U3jxX zXWOtycY(3K_ww5I-Dew9a|AM9)EGW8jr;g8#qn?=NvZ2>DeR$KDKa51*`hygEjz!p zhw54T7mKE~leeF|OLvaAa_a$TzOR4f3eeb!xxQpI#+L!491Jy>tUtC}BzWx<|CD0F zyE@T;5>IJr>ktR=YiIWcSWWT>lRBVGeh5@3irs!D5=$RcaY1Cu2fRmg{-!V*WoHbx zRoQkRp7zSHwQr}HBsjI@ZgpjxuuI36%~su4?^dv-oAWIvb$9cruD5wLZ+0-dytX}} zZOdBkJNao!!UEGw7jM$827<5lF`wM9?6N@JnxUX3w%YutkbNTNo;cR2vXBn?_?OvA3x&YfE1>M-H)j3pYY!vaK0AIhaJ;#ttXUI7dR@*`&W6L>P08YrWJ6y{OrJ96+G&8eI1i^r|4NoThQbe!9@#{G;Rbr zUtUO(8FWi#y2A3!8n-5=(9yLrXEn2xB=ld2G)5{2BEcFfrpiysDdKdky5f2#fmg45 zTbi*xslTpYRD94&+t*gPL{vvE(2F!qE| zMF{W&*{i97m3S3g+R3TKFD*quMNA-#DiGh)=L6f=>eWXG8a|wC)g=h_&8SXlVOClE zdLb%7Mx-;i^u&;Z1MDDG&+>Vo4-DHvx4_Lt&45OklO&aeH1*#kDQrTrPuGt>qQcT$ zWV;D9+b(bOU@JT3`b8>i()tJ2fCg#Q%!;5R87CxS6Bp>Nd6iqI0>taX_wT%>i-(PP zxEQ$N-(LaE@ty}@i4Gg9EC-rlJojs3CC%(;Up=_f&*N*=Ghj z{%y5W#(DG-X2uQ_zm-Ngh%9&mWV&Ugx1C1$CI>UN!%nNr=aJMR5Ve)_l^6V$2}Suk zG`3-`8@==D60f&#zKD2hwc-1Ko zj8OQd&l54P+GH~JjgGD`cbG~~a%_4HN8A@P+N`)VYyGGRdNIQA`j(fOL?PValZg%K zk@izt)+rwuCEgnqS>CP+7qtgki8e?9}befKxy<%Za%*9H!#w3GNj;VKK(i$}O zrYvlkde1})i17gz)fuo{T);JW<*6kM;OM)ChIY$jorVU(#>o20`abLGrwm>OqxO-| z(89sKl1eZ)CCTM#Oq#7@oHFN(di0(ijFODD+bI_*&qiQV#37ZsGpIwJdBjO6+!U zt-4Fh@y^-6Z6X*}HJWW*Z(+f^$`~0p-yWG}EeGtB|l~mz;*Xp+zjBDiT9~OIh zvbx(**z!_%UV$+$AD>H&f8Xb(Q0x6Q+=ndpe4X4=b;aWTNBZ^d+w)V!mZjousUVHB zyn7$(N=nifg+a<|r>mJ0l+u&N%nyjURqbkBnU|M${@kfkUmK1cJBE9cuyK^$t~ju^ zzT9Fct0R4SUu{tB;YmxmlBu?G+rkXb<}!yo-TT4}pI{e8FIBo0Ux+8?S^e10$4=u8!h(v6ZGe*XUDy|#mHcRFHRPj%R|SDzvn9e;nX z^i*d%~#jJ70d0>cT|fF zjPE;IJb7c#)|(*^wZ%eDSJEQ=_%q(|?6Et0y<)~|-P1?9`tsg7^hvp0wV>lvg za^KvG@`swbJG(la_?DcL+9tIuQ!G51*YM`Bt5CxuA@@k>Z*Pj8%P74g9^S4k9}z0c zh<#@Upp9FQ=6UmEFq1=5Ln$YSG!*|f-uF2yFh+5Q)HY%fBu=gR5QR}zI{s*kwMbXQ z4s4oyXIq?M&Iv6}eCI>RnN;oq2IM=AJiB*;8T2vQt^s2i=E$nfRC8m*U02`gUJzdX zdBdJ+IVwT52KhC3Th7=~AP6mRrOYO}?u%pI!QY&>tG~1MBls^|RX;q+0sK;uj9c4Q zwp^_%-~>Htc03jxA7n~OVP1T9T*5&!n*Uvf^os?TCQeZ1}0yfT~;Y-ah_xWD%@0_2PE;P!1)<3Kb6JnbMjH= zwf&MF%QN<<_qVQI1MC%QMfDA6z_I+g_2zj|j&bxLdz;C13t?S+ei>WnBAUpMgG%tk z!0H>ls{6F4!Z`{Ls{?WSG-o*6s5+Q|M@^An)h>h5dwaBZvQnLY{;qvlWug1(e%BX4 zMiwodavUC1SX_uGTph%-42mWK(NyJ##7!E!w*(i9dtM|xzc6-l%dhU zcI^ipGI`(LEj*5BW8!w22?6PJq+{-3$%SB@X%!2c;x@cES)#IKjZ&e}mrTn9f|R?9 z!Z4uaaPXs19)JEq0uFOl>!bkw$kqgQur#BQKIPLtO+F*STUHFo1il`B|J3$fF)VzyTgIlNL$hTrcG6+ARC+SDvnFkmdxglFV!Q zw4fd9unL4T&B1fhkd%p5c^H~$DY(XA6s?XDY9h)sU zU*a>`FIb;ke&Su$!@B4F)hCb(75D8#ql~za4tP;PtqvE|;{9(9-;rwDm%JXa=0>^Q zRMOa^u>zCj5O`s>{1LriKTm34IVOb1&tzt4uR)-z7uRc>)*CHxg zWj#3kre2bqyAH=uY*g>H713 zI@Bj?T9CYDm?y3{P-)|v3)?KVX)b4_%UYqUK6>80M1PCu8KaJn9<^HpD!N-a{p&Li z295bc9JLJPlh^urH&#KxO}lZQ^JL-!URS$=r*?#w-R}Z3clw8&^dkhFnlS1euYQ{^ zCEt(bP-(F5&Q?5S9kZL*|DpVhWrx`+xvCD{!7y!?-hABTSk0$UD>pG|xcDC70N!u) z@;Vzn76%Gg0Q)Y$~;d6J#Udxm>OF!E@ zdlNsT6!?6fU`m5Qm0$r&Q~81S!I&&&rC8nmH({X@&HL=|ovsT0PVq|{o|R6$Ica(N z+{fa2JL)gxBCoW{32vX7jP5)3^k?rq-D9)aC*37Eph%T(B5*x-)Nx<+rgL-Bp4!=sn#7(vZ8sig2>+FeixE}F|2lDC1z7S*59=&A;!^!ijcba-t+5&^mLx=d$Br!>`g}mAKG% zyanuHuQPnYCJ0rBweMKm3QLWZpNPKi{|$FtFDWupi}<8ot7A{nCubwGs4abuzlwlX z6KQF&vwF&QHYe=Hi|}dJhpbl;zp?amtkRXG=B)(cuM0LjKWt_Tg5Q)`G-_>P4_yZE zmhKNRCi;pp4A|v@3$RBFjvs#lnq6u&@2vo1P6S(C8YiHW$pNTK#G5dJfKHPiBvbQH zy-1@s*^^Nqkxri_V6pr}bkY>-rQubek(x(nEbx-BwGQ|Fmt&G55@=-dfOniFu11dH zqnl)uuR2ELE*CfR-Q7noHoePt!|*=7<*-{+MmGaRe%CageKKOU_|c^wl=-D-v?j`S z>|wqtAZcXs#y3o3OV#!Q77Tk+1fIRD*tfF2tAjRI>Ecr{dNUyxeVZqX`K_;sy&8M} zK=jqFT)pfq4J@vZQDu6lb>o1lZx7aWr^*1g<^Ihgz7-^#qs8ao#K=mRfMO>$~+u-r?y>0*`Dob4H2 z_ds_)x2D)&lU~f4o>qw&y00t1(N?F;A}ps$nVE>omxr2UbX9sqie|iccKRuE(O7FM z41iq#SGuLeQgjL|#0W=ES!E4`Gt)FseN`KFsIrgVuwmLjel05jTWcJOGx^-*S^1BUJrB;b10@Hr{`Y?7_-vhjRcLO8Jy1)#C7;|2@8 zMh>m)4%U5q`7{7451Q%KIPO{8?Wa2-h+7E41sB)%6cPF?DvLLpCWtw?cqi6X)i}As zFt@~;q`@u&mjtWVU8#lCCOu}}p%)kc`4-)&J1O3^Hel*=pM@}sTYX#mu|(?uV4VLurCLq#wtZ#H#$T+cS`DB z_RV)-yCcN6m%Y9=WQzHO^%nST(m>2+udp@Fxh2&#cl4~cxVgB;!abXf65V~CObsLM zu|?8#J&i63@ZY^`_VzVSZ+-3+v$&1^sQZ|2jl1QU7*{WDTO(_MeYZu6jlTsr`{{aM zn>Yk%KmlgaRp+W}%*Ka@Pj%w)VvbA45S`=-WD(OBk+BDG^Lr5%Tpgu2y zWe0-PJ%O2Z`@^^+4y7ha&bCLbkBn@0>lf(K@Ei1pLE zykc%KjraLSnMNgBS9eZ(t6WIa4~0GzQtD>KHQCrzal&^5?{f-zCJy_CiKG>Q1maroMP9MymX!1BXQsR8zf z8T8^i#V`Z>0Bpg^U@$ONF2vix-gRJd%uF0?d2pWH?5>Kk-Xxq&xRd~I^YSd>2H7=7 z!bjBHim$cOhikX8m$|LR(r>+SBELCl#;5vb(QKD~57lpUU5H4;Gu)Rztgr + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/KusPus.App/App.xaml.cs b/src/KusPus.App/App.xaml.cs index 11737d3..d498e12 100644 --- a/src/KusPus.App/App.xaml.cs +++ b/src/KusPus.App/App.xaml.cs @@ -79,7 +79,9 @@ protected override void OnStartup(StartupEventArgs e) _coordinator = _services.GetRequiredService(); _pill = new FloatingPillWindow(); _pill.SetLogger(_services.GetRequiredService().CreateLogger()); + _pill.SetCloseAction(Shutdown); _pill.Bind(_coordinator.State); + _pill.BindLevels(_services.GetRequiredService().Levels); _tray = new TrayManager(_coordinator, Shutdown); diff --git a/src/KusPus.App/AppCoordinator.cs b/src/KusPus.App/AppCoordinator.cs index 11c2311..69dd400 100644 --- a/src/KusPus.App/AppCoordinator.cs +++ b/src/KusPus.App/AppCoordinator.cs @@ -329,6 +329,9 @@ private async Task DeliverAsync(string text, TimeSpan duration, string model) _logger.LogInformation( "Paste outcome: pasted={Pasted} app={App} error={Error}.", outcome.Pasted, outcome.TargetApp, outcome.Error ?? ""); + + EmitPostPasteSnapshot(outcome.Pasted, outcome.TargetApp, outcome.Error); + var settingsHistory = _prefs.Current.History; if (!settingsHistory.Enabled) { @@ -350,10 +353,27 @@ private async Task DeliverAsync(string text, TimeSpan duration, string model) } } + /// + /// Marshals a one-shot post-paste snapshot onto the dispatcher so the pill can + /// render Confirmed (1 s) or Error (2 s) per design spec §2.5 / §2.6. The FSM + /// itself already transitioned to Idle before this fires; the snapshot rides + /// on top of Idle with attached. + /// + private void EmitPostPasteSnapshot(bool pasted, string targetApp, string? error) + { + var info = new PostPasteInfo(pasted, targetApp, error); + _dispatcher.BeginInvoke(new Action(() => + { + _state.OnNext(new CoordinatorSnapshot(AppState.Idle, PostPaste: info)); + })); + } + private async Task HandleFailureAsync(string error, string? failedWavPath) { _logger.LogWarning("Transcription failed: {Error}", error); + EmitPostPasteSnapshot(pasted: false, targetApp: "?", error: error); + string? retainedPath = null; if (failedWavPath is not null && File.Exists(failedWavPath)) { diff --git a/src/KusPus.App/FloatingPillWindow.xaml b/src/KusPus.App/FloatingPillWindow.xaml index 84d0522..d8bad23 100644 --- a/src/KusPus.App/FloatingPillWindow.xaml +++ b/src/KusPus.App/FloatingPillWindow.xaml @@ -1,23 +1,286 @@ - - - - + Width="200" Height="56" + MinWidth="200" MaxWidth="280" + ResizeMode="NoResize" + UseLayoutRounding="True" + SnapsToDevicePixels="True" + WindowStartupLocation="Manual" + MouseEnter="OnPillMouseEnter" + MouseLeave="OnPillMouseLeave"> + + + + + + + + + + + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/KusPus.App/FloatingPillWindow.xaml.cs b/src/KusPus.App/FloatingPillWindow.xaml.cs index a6e6ce0..c01c763 100644 --- a/src/KusPus.App/FloatingPillWindow.xaml.cs +++ b/src/KusPus.App/FloatingPillWindow.xaml.cs @@ -1,106 +1,813 @@ using System.Runtime.InteropServices; using System.Windows; +using System.Windows.Controls; using System.Windows.Forms; +using System.Windows.Input; using System.Windows.Interop; +using System.Windows.Media; +using System.Windows.Media.Animation; +using System.Windows.Threading; using KusPus.Core.State; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +// System.Drawing types leak in via UseWindowsForms — alias the WPF shapes/media we use. +using WpfRectangle = System.Windows.Shapes.Rectangle; +using WpfColor = System.Windows.Media.Color; +using WpfSolidColorBrush = System.Windows.Media.SolidColorBrush; +using WpfPoint = System.Windows.Point; namespace KusPus.App; /// -/// Phase 6 milestone pill — plain dark rectangle that displays the current -/// FSM state. TECH_SPEC §8.5 / §24's transparent acrylic version with the -/// visualizer + paste-confirmation overlay ships in Phase 8. +/// Floating pill window per docs/PILL_DESIGN.md (the §10 hover-extend override +/// makes this hit-testable rather than click-through, and adds the close + settings +/// buttons that appear on hover). /// -/// Window styles set in : +/// Design coverage (full spec): /// -/// WS_EX_TOOLWINDOW — no Alt+Tab, no taskbar entry. -/// WS_EX_NOACTIVATE — clicking the pill doesn't steal focus. -/// WS_EX_TRANSPARENT — mouse events pass through to the underlying window. +/// Surface (§1.1, §3.1, §3.3) — 200×56, 8px corners via DWM, dark gradient, +/// 1px hairline border, drop shadow, inner top highlight. +/// Mica backdrop (§3.3) on Win11 22H2+ via DWMWA_SYSTEMBACKDROP_TYPE; +/// gracefully falls back to the gradient on older Windows. +/// Five-state machine (§2) — Hidden (resting), Recording, Transcribing, +/// Confirmed (1 s hold), Error (2 s hold). +/// 20-bar visualizer (§4) with the damped target/value motion model from +/// §4.2 — center-weighted, per-bar damp rates, real audio levels (when available) +/// override the simulated speak envelope. +/// Accent line (§3.4) — mint gradient with glow, opacity per state. +/// Motion (§5) — 120 ms appear/disappear fade, 150 ms content crossfade, +/// confirmation choreography per §5.1, instant accent-color shift on error. +/// Positioning (§1.2, §6.5) — bottom-center on the monitor containing the +/// foreground window, 40 DIP above work-area bottom, per-monitor DPI math from 8A. +/// No focus theft (§6.3) — WS_EX_NOACTIVATE. Hidden from taskbar/Alt-Tab +/// (§1.2) — WS_EX_TOOLWINDOW. +/// Hover-extend (§10) — width animates 200→280 over 150 ms, button panel +/// fades in. WS_EX_TRANSPARENT is intentionally NOT applied. /// -/// AllowsTransparency=True is deliberately OFF for Phase 6 — it caused the -/// pill to render invisibly on the author's Windows 11 dev box during the first -/// smoke test. Phase 8 brings it back along with the rounded-corner / acrylic look. +/// +/// Out-of-spec / deferred until the Settings UI lands (Phase 9+): +/// - Accent colour picker (Mint hardcoded; spec §3.4 Amber/Azure/Violet alternates). +/// - Live light/dark theme switching (dark-only for v1; PILL_DESIGN §3.2 light theme). +/// - Reduced-motion preference gating (§5.3) — pill always animates. +/// - Confirmed-state radial mask on the visualizer (§4.3) — bars hide entirely +/// during Confirmed instead. /// public partial class FloatingPillWindow : Window { + // ── DWM ───────────────────────────────────────────────────────────────── + private const int DWMWA_USE_IMMERSIVE_DARK_MODE = 20; + private const int DWMWA_WINDOW_CORNER_PREFERENCE = 33; + private const int DWMWA_SYSTEMBACKDROP_TYPE = 38; + private const int DWMWCP_ROUND = 2; + private const int DWMSBT_TRANSIENTWINDOW = 3; + + // ── User32 extended styles (NO WS_EX_TRANSPARENT per §10.2) ───────────── private const int GWL_EXSTYLE = -20; private const int WS_EX_TOOLWINDOW = 0x00000080; - // WS_EX_TRANSPARENT and WS_EX_NOACTIVATE temporarily removed for Win 11 25H2 - // pill-visibility debugging. Restored in Phase 8. + private const int WS_EX_NOACTIVATE = 0x08000000; + + // ── Monitor lookup ────────────────────────────────────────────────────── + private const uint MONITOR_DEFAULTTONEAREST = 2; + private const uint MONITOR_DEFAULTTOPRIMARY = 1; + + private enum MonitorDpiType + { + EffectiveDpi = 0, + } + + [DllImport("dwmapi.dll")] + private static extern int DwmSetWindowAttribute(IntPtr hwnd, int dwAttribute, ref int pvAttribute, int cbAttribute); + + [DllImport("user32.dll")] + private static extern IntPtr MonitorFromPoint(POINT pt, uint dwFlags); + + [DllImport("user32.dll")] + private static extern IntPtr MonitorFromWindow(IntPtr hwnd, uint dwFlags); [DllImport("user32.dll")] - private static extern int GetWindowLong(IntPtr hwnd, int index); + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetMonitorInfoW(IntPtr hMonitor, ref MONITORINFO lpmi); [DllImport("user32.dll")] - private static extern int SetWindowLong(IntPtr hwnd, int index, int newStyle); + private static extern IntPtr GetForegroundWindow(); + + [DllImport("shcore.dll")] + private static extern int GetDpiForMonitor(IntPtr hmonitor, MonitorDpiType dpiType, out uint dpiX, out uint dpiY); + + [DllImport("user32.dll", EntryPoint = "GetWindowLongPtrW")] + private static extern IntPtr GetWindowLongPtr(IntPtr hWnd, int nIndex); + + [DllImport("user32.dll", EntryPoint = "SetWindowLongPtrW")] + private static extern IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr dwNewLong); + + [StructLayout(LayoutKind.Sequential)] + private struct POINT + { + public int X; + public int Y; + } + + [StructLayout(LayoutKind.Sequential)] + private struct RECT + { + public int Left; + public int Top; + public int Right; + public int Bottom; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct MONITORINFO + { + public int cbSize; + public RECT rcMonitor; + public RECT rcWork; + public uint dwFlags; + } + + // EX-variant carries the device name (`\\.\DISPLAY1`) which we use as a stable + // key for the per-monitor remembered-position dictionary. + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct MONITORINFOEX + { + public int cbSize; + public RECT rcMonitor; + public RECT rcWork; + public uint dwFlags; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] + public string szDevice; + } + + [DllImport("user32.dll", EntryPoint = "GetMonitorInfoW", CharSet = CharSet.Unicode)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetMonitorInfoExW(IntPtr hMonitor, ref MONITORINFOEX lpmi); + + // ── Visualizer geometry (§4.1) ────────────────────────────────────────── + private const int BarCount = 20; + private const double BarWidth = 3.0; + private const double BarGap = 4.0; + private const double BarMinHeight = 4.0; + private const double BarMaxHeight = 26.0; + private const double TrackHeight = 28.0; + + // ── Motion durations (§5) ─────────────────────────────────────────────── + private static readonly Duration AppearDuration = new(TimeSpan.FromMilliseconds(120)); + private static readonly Duration ContentCrossfade = new(TimeSpan.FromMilliseconds(150)); + private static readonly Duration HoverExtendDuration = new(TimeSpan.FromMilliseconds(150)); private ILogger _logger = NullLogger.Instance; + private Action? _onClose; + + // ── Visualizer state ──────────────────────────────────────────────────── + private readonly WpfRectangle[] _bars = new WpfRectangle[BarCount]; + private readonly double[] _levels = new double[BarCount]; + private readonly double[] _targets = new double[BarCount]; + private readonly Random _rng = new(); + private DateTime _lastFrameTime = DateTime.MinValue; + private DateTime _nextTargetAt = DateTime.MinValue; + private float[]? _lastRealLevels; + private bool _isRecording; + private bool _renderingHooked; + + // ── State machine ─────────────────────────────────────────────────────── + // Idle is a dev-override (CLAUDE.md deviation): spec §6.1 wants the pill hidden + // when not in an active state; user requested always-visible until the Settings + // modal makes the close path discoverable elsewhere. + private enum PillVisual { Hidden, Idle, Recording, Transcribing, Confirmed, Error } + private PillVisual _currentVisual = PillVisual.Hidden; + private DispatcherTimer? _postPasteTimer; + + // Session-only — keyed by MONITORINFOEX.szDevice (e.g. "\\.\DISPLAY1"). Cleared + // on every fresh process start per user spec ("forget on close"). A dictation + // started on a monitor with no entry uses that monitor's bottom-center default. + private readonly Dictionary _monitorPositions = new(StringComparer.OrdinalIgnoreCase); + + // Set during the drag operation so animation overlap doesn't undo the user. + private bool _isDragging; public FloatingPillWindow() { InitializeComponent(); SourceInitialized += OnSourceInitialized; + Loaded += OnLoaded; } public void SetLogger(ILogger logger) => _logger = logger; + /// + /// Hook for the close button (§10.1). The App passes Application.Shutdown here. + /// + public void SetCloseAction(Action onClose) => _onClose = onClose; + private void OnSourceInitialized(object? sender, EventArgs e) { var hwnd = new WindowInteropHelper(this).Handle; - int ex = GetWindowLong(hwnd, GWL_EXSTYLE); - // Only tool-window for now (no taskbar / no Alt-Tab). Skip click-through - // and no-activate during the dev visibility debug — they sometimes - // suppress the window on Win 11 25H2 entirely. Phase 8 reinstates them. - ex |= WS_EX_TOOLWINDOW; - _ = SetWindowLong(hwnd, GWL_EXSTYLE, ex); + + // Rounded 8 px corners (Win11). Older Windows returns non-success HRESULT — we ignore. + int corner = DWMWCP_ROUND; + _ = DwmSetWindowAttribute(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE, ref corner, sizeof(int)); + + // Dark-mode hint so DWM tints Mica with the dark palette. + int darkMode = 1; + _ = DwmSetWindowAttribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, ref darkMode, sizeof(int)); + + // Mica (transient). Win11 22H2+. Older Windows returns non-success — gradient fallback. + int backdrop = DWMSBT_TRANSIENTWINDOW; + _ = DwmSetWindowAttribute(hwnd, DWMWA_SYSTEMBACKDROP_TYPE, ref backdrop, sizeof(int)); + + // No focus theft + hidden from taskbar/Alt-Tab. No WS_EX_TRANSPARENT per §10.2. + long ex = GetWindowLongPtr(hwnd, GWL_EXSTYLE).ToInt64(); + ex |= WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW; + _ = SetWindowLongPtr(hwnd, GWL_EXSTYLE, new IntPtr(ex)); + } + + private void OnLoaded(object sender, RoutedEventArgs e) + { + BuildBars(); + StartSpinner(); + // PRD G4 dev override: pill is visible from launch and stays visible + // between dictations. Transition into Idle so the position math runs + // and the surface appears. + TransitionTo(PillVisual.Idle); + } + + // ── Visualizer build ──────────────────────────────────────────────────── + + private void BuildBars() + { + if (_bars[0] is not null) + { + return; + } + + // §3.1 active-bar token rgba(255,255,255,0.92) → #EBFFFFFF + var activeBrush = new WpfSolidColorBrush(WpfColor.FromArgb(0xEB, 0xFF, 0xFF, 0xFF)); + activeBrush.Freeze(); + + for (int i = 0; i < BarCount; i++) + { + var bar = new WpfRectangle + { + Width = BarWidth, + Height = BarMinHeight, + Fill = activeBrush, + RadiusX = 1.5, + RadiusY = 1.5, + }; + Canvas.SetLeft(bar, i * (BarWidth + BarGap)); + Canvas.SetTop(bar, (TrackHeight - BarMinHeight) / 2); + VisualizerCanvas.Children.Add(bar); + _bars[i] = bar; + _levels[i] = 0.05; + _targets[i] = 0.05; + } + + if (!_renderingHooked) + { + CompositionTarget.Rendering += OnVisualizerTick; + _renderingHooked = true; + } + } + + private void StartSpinner() + { + // 0.9 s full rotation per §2.4. Direct BeginAnimation on the RotateTransform — + // Storyboard.SetTarget on a Freezable inside a deeply named scope silently + // no-ops; this is the reliable form. + var anim = new DoubleAnimation + { + From = 0, + To = 360, + Duration = new Duration(TimeSpan.FromMilliseconds(900)), + RepeatBehavior = RepeatBehavior.Forever, + }; + SpinnerRotation.BeginAnimation(RotateTransform.AngleProperty, anim); + } + + // ── Visualizer motion model (§4.2) ────────────────────────────────────── + + private void OnVisualizerTick(object? sender, EventArgs e) + { + if (_bars[0] is null || _currentVisual is PillVisual.Hidden) + { + // Stay quiescent — bars retain whatever level they had. The next show + // will damp from idle back up via the model. + return; + } + + var now = DateTime.UtcNow; + double dt = _lastFrameTime == DateTime.MinValue + ? 0.016 + : Math.Clamp((now - _lastFrameTime).TotalSeconds, 0, 0.064); + _lastFrameTime = now; + + // Re-roll targets every 90–150 ms while recording. + if (_isRecording && now >= _nextTargetAt) + { + _nextTargetAt = now.AddMilliseconds(90 + (_rng.NextDouble() * 60)); + + if (_lastRealLevels is not null && _lastRealLevels.Length == BarCount) + { + // Real audio path — §4.2 trailer: realAmplitude × center_weight × (1 + jitter). + for (int i = 0; i < BarCount; i++) + { + double centerWeight = 1.0 - (Math.Abs(i - 9.5) / 9.5); + double jitter = ((_rng.NextDouble() * 2) - 1) * 0.05; + double scaled = _lastRealLevels[i] * (0.6 + (centerWeight * 0.6)) * 4.0; + _targets[i] = Math.Clamp(scaled + jitter, 0.05, 1.0); + } + } + else + { + // Simulated speak envelope. + double speak = 0.55 + (Math.Sin(now.Ticks / 1e7 / 0.38) * 0.25) + ((_rng.NextDouble() - 0.5) * 0.25); + for (int i = 0; i < BarCount; i++) + { + double centerWeight = 1.0 - (Math.Abs(i - 9.5) / 9.5); + double baseVal = 0.18 + (centerWeight * 0.45); + double jitter = ((_rng.NextDouble() * 1.0) - 0.30) * 0.55; + _targets[i] = Math.Clamp((baseVal * speak) + jitter, 0.05, 1.0); + } + } + } + + if (!_isRecording) + { + for (int i = 0; i < BarCount; i++) + { + _targets[i] = 0.05; + } + } + + // Damped approach with per-bar rate variation so bars don't move in lockstep. + for (int i = 0; i < BarCount; i++) + { + double rate = _isRecording ? (14 + ((i % 3) * 3)) : 6; + double k = 1 - Math.Exp(-rate * dt); + _levels[i] += (_targets[i] - _levels[i]) * k; + + double h = BarMinHeight + (_levels[i] * (BarMaxHeight - BarMinHeight)); + _bars[i].Height = h; + Canvas.SetTop(_bars[i], (TrackHeight - h) / 2); + } } + // ── Public binding API ────────────────────────────────────────────────── + public void Bind(IObservable state) { state.Subscribe(snap => Dispatcher.BeginInvoke(() => Render(snap))); } + public void BindLevels(IObservable levels) + { + // Background priority so visualizer updates can't starve state-change rendering. + levels.Subscribe(arr => Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() => + { + _lastRealLevels = arr; + }))); + } + + // ── State rendering ───────────────────────────────────────────────────── + private void Render(CoordinatorSnapshot snapshot) { #pragma warning disable CA1848, CA1873 - // Information level so it definitely appears in the log file regardless of - // MEL filter settings. Phase 8 will drop this back to Debug. - _logger.LogInformation( - "Pill render: state={State} holdMode={Hold} visible={Visible}.", - snapshot.State, snapshot.IsHoldMode, IsVisible); + _logger.LogDebug( + "Pill render: state={State} hold={Hold} postPaste={Post} visible={Visible}.", + snapshot.State, snapshot.IsHoldMode, snapshot.PostPaste, IsVisible); +#pragma warning restore CA1848, CA1873 + + // Post-paste snapshot — show Confirmed or Error for its hold, then fade out. + if (snapshot.PostPaste is { } post) + { + if (post.Pasted) + { + ShowConfirmed(post.TargetApp); + } + else + { + ShowError(post.ErrorReason ?? "Something went wrong"); + } + return; + } + + // Fresh dictation starting — clear any lingering post-paste timer. + if (snapshot.State is AppState.Armed or AppState.Recording) + { + _postPasteTimer?.Stop(); + _postPasteTimer = null; + } + + // Hybrid sticky multi-monitor (option C): when a dictation kicks off, move + // to the foreground window's monitor if the pill isn't already there. + if (snapshot.State is AppState.Armed or AppState.Recording) + { + EnsurePillOnForegroundMonitor(); + } + + switch (snapshot.State) + { + case AppState.Recording: + TransitionTo(PillVisual.Recording); + break; + case AppState.Transcribing: + TransitionTo(PillVisual.Transcribing); + break; + default: + // Idle / Armed / Cancelled — fall back to the Idle visual (dev override + // of spec §6.1), unless a post-paste hold is currently running. + if (_postPasteTimer is null) + { + TransitionTo(PillVisual.Idle); + } + break; + } + } + + private void TransitionTo(PillVisual next) + { + if (next == _currentVisual) + { + return; + } + +#pragma warning disable CA1848, CA1873 + _logger.LogDebug("Pill visual {From} → {To}.", _currentVisual, next); #pragma warning restore CA1848, CA1873 - StatusText.Text = snapshot.State switch + var previous = _currentVisual; + _currentVisual = next; + + // Update recording flag — drives the visualizer motion model. + _isRecording = next == PillVisual.Recording; + + // Accent line + glow per §3.4 opacity table. Idle and Hidden both render + // the accent line invisible. + AccentLine.Opacity = next switch { - AppState.Idle => "KusPus (idle)", - AppState.Armed => "Armed…", - AppState.Recording => "Recording…", - AppState.Transcribing => "Transcribing…", - AppState.Cancelled => "Cancelled", - _ => "KusPus", + PillVisual.Recording => 1.0, + PillVisual.Transcribing => 0.55, + PillVisual.Confirmed => 0.40, + PillVisual.Error => 1.0, + _ => 0.0, }; + AccentGlow.Opacity = next is PillVisual.Recording or PillVisual.Error ? 0.6 : 0.0; - if (!IsVisible) + // §2.6 error accent shift is instant — swap brush colors. + if (next == PillVisual.Error) { - ShowAtCursorMonitor(); - // Force topmost re-apply after Show — Win 11 sometimes drops the topmost - // bit when a window with WS_EX_NOACTIVATE is first shown. - Topmost = false; - Topmost = true; + SetAccent(0xFF, 0x4D, 0x4F); } + else if (previous == PillVisual.Error) + { + SetAccent(0x4D, 0xDB, 0xA6); + } + + // Content crossfade — fade out the old, fade in the new. + FadeContent(previous, fadeIn: false); + FadeContent(next, fadeIn: true); + + // Pill-level appear/disappear (§5). + if (next == PillVisual.Hidden && previous != PillVisual.Hidden) + { + FadePillOut(); + } + else if (next != PillVisual.Hidden && previous == PillVisual.Hidden) + { + FadePillIn(); + } + } + + private void SetAccent(byte r, byte g, byte b) + { + // Three stops in AccentBrush — fade ends to transparent, center to 50% accent. + var transparent = WpfColor.FromArgb(0x00, r, g, b); + var middle = WpfColor.FromArgb(0x80, r, g, b); + AccentBrush.GradientStops[0].Color = transparent; + AccentBrush.GradientStops[1].Color = middle; + AccentBrush.GradientStops[2].Color = transparent; + AccentGlow.Color = WpfColor.FromRgb(r, g, b); + } + + private void FadeContent(PillVisual which, bool fadeIn) + { + FrameworkElement? target = which switch + { + PillVisual.Idle => IdleContent, + PillVisual.Recording => RecordingContent, + PillVisual.Transcribing => TranscribingContent, + PillVisual.Confirmed => ConfirmedContent, + PillVisual.Error => ErrorContent, + _ => null, + }; + if (target is null) + { + return; + } + + target.IsHitTestVisible = fadeIn; + var anim = new DoubleAnimation + { + From = fadeIn ? 0 : target.Opacity, + To = fadeIn ? 1 : 0, + Duration = ContentCrossfade, + EasingFunction = new CubicEase { EasingMode = fadeIn ? EasingMode.EaseOut : EasingMode.EaseIn }, + }; + target.BeginAnimation(OpacityProperty, anim); + } + + private void FadePillIn() + { + Opacity = 0; + ShowAtForegroundMonitor(); + var anim = new DoubleAnimation + { + From = 0, + To = 1, + Duration = AppearDuration, + EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }, + }; + BeginAnimation(OpacityProperty, anim); + } + + private void FadePillOut() + { + var anim = new DoubleAnimation + { + From = Opacity, + To = 0, + Duration = AppearDuration, + EasingFunction = new CubicEase { EasingMode = EasingMode.EaseIn }, + }; + anim.Completed += (_, _) => + { + if (_currentVisual == PillVisual.Hidden) + { + Hide(); + // Reset levels so the next show damps up from idle naturally. + for (int i = 0; i < BarCount; i++) + { + _levels[i] = 0.05; + _targets[i] = 0.05; + } + } + }; + BeginAnimation(OpacityProperty, anim); } - private void ShowAtCursorMonitor() + // ── Post-paste states (§2.5, §2.6, §5.1) ──────────────────────────────── + + private void ShowConfirmed(string appName) { - var cursor = System.Windows.Forms.Cursor.Position; - var screen = Screen.FromPoint(cursor); - var work = screen.WorkingArea; - // DEBUG: position at CENTER of screen so it can't be missed. - // Phase 8 restores bottom-center placement. - Left = work.Left + ((work.Width - Width) / 2); - Top = work.Top + ((work.Height - Height) / 2); + ConfirmedAppRun.Text = appName; + TransitionTo(PillVisual.Confirmed); + StartPostPasteHold(TimeSpan.FromMilliseconds(1000)); + } + + private void ShowError(string reason) + { + ErrorText.Text = reason; + TransitionTo(PillVisual.Error); + StartPostPasteHold(TimeSpan.FromMilliseconds(2000)); + } + + private void StartPostPasteHold(TimeSpan hold) + { + _postPasteTimer?.Stop(); + _postPasteTimer = new DispatcherTimer { Interval = hold }; + _postPasteTimer.Tick += (_, _) => + { + _postPasteTimer?.Stop(); + _postPasteTimer = null; + // PRD G4 dev override — return to Idle (visible), not Hidden. + TransitionTo(PillVisual.Idle); + }; + _postPasteTimer.Start(); + } + + // ── Positioning (§1.2, §6.5 + multi-monitor sticky from CLAUDE.md) ────── + + private void ShowAtForegroundMonitor() + { + var fg = GetForegroundWindow(); + var hMon = fg != IntPtr.Zero + ? MonitorFromWindow(fg, MONITOR_DEFAULTTONEAREST) + : MonitorFromPoint(new POINT { X = 0, Y = 0 }, MONITOR_DEFAULTTOPRIMARY); + + if (hMon != IntPtr.Zero) + { + PlaceOnMonitor(hMon); + } + else + { + // Final fallback — no monitor handle. Use cursor's screen via WinForms. + var cur = System.Windows.Forms.Cursor.Position; + var s = Screen.FromPoint(cur); + Left = s.WorkingArea.Left + ((s.WorkingArea.Width - 200.0) / 2); + Top = s.WorkingArea.Bottom - Height - 40; + } + Show(); } + + /// + /// Positions the pill on the given monitor — at the user's remembered position + /// for that monitor if one exists this session, otherwise at the §1.2 default + /// (bottom-center of the work area, 40 DIP above the bottom edge). Does not + /// call Show — the caller decides visibility lifecycle. + /// + private void PlaceOnMonitor(IntPtr hMon) + { + var miEx = new MONITORINFOEX { cbSize = Marshal.SizeOf() }; + if (!GetMonitorInfoExW(hMon, ref miEx)) + { + return; + } + + if (_monitorPositions.TryGetValue(miEx.szDevice, out var remembered)) + { +#pragma warning disable CA1848, CA1873 + _logger.LogDebug("Pill placing on {Device} at remembered ({L:F0},{T:F0}).", + miEx.szDevice, remembered.X, remembered.Y); +#pragma warning restore CA1848, CA1873 + Left = remembered.X; + Top = remembered.Y; + return; + } + + // Default per design spec §1.2. + double scale = 1.0; + if (GetDpiForMonitor(hMon, MonitorDpiType.EffectiveDpi, out uint dpiX, out _) == 0 + && dpiX > 0) + { + scale = dpiX / 96.0; + } + + double workLeftDip = miEx.rcWork.Left / scale; + double workWidthDip = (miEx.rcWork.Right - miEx.rcWork.Left) / scale; + double workBottomDip = miEx.rcWork.Bottom / scale; + + // Anchor on base width (200) not animated width, so hover-extend doesn't drift center. + Left = workLeftDip + ((workWidthDip - 200.0) / 2); + Top = workBottomDip - Height - 40; + +#pragma warning disable CA1848, CA1873 + _logger.LogDebug("Pill placing on {Device} at default ({L:F0},{T:F0}) (scale {Scale}).", + miEx.szDevice, Left, Top, scale); +#pragma warning restore CA1848, CA1873 + } + + /// + /// Multi-monitor option C (CLAUDE.md): if a dictation is starting on a monitor + /// the pill isn't on, jump to that monitor's remembered (or default) position. + /// Called on transition into Armed/Recording. No-op when pill is already on the + /// foreground monitor, or while the user is actively dragging. + /// + private void EnsurePillOnForegroundMonitor() + { + if (_isDragging) + { + return; + } + + var fg = GetForegroundWindow(); + var ourHwnd = new WindowInteropHelper(this).Handle; + if (fg == IntPtr.Zero || fg == ourHwnd || ourHwnd == IntPtr.Zero) + { + return; + } + + var fgMon = MonitorFromWindow(fg, MONITOR_DEFAULTTONEAREST); + if (fgMon == IntPtr.Zero) + { + return; + } + + var pillMon = MonitorFromWindow(ourHwnd, MONITOR_DEFAULTTONEAREST); + if (pillMon == fgMon) + { + return; + } + + PlaceOnMonitor(fgMon); + } + + // ── Drag (custom, beyond design spec §1.2 click-through override) ─────── + + private void OnPillMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + if (e.Handled || e.ChangedButton != MouseButton.Left) + { + return; + } + // Skip when the user is clicking on a Settings / Close button — let the + // button's Click handler do its thing. + if (IsClickOnButton(e.OriginalSource as DependencyObject)) + { + return; + } + + _isDragging = true; + try + { + DragMove(); + } + finally + { + _isDragging = false; + } + + // Capture the new position under whatever monitor we landed on. + var device = GetCurrentMonitorDeviceName(); + if (device is not null) + { + _monitorPositions[device] = new WpfPoint(Left, Top); +#pragma warning disable CA1848, CA1873 + _logger.LogDebug("Pill drag end → remembered ({L:F0},{T:F0}) on {Device}.", + Left, Top, device); +#pragma warning restore CA1848, CA1873 + } + } + + private static bool IsClickOnButton(DependencyObject? source) + { + while (source is not null) + { + if (source is System.Windows.Controls.Button) + { + return true; + } + source = VisualTreeHelper.GetParent(source); + } + return false; + } + + private string? GetCurrentMonitorDeviceName() + { + var hwnd = new WindowInteropHelper(this).Handle; + if (hwnd == IntPtr.Zero) + { + return null; + } + var hMon = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST); + if (hMon == IntPtr.Zero) + { + return null; + } + var miEx = new MONITORINFOEX { cbSize = Marshal.SizeOf() }; + return GetMonitorInfoExW(hMon, ref miEx) ? miEx.szDevice : null; + } + + // ── Hover-extend (§10) ────────────────────────────────────────────────── + + private void OnPillMouseEnter(object sender, System.Windows.Input.MouseEventArgs e) + { + AnimateWidth(280); + AnimateButtonPanel(visible: true); + } + + private void OnPillMouseLeave(object sender, System.Windows.Input.MouseEventArgs e) + { + AnimateWidth(200); + AnimateButtonPanel(visible: false); + } + + private void AnimateWidth(double to) + { + var anim = new DoubleAnimation + { + To = to, + Duration = HoverExtendDuration, + EasingFunction = new CubicEase { EasingMode = to > Width ? EasingMode.EaseOut : EasingMode.EaseIn }, + }; + BeginAnimation(WidthProperty, anim); + } + + private void AnimateButtonPanel(bool visible) + { + ButtonPanel.IsHitTestVisible = visible; + var anim = new DoubleAnimation + { + To = visible ? 1.0 : 0.0, + Duration = HoverExtendDuration, + EasingFunction = new CubicEase { EasingMode = visible ? EasingMode.EaseOut : EasingMode.EaseIn }, + }; + ButtonPanel.BeginAnimation(OpacityProperty, anim); + } + + private void OnCloseClick(object sender, RoutedEventArgs e) + { +#pragma warning disable CA1848, CA1873 + _logger.LogInformation("Close button clicked — invoking shutdown action."); +#pragma warning restore CA1848, CA1873 + _onClose?.Invoke(); + } + + private void OnSettingsClick(object sender, RoutedEventArgs e) + { +#pragma warning disable CA1848, CA1873 + _logger.LogDebug("Settings button clicked (no-op until Phase 9 settings modal)."); +#pragma warning restore CA1848, CA1873 + } } diff --git a/src/KusPus.App/KusPus.App.csproj b/src/KusPus.App/KusPus.App.csproj index dde4d2a..39067b5 100644 --- a/src/KusPus.App/KusPus.App.csproj +++ b/src/KusPus.App/KusPus.App.csproj @@ -8,6 +8,11 @@ KusPus.App KusPus app.manifest + + ..\..\icons\icon.ico @@ -19,6 +24,7 @@ + @@ -30,4 +36,13 @@ + + + + + + + diff --git a/src/KusPus.App/TrayManager.cs b/src/KusPus.App/TrayManager.cs index 08ccaf3..bbe4da4 100644 --- a/src/KusPus.App/TrayManager.cs +++ b/src/KusPus.App/TrayManager.cs @@ -3,10 +3,10 @@ namespace KusPus.App; /// -/// Tray icon per TECH_SPEC §8.9 + §25. Switched from H.NotifyIcon.Wpf to -/// System.Windows.Forms.NotifyIcon after Phase 6 manual smoke: H.NotifyIcon -/// didn't actually surface the tray icon, and the WinForms version is well-trodden -/// in WPF apps with UseWindowsForms=true alongside UseWPF=true. +/// Tray icon per TECH_SPEC §8.9 + §25. Uses the bundled icon.ico (generated from +/// icons/icon.svg by tools/IconBuilder). Switched from H.NotifyIcon.Wpf to +/// System.Windows.Forms.NotifyIcon during Phase 6 manual smoke — H.NotifyIcon +/// didn't reliably render on Win 11 25H2. /// internal sealed class TrayManager : IDisposable { @@ -16,7 +16,7 @@ public TrayManager(AppCoordinator coordinator, Action onQuit) { _icon = new WinFormsApp.NotifyIcon { - Icon = System.Drawing.SystemIcons.Application, + Icon = LoadAppIcon(), Text = "KusPus", Visible = true, }; @@ -31,6 +31,23 @@ public TrayManager(AppCoordinator coordinator, Action onQuit) public void Dispose() { _icon.Visible = false; + _icon.Icon?.Dispose(); _icon.Dispose(); } + + private static System.Drawing.Icon LoadAppIcon() + { + // Resolve the WPF Resource icon.ico via pack URI and hand the stream to + // System.Drawing.Icon. Keeps the tray, window, and exe icons unified on + // a single source-of-truth file. + var info = System.Windows.Application.GetResourceStream(new Uri("pack://application:,,,/icon.ico")); + if (info is null) + { + // Defensive — shouldn't happen because icon.ico is included as a Resource + // in KusPus.App.csproj. Fall back to the system app icon so the tray still works. + return System.Drawing.SystemIcons.Application; + } + using var stream = info.Stream; + return new System.Drawing.Icon(stream); + } } diff --git a/src/KusPus.Core/State/CoordinatorSnapshot.cs b/src/KusPus.Core/State/CoordinatorSnapshot.cs index 49d3154..6a45f06 100644 --- a/src/KusPus.Core/State/CoordinatorSnapshot.cs +++ b/src/KusPus.Core/State/CoordinatorSnapshot.cs @@ -1,7 +1,22 @@ namespace KusPus.Core.State; +/// +/// Post-paste payload attached to a snapshot. Non-null for one snapshot per +/// dictation cycle — emitted by AppCoordinator after PasteEngine +/// finishes, so the pill can show "Pasted into <App>" (or the error reason) +/// for the hold defined in docs/PILL_DESIGN.md §2.5 / §2.6. +/// +/// True if the paste keystroke reached the target window. +/// Friendly app name (or "?" if not resolvable). +/// Non-null when is false — short user-facing reason ("Microphone blocked", "Disk full", etc.). +public sealed record PostPasteInfo(bool Pasted, string TargetApp, string? ErrorReason); + /// /// The full coordinator state as a single immutable value. /// is only meaningful when is . +/// is non-null only on the post-paste snapshot. /// -public sealed record CoordinatorSnapshot(AppState State, bool IsHoldMode = false); +public sealed record CoordinatorSnapshot( + AppState State, + bool IsHoldMode = false, + PostPasteInfo? PostPaste = null); diff --git a/tools/IconBuilder/IconBuilder.csproj b/tools/IconBuilder/IconBuilder.csproj new file mode 100644 index 0000000..522293e --- /dev/null +++ b/tools/IconBuilder/IconBuilder.csproj @@ -0,0 +1,22 @@ + + + + Exe + net10.0-windows + KusPus.IconBuilder + enable + enable + false + false + + false + + false + $(NoWarn);CA1416;SYSLIB0011 + + + + + + + diff --git a/tools/IconBuilder/Program.cs b/tools/IconBuilder/Program.cs new file mode 100644 index 0000000..28a687d --- /dev/null +++ b/tools/IconBuilder/Program.cs @@ -0,0 +1,145 @@ +// IconBuilder: one-shot tool that converts an SVG file to a multi-resolution .ico +// suitable for Windows app icons (taskbar, tray, File Explorer, Task Manager). +// +// Usage: +// dotnet run --project tools/IconBuilder -- +// +// Sizes embedded: 16, 24, 32, 48, 64, 128, 256. +// 256-sized frames are PNG-encoded (per ICO spec for large frames); smaller frames +// are 32bpp BGRA bitmaps. + +using System.Drawing; +using System.Drawing.Imaging; +using Svg; + +if (args.Length != 2) +{ + Console.Error.WriteLine("usage: IconBuilder "); + return 2; +} + +string svgPath = args[0]; +string icoPath = args[1]; + +if (!File.Exists(svgPath)) +{ + Console.Error.WriteLine($"Input not found: {svgPath}"); + return 1; +} + +Console.WriteLine($"Loading SVG: {svgPath}"); +var doc = SvgDocument.Open(svgPath); + +int[] sizes = { 16, 24, 32, 48, 64, 128, 256 }; +var frames = new List<(int Size, byte[] Data, bool IsPng)>(); + +foreach (int size in sizes) +{ + Console.WriteLine($" rendering {size}x{size}…"); + using var bmp = doc.Draw(size, size); + + if (size >= 64) + { + // PNG encode for larger sizes — both for size efficiency and because + // 256x256 BMP frames in ICO format have known compatibility issues. + using var ms = new MemoryStream(); + bmp.Save(ms, ImageFormat.Png); + frames.Add((size, ms.ToArray(), true)); + } + else + { + // 32bpp BGRA bitmap data. We write the BITMAPINFOHEADER (no file header) + // followed by raw BGRA pixel data, bottom-up, plus an AND mask. + frames.Add((size, EncodeBmpForIco(bmp), false)); + } +} + +WriteIco(icoPath, frames); +Console.WriteLine($"Wrote {icoPath} ({new FileInfo(icoPath).Length} bytes, {frames.Count} frames)"); +return 0; + +static byte[] EncodeBmpForIco(Bitmap bmp) +{ + int size = bmp.Width; + using var ms = new MemoryStream(); + using var w = new BinaryWriter(ms); + + // BITMAPINFOHEADER (40 bytes). Note: ICO uses double height in this header to + // accommodate the AND mask, even though we never read it as height. + w.Write(40); // biSize + w.Write(size); // biWidth + w.Write(size * 2); // biHeight (image + mask) + w.Write((short)1); // biPlanes + w.Write((short)32); // biBitCount + w.Write(0); // biCompression (BI_RGB) + w.Write(0); // biSizeImage + w.Write(0); // biXPelsPerMeter + w.Write(0); // biYPelsPerMeter + w.Write(0); // biClrUsed + w.Write(0); // biClrImportant + + // Pixel data: BGRA, bottom-up. + var bits = bmp.LockBits( + new Rectangle(0, 0, size, size), + ImageLockMode.ReadOnly, + PixelFormat.Format32bppArgb); + try + { + int stride = bits.Stride; + var row = new byte[stride]; + for (int y = size - 1; y >= 0; y--) + { + System.Runtime.InteropServices.Marshal.Copy( + bits.Scan0 + (y * stride), row, 0, stride); + w.Write(row); + } + } + finally + { + bmp.UnlockBits(bits); + } + + // AND mask — all transparent, packed 1-bit (rows padded to 4-byte boundary). + int rowBytes = ((size + 31) / 32) * 4; + var maskRow = new byte[rowBytes]; + for (int y = 0; y < size; y++) + { + w.Write(maskRow); + } + + return ms.ToArray(); +} + +static void WriteIco(string path, List<(int Size, byte[] Data, bool IsPng)> frames) +{ + using var fs = File.Create(path); + using var w = new BinaryWriter(fs); + + // ICONDIR (6 bytes). + w.Write((short)0); // reserved + w.Write((short)1); // type (1 = ICO) + w.Write((short)frames.Count); // count + + // ICONDIRENTRY[] (16 bytes each), data offsets need to come after all entries. + int entriesSize = 6 + (frames.Count * 16); + int dataOffset = entriesSize; + foreach (var (size, data, _) in frames) + { + byte width = (byte)(size == 256 ? 0 : size); + byte height = (byte)(size == 256 ? 0 : size); + w.Write(width); // width (0 means 256) + w.Write(height); // height + w.Write((byte)0); // colorCount (0 for 32bpp) + w.Write((byte)0); // reserved + w.Write((short)1); // planes + w.Write((short)32); // bitCount + w.Write(data.Length); // sizeInBytes + w.Write(dataOffset); // offset + dataOffset += data.Length; + } + + foreach (var (_, data, _) in frames) + { + w.Write(data); + } +} From 39b07481e29f6315df9329a63a6c2416bc27984b Mon Sep 17 00:00:00 2001 From: Devang Kumawat Date: Sat, 16 May 2026 23:02:36 +0530 Subject: [PATCH 03/28] Phase 9: MainWindow + 6 tabs + theming + pill flips with theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 26 +- docs/APP_DESIGN.md | 1354 +++++++++++++++++++++ src/KusPus.App/App.xaml.cs | 25 +- src/KusPus.App/AutostartRegistry.cs | 49 + src/KusPus.App/FloatingPillWindow.xaml | 73 +- src/KusPus.App/FloatingPillWindow.xaml.cs | 9 +- src/KusPus.App/MainWindow.xaml | 784 ++++++++++++ src/KusPus.App/MainWindow.xaml.cs | 1315 ++++++++++++++++++++ src/KusPus.App/ThemeApply.cs | 62 + src/KusPus.App/ThemeTokens.cs | 119 ++ src/KusPus.App/TrayManager.cs | 6 +- 11 files changed, 3751 insertions(+), 71 deletions(-) create mode 100644 docs/APP_DESIGN.md create mode 100644 src/KusPus.App/AutostartRegistry.cs create mode 100644 src/KusPus.App/MainWindow.xaml create mode 100644 src/KusPus.App/MainWindow.xaml.cs create mode 100644 src/KusPus.App/ThemeApply.cs create mode 100644 src/KusPus.App/ThemeTokens.cs diff --git a/CLAUDE.md b/CLAUDE.md index 66de6b5..662e175 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,9 +14,10 @@ Read these IN ORDER before writing any code in a new phase: 1. **`docs/PRD.md`** — what we're building and why. Authoritative for scope, non-goals, acceptance criteria. The product contract. 2. **`docs/TECH_SPEC.md`** — how. Prescriptive — every architectural decision lives here (file layout, threading, P/Invoke, naming, dependencies). -3. **`docs/PILL_DESIGN.md`** — visual/motion spec for the floating pill (Phase 8+). **Supersedes TECH_SPEC §24** where they conflict (geometry, state machine, motion model, accent line, choreography). -4. **`docs/ROADMAP.md`** — what's deferred past v1.0. "Phase 2+", "deferred to ROADMAP", or any v1.1+ heading = **NOT in scope right now.** -5. **`docs/PROCESS.md`** — the gate-driven workflow. Follow it exactly. +3. **`docs/APP_DESIGN.md`** — full visual + interaction spec for every user-facing surface (pill, MainWindow, onboarding, tray). Tokens, components, layouts. Use it for Phase 9+ UI. +4. **`docs/PILL_DESIGN.md`** — pill-specific spec, including the **§10 hover-extend override** (close + settings buttons on hover). Where it conflicts with APP_DESIGN §2 (click-through), **PILL_DESIGN §10 wins** — user-confirmed override. +5. **`docs/ROADMAP.md`** — what's deferred past v1.0. "Phase 2+", "deferred to ROADMAP", or any v1.1+ heading = **NOT in scope right now.** +6. **`docs/PROCESS.md`** — the gate-driven workflow. Follow it exactly. **Conflict resolution:** PRD §scope decisions outrank TECH_SPEC §implementation decisions outrank anything else. If you find a contradiction between docs, surface it before picking a side. Never quietly resolve a conflict. @@ -101,7 +102,12 @@ When a gate forces a deliberate deviation from TECH_SPEC's literal text, log it - **`src/KusPus.Audio/AudioRecorder.cs`** — same CA1848/CA1873 suppression as the other layers (start/stop/cap logging only). - **`src/KusPus.Audio/IAudioRecorder.cs`** — exposes `event EventHandler? DefaultDeviceChanged` rather than the spec §14 prose "surface error in pill" (the spec describes a behaviour, not a mechanism). Event lets the Coordinator subscribe in Phase 6 and translate to pill state. - **`src/KusPus.Audio/AudioRecorder.cs` (REVERTED 2026-05-16)** — Phase 4 originally used `MediaFoundationResampler` instead of the spec §14 `WdlResamplingSampleProvider` chain. Phase 6 manual smoke uncovered why MF was the wrong choice: it pads the output buffer with zeros when the source has no fresh data, so the worker writes target-format bytes at SSD speed (~500× realtime) until the disk fills. Reverted to the spec-prescribed `ToSampleProvider → ToMono → WdlResamplingSampleProvider(16k) → SampleToWaveProvider16` pull-based chain — WDL returns 0 when source is empty, so the worker correctly stalls. -- **`src/KusPus.App/FloatingPillWindow.xaml.cs`** — **PRD G4 deviation (intentional, dev-only, narrowing):** pill is visible at ALL times during Phase 8 development, showing the current FSM state. PRD G4 says "visible only when in use" — Cluster 8F restores that behaviour once the paste-confirmation overlay (8D) and acrylic/DPI work (8B + 8E) land. As of Cluster 8A the pill is back to the spec'd compact 360×64 bottom-center dark rounded shape (no longer the debug red center rectangle); only the always-visible aspect is still a deviation. Apply `SetWindowPos(SWP_FRAMECHANGED)` after toggling extended styles so Win 11 25H2 picks them up without the layered window going invisible. +- **`src/KusPus.App/FloatingPillWindow.xaml.cs`** — **PRD G4 deviation (user-requested, ongoing):** pill stays visible between dictations, showing an "Idle" content state (faint SVG icon + "KusPus" label). PRD G4 + APP_DESIGN §2.4 say no idle pill — user explicitly wants always-visible until the Settings modal exposes the close path through the tray. Will revert to the spec'd hidden-when-not-in-use behaviour once Phase 9's tray "Preferences…" / "Quit" items make discoverability acceptable. +- **`src/KusPus.App/FloatingPillWindow.xaml{,.cs}` — APP_DESIGN §2 / PILL_DESIGN.md fully implemented in Phase 8.** 200×56 Mica surface, 8 px DWM rounded corners, §3.1 dark gradient, §3.3 hairline border + drop shadow + inner highlight, five-state machine (Hidden/Recording/Transcribing/Confirmed/Error), §4.2 damped-target visualizer (20 bars × 3 px × 4 px gap, real audio via `IAudioRecorder.Levels`), 14 px ¾-arc spinner, 136 × 1.5 mint accent line with per-state opacity, 120 / 150 ms motion. `WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW` for focus-proof + hidden-from-shell. Per-monitor DPI math via `MonitorFromWindow` + `GetDpiForMonitor`. +- **`src/KusPus.App/FloatingPillWindow.xaml{,.cs}` — `PILL_DESIGN.md §10` hover-extend override (user-requested):** pill width animates 200 → 280 on hover, revealing Settings (placeholder) + Close buttons. `WS_EX_TRANSPARENT` therefore NOT applied (overrides APP_DESIGN §2.1 / §8.3 click-through). Drag-anywhere-except-buttons via `Window.DragMove` with `VisualTreeHelper`-based button-source detection. +- **Draggable pill + hybrid-sticky multi-monitor (beyond spec, user-requested):** session-only `Dictionary` keyed by `MONITORINFOEX.szDevice` remembers per-monitor positions. On `Armed`/`Recording` transitions, `EnsurePillOnForegroundMonitor` jumps the pill to the focused window's monitor (at remembered or default position). Dictionary cleared every fresh process start per user spec. +- **`src/KusPus.App/AppCoordinator.cs` + `src/KusPus.Core/State/CoordinatorSnapshot.cs`** — added `PostPasteInfo (Pasted, TargetApp, ErrorReason)` on the snapshot. `AppCoordinator.EmitPostPasteSnapshot` fires one extra snapshot from `DeliverAsync` / `HandleFailureAsync` so the pill can render Confirmed (1 s) or Error (2 s) per APP_DESIGN §2.4. +- **`src/KusPus.App/KusPus.App.csproj`** — adds `SharpVectors.Wpf 1.8.5` so the pill Idle state can load `icons/icon.svg` via `` rather than hand-converted XAML. Same SVG file is the single source for the tray, taskbar, Task Manager, and .exe icon (via `tools/IconBuilder` → `icons/icon.ico`). SVG `viewBox` tightened to `272 246 480 480` so the 5-bar content fills ~94 % of every rendered frame instead of ~44 %. - **`src/KusPus.Audio/AudioRecorder.cs`** — RMS is computed on the worker thread over each 1/15s output frame rather than "in the WASAPI callback over 20 chunks per buffer" per §14. User-visible behaviour is identical (20-channel array at 15 Hz). The worker location avoids doing math on the capture thread (NAudio holds a lock that delays the next callback). - **`src/KusPus.Audio/RecordedFile.cs`** — adds `bool CappedAtLimit = false` so Phase 6 can surface the spec §14 "Recording capped at 50 min" pill message. Optional ctor parameter; pre-existing callers continue to compile. - **`test/KusPus.Native.Tests/`** — 5th test project (spec §1 lists 3). Pure-helper coverage for `PasteEngine` (terminal-detect, friendly-name, SendInput payload) and `HotkeyEngine.ProcessKey` chord state machine. LL-hook itself and end-to-end paste are Phase 6 manual smoke per §33. @@ -113,13 +119,13 @@ When a gate forces a deliberate deviation from TECH_SPEC's literal text, log it - **`src/KusPus.Native/HotkeyEngine.cs`** — Spec §13 step 6 prescribes a `Channel` between the LL callback and the subject. Real impl emits directly to `Subject` AFTER releasing the state lock, which avoids the deadlock-under-callback risk without the channel allocation/dispatch overhead. At v1's input rate (a handful of events per chord activation) this is well within the `LowLevelHooksTimeout` budget. Revisit if the watchdog observes timeouts. - **`src/KusPus.Native/HotkeyEngine.cs` — LWin keyup is NOT consumed** (spec §13 says it should be; that turned out to be a bug). The Ctrl-tap injection still runs (AHK `#MenuMaskKey` idiom — masks the Start menu) but the real LWin keyup is allowed to reach the OS so its internal "Win is held" state clears. Consuming the keyup left Win stuck-down — every later `SendInput(Ctrl+V)` from PasteEngine read as `Win+Ctrl+V` (opens Action Center / Quick Settings) and every later keystroke became a `Win+key` system shortcut. Validated by AutoHotkey community docs + PowerToys issue #35345 / #18175. **Spec §13 needs an update** when the user is reviewing — flagged in conversation, not edited here per CLAUDE.md rule. - **`src/KusPus.Whisper/WhisperRunner.cs`** — empty `expectedWhisperSha256` now skips the integrity check (dev mode). Phase 12 release builds populate the SHA from `installer/payload/whisper/SHA256SUMS`. -- **`src/KusPus.App/`** — Phase 6 milestone code. Several v1-only simplifications, all to be revisited in their planned phases: - - Pill: plain `Topmost`/click-through/`WS_EX_NOACTIVATE` window with a single status text; no acrylic, no animations, no visualizer (Phase 8). Position math uses `Screen.FromPoint` + `MonitorFromPoint` + `GetDpiForMonitor` to convert physical work-area pixels to DIPs (moved up from Cluster 8E into 8A because mixing units silently dropped the pill off the bottom of high-DPI screens — see dotnet/wpf #4127). - - No MainWindow yet (Phase 9). Tray menu has Toggle + Quit only. - - Single-instance "bring main to front" broadcast deferred until MainWindow exists (Phase 9). Second launch just exits silently for now. - - `UseWindowsForms=true` alongside `UseWPF=true` to get `NotifyIcon`-style tray and `Screen.FromPoint`. Triggers ambiguous-`Application` references — App uses `using WpfApplication = System.Windows.Application` and qualifies the partial-class base type. +- **`src/KusPus.App/`** — Phase 6 milestone code. Several v1-only simplifications: + - Pill: built out in Phase 8 per `docs/PILL_DESIGN.md` + APP_DESIGN §2. The Phase 6 plain-Topmost/text-only pill is gone; see the Phase 8 entries above for the current state. + - MainWindow under construction (Phase 9 — starting now). Tray menu has Toggle + Quit until 9A adds Preferences / History items. + - Single-instance "bring main to front" broadcast still deferred until the WndProc message handler lands with MainWindow. Second launch exits silently for now. + - `UseWindowsForms=true` alongside `UseWPF=true` to get `NotifyIcon`-style tray and `Screen.FromPoint`. Triggers ambiguous-`Application` / `Button` / `Rectangle` / `Color` / `Point` references — App uses local aliases (e.g. `WpfRectangle`, `WpfPoint`) and fully qualifies where needed. - `KUSPUS_WHISPER_DIR` and `KUSPUS_WHISPER_SHA256` env vars override the default `{app}\whisper` path + skip-integrity-check dev mode. Phase 12 installer sets these from real values. - - Sentry, autostart registry write, onboarding modal — all deferred to Phases 10/11. + - Sentry, autostart registry write, onboarding modal — deferred to Phases 10/11; autostart toggle UI lands in Phase 9 General tab. - `CA1001` suppressed on `App` (it owns disposable fields but inherits from `System.Windows.Application` which isn't `IDisposable`; cleanup happens in `OnExit`). Append to this list (don't replace) when a new deviation lands. diff --git a/docs/APP_DESIGN.md b/docs/APP_DESIGN.md new file mode 100644 index 0000000..391a5c1 --- /dev/null +++ b/docs/APP_DESIGN.md @@ -0,0 +1,1354 @@ +\# KusPus — Full Design Specification + + + +> This document is the single source of truth for the KusPus visual + interaction system. Hand it to a designer, an image generator, or another AI to reproduce the design across all surfaces. It is implementation-agnostic — translate the tokens and components into whatever framework you build with. + + + +\--- + + + +\## 0. Product Summary + + + +\*\*KusPus\*\* is a lightweight, local-first \*\*Windows dictation utility\*\*. + + + +> Press a hotkey. Speak. The transcript pastes into whatever app has focus. + + + +\- \*\*Audience.\*\* Power users, developers, writers — people who type all day and would rather not. + +\- \*\*Stack constraint.\*\* Native Windows 11 (Mica/Acrylic). Windows 10 supported as a fallback. + +\- \*\*No cloud.\*\* No accounts, no browser UI, no telemetry by default. + +\- \*\*Surface count.\*\* Three user-facing surfaces total: the \*\*floating pill\*\*, the \*\*main window\*\*, and the \*\*first-launch onboarding\*\*. Plus a \*\*tray icon + menu\*\*. + + + +The brand temperament is \*\*quiet, instant, native, calm, voice.\*\* If a decision would make the app feel louder than the OS shell around it, the decision is wrong. + + + +\--- + + + +\## 1. Brand Identity + + + +\### 1.1 The mark + + + +The KusPus mark is a \*\*voice stack\*\*: five vertical pearly-mint pills arranged in a \*\*center-heavy envelope silhouette\*\* — tallest in the middle, shorter at the edges. Each bar has a faint mint glow ellipse underneath, suggesting reflected light from the visualizer in the product. + + + +\*\*Geometry (in a 1024 × 1024 artboard):\*\* + + + +| Bar | x | y | width | height | corner radius | + +|---|---|---|---|---|---| + +| 1 (outer) | 288 | 402 | 64 | 146 | 32 | + +| 2 | 384 | 332 | 64 | 286 | 32 | + +| 3 (center) | 480 | 260 | 64 | 424 | 32 | + +| 4 | 576 | 332 | 64 | 286 | 32 | + +| 5 (outer) | 672 | 402 | 64 | 146 | 32 | + + + +\- \*\*Fill:\*\* vertical gradient `#F5F5F2` (top) → `#EEF4EE` (72 %) → `#CDEFD9` (bottom). Pearly, not pure white. + +\- \*\*Glow:\*\* under each bar, a horizontal ellipse filled `#4DDBA6` at 18–20 % opacity, blurred 10 px stdDev, color-matrixed to mint. Glow ellipses sit slightly \*below\* the bar baseline. + +\- \*\*Container:\*\* rounded square with corner radius \*\*22 % of the side\*\*. Background `#1C1C1E` on dark, `#F3F3F3` on light. + + + +\### 1.2 The wordmark + + + +\- Spelled \*\*`KusPus`\*\* — capital K, capital P. \*\*Mid-string cap is intentional\*\*; never flatten to `Kuspus` or `KUSPUS`. + +\- \*\*Typeface:\*\* Segoe UI Variable, weight 600. Fallback: Segoe UI 600, then Inter 600. + +\- \*\*Tracking:\*\* −0.5 % to −1 %. + +\- \*\*Color:\*\* primary text token (white on dark, near-black on light) — \*\*never mint\*\*. + +\- \*\*Lockup:\*\* when paired with the icon horizontally, the gap between them = 0.5 × the icon side. + +\- \*\*Optional motif:\*\* a 1.5 px mint underline beneath the wordmark, \*\*136 px wide\*\*, gradient-faded at both ends — same accent stroke used over the pill's visualizer. + + + +\### 1.3 Color palette + + + +| Token | Value | Where used | + +|---|---|---| + +| \*\*Mint\*\* \*(primary brand)\* | `#4DDBA6` | Accent line, selected states, brand highlights, success affirmations | + +| Mint glow | `rgba(77,219,166,0.22)` | Soft outer glows behind mint elements | + +| Charcoal | `#1E1E1E` | Pill surface fill (dark, effective at 90 % opacity) | + +| App background (dark) | `#202020` | Main window body | + +| Surface (dark) | `#2A2A2C` | Cards, inputs in dark mode | + +| Surface elevated (dark) | `#323234` | Hover state, dropdowns | + +| App background (light) | `#F3F3F3` | Main window body (light) | + +| Surface (light) | `#FFFFFF` | Cards in light mode | + +| Error red | `#EF5350` | Error states, danger buttons | + +| Success | `#4CAF50` | OK indicators in history, mic detect | + +| Warning amber | `#FFB74D` | Shortcut conflict warnings | + + + +\*\*Rules:\*\* + +\- Mint is the \*\*only saturated color in normal flow\*\*. If you find yourself reaching for a second brand color, stop. + +\- Error red is reserved for \*\*the error state only\*\* — not for emphasis, not for delete buttons-at-rest. A "Delete" button is ghost-styled until destructive intent is being shown (e.g., a confirmation modal). + +\- Never invent gradients beyond the icon's pearly-mint gradient and the pill's surface gradient. No marketing-style hero gradients anywhere. + + + +\### 1.4 Type system + + + +Single family — \*\*Segoe UI Variable\*\* (fallback chain: `"Segoe UI Variable", "Segoe UI", -apple-system, system-ui, sans-serif`). One monospace family for code/keys: `ui-monospace, "Cascadia Mono", "Cascadia Code", "SF Mono", Menlo, monospace`. + + + +| Role | Size | Weight | Letter-spacing | Notes | + +|---|---|---|---|---| + +| Hero / wordmark | 56 px | 600 | −0.03 em | Brand only | + +| Window title | — | — | — | Use system title bar | + +| Step title (onboarding) | 22 px | 600 | −0.02 em | | + +| Section title | 13 px | 600 | −0.005 em | Inside main window | + +| Row title | 13 px | 500 | −0.005 em | Inside list cards | + +| Body | 13 px | 500 | −0.005 em | Default copy | + +| Body small | 12.5 px | 500 | −0.005 em | Error pill text | + +| Subtitle / secondary | 11.5–12 px | 400 | 0 | Helper copy | + +| Micro-label (`RECORDING`) | 9.5 px | 500 | 0.08 em uppercase | Under visualizer | + +| Eyebrow | 10.5 px | 600 | 0.1 em uppercase | Onboarding step counter | + +| Mono (keys, timestamps) | 10–14 px | 500 | 0 | Cascadia Mono / monospace | + + + +\--- + + + +\## 2. Surface 1 · The Floating Pill + + + +99 % of daily interaction. Visible only when the app is in a dictation cycle; otherwise the OS shouldn't show it exists. + + + +\### 2.1 Anatomy + + + +| Property | Value | + +|---|---| + +| Width | \*\*200 px\*\* logical (fixed across all states) | + +| Height | \*\*56 px\*\* | + +| Corner radius | \*\*8 px\*\* — Windows 11 surface rounding, \*\*not iOS super-ellipse\*\* | + +| Material | Mica/Acrylic on Win11; solid translucent fill on Win10 | + +| Position | Bottom-center of the active monitor, \*\*40 px above the taskbar\*\* | + +| Z-order | Always-on-top | + +| Interaction | \*\*Click-through.\*\* Mouse events pass through; keyboard focus never moves | + +| Taskbar / Alt-Tab | Hidden from both | + +| Inner horizontal padding | 32 px each side (content track = 136 px) | + +| Shadow (dark) | `0 12 32 rgba(0,0,0,0.45) + 0 2 6 rgba(0,0,0,0.35) + 1 px inset border` | + +| Shadow (light) | `0 12 32 rgba(0,0,0,0.14) + 0 2 6 rgba(0,0,0,0.08) + 1 px inset border` | + +| Top inner highlight | 1 px line, `rgba(255,255,255,0.05)` dark / `rgba(255,255,255,0.7)` light | + + + +\### 2.2 The accent line + + + +A thin glowing horizontal stroke at the top edge — the only chrome on the pill. + + + +| Property | Value | + +|---|---| + +| Width | \*\*136 px\*\* (hugs the visualizer) | + +| Height | 1.5 px | + +| Position | Centered, flush to top edge (`top: 0`) | + +| Fill | Horizontal gradient `transparent → accent (50%) → transparent` | + +| Glow (recording/error) | Outer blur `0 0 12 px {accent}@80%` + offset `0 2 14 px {accent}@40%` | + +| Opacity by state | `recording` 1.0 · `error` 1.0 · `transcribing` 0.55 · `confirmed` 0.40 · `hidden` 0 | + +| Color | Mint by default (`#4DDBA6`); \*\*shifts to `#FF4D4F` (error red) instantly — no fade\*\* | + + + +\### 2.3 The visualizer + + + +\*\*This is the message\*\* when recording. No text is needed; the visualizer carries the meaning. + + + +| Property | Value | + +|---|---| + +| Bar count | 20 | + +| Bar width | 3 px | + +| Bar corner radius | 2 px | + +| Bar gap | 4 px | + +| Total track width | \*\*136 px\*\* (20 × 3 + 19 × 4) | + +| Track height | 28 px | + +| Bar height range | 4 px (idle) ↔ 26 px (max) | + +| Active bar fill | `rgba(255,255,255,0.92)` (dark) / `rgba(20,20,20,0.85)` (light) | + +| Idle bar fill | `rgba(255,255,255,0.28)` (dark) / `rgba(20,20,20,0.25)` (light) | + +| Per-bar glow | When level > 0.7, add `0 0 6 px {accent}@33%` shadow | + + + +\#### Motion model (language-agnostic pseudocode) + + + +``` + +every animation frame: + + dt = clamp(now - lastFrame, 0, 64ms) / 1000 + + + + if (recording and now > nextTargetAt): + + nextTargetAt = now + random(90..150) ms + + # voice envelope — louder in center, softer at edges + + speak = 0.55 + sin(now / 380) \* 0.25 + random(-0.125, +0.125) + + for each bar i in 0..19: + + center\_weight = 1 - abs(i - 9.5) / 9.5 # 1.0 at center, 0 at edges + + base = 0.18 + center\_weight \* 0.45 + + jitter = random(-0.30, +0.70) \* 0.55 # asymmetric — favors loud + + target\[i] = clamp(base \* speak + jitter, 0.05, 1.0) + + + + if (not recording): + + target\[i] = 0.05 for all bars + + + + # damped approach (per-bar rate so they don't move in lockstep) + + for each bar i: + + rate = recording ? (14 + (i % 3) \* 3) : 6 + + k = 1 - exp(-rate \* dt) + + level\[i] += (target\[i] - level\[i]) \* k + + + + # render + + bar\_height\[i] = 4 + level\[i] \* 22 + +``` + + + +Replace `target\[i]` with real-amplitude × center\_weight × jitter if you have live audio data; keep the damping pass. + + + +\### 2.4 The five states + + + +\#### 1 · Hidden + +Nothing on screen. Window not rendered (or fully transparent + non-interactive). There is \*\*no "idle" pill\*\*. + + + +\#### 2 · Recording + +\- Dark/light surface as theme dictates. + +\- Mint accent line at full opacity + glow. + +\- 20-bar visualizer running (motion model above). + +\- 4 px below the visualizer: micro-label \*\*`RECORDING`\*\* in 9.5 px uppercase, letter-spacing 0.08 em, muted-text color. + + + +\#### 3 · Transcribing + +\- Surface unchanged. + +\- Accent line opacity drops to 0.55. + +\- Center content: \*\*14 px spinner\*\* (1.5 px stroke, \~270° dash arc, 0.9 s linear rotation) + \*\*`Transcribing…`\*\* in 13 px / 500 / primary-text-color. 8 px gap. + + + +\#### 4 · Paste confirmed + +\- Surface unchanged. + +\- Accent line opacity drops to 0.40. + +\- Visualizer \*\*continues to run\*\* but at \*\*35 % opacity\*\* with a \*\*radial mask\*\* punching a transparent hole through the middle: + + + +``` + +mask-image: radial-gradient( + + ellipse 60% 140% at 50% 50%, + + transparent 0%, + + transparent 35%, + + rgba(0,0,0,0.55) 60%, + + black 88% + +); + +``` + + + +\- Text \*\*`Pasted into `\*\* fades in (200 ms, opacity 0→1 + translateY 2→0). `` is \*\*bold 600\*\*, the rest is \*\*500\*\*. 13 px. + +\- Holds 1 s, then the whole pill fades out (120 ms). + + + +\#### 5 · Error + +\- Surface unchanged. + +\- Accent line \*\*shifts to red `#FF4D4F` instantly\*\* — no fade on color. + +\- Center content: a 5 × 5 px red dot with a soft red glow + brief error text (12.5 px / 500). 8 px gap. + +\- Holds \~2 s, then the pill fades out. + +\- Copy must fit the 136 px content track at 12.5 px. Truncate with `…`. Approved short forms: `Microphone blocked`, `Disk full`, `No internet`, `Window closed`, `Paste failed`, `Model not loaded`. + + + +\### 2.5 Pill motion language + + + +| Transition | Duration | Curve | Notes | + +|---|---|---|---| + +| Appear (hidden → any) | 120 ms | ease-out | Opacity 0 → 1. No scale, no slide, no bounce. | + +| Disappear (any → hidden) | 120 ms | ease-in | Opacity 1 → 0. | + +| State content crossfade | 150 ms | ease | Old content fades out, new fades in. | + +| Confirmation choreography | 200 + 1000 + 120 ms | — | See §2.6 | + +| Error accent color shift | \*\*0 ms\*\* | — | Instant — no fade on the color, only on appearance. | + + + +\### 2.6 Confirmation choreography + + + +``` + +t = 0 transcribing showing + +t = 0 transcript ready → switch to confirmed state + +t = 0 visualizer ghosts to 35 % opacity, mask applies, accent line drops to 0.4 + +t = 0..200 "Pasted into " text fades in (opacity 0→1, translateY 2→0) + +t = 1000 begin pill fade-out (120 ms) + +t = 1120 pill fully hidden + +``` + + + +\### 2.7 Reduced motion + +Respect the OS reduced-motion preference: hold all bars at the mean level (\~0.45), no animation. All fades become instant. + + + +\--- + + + +\## 3. Surface 2 · The Main Window + + + +Opened intentionally from the tray. Closing hides (does not quit). + + + +\### 3.1 Window chrome + + + +| Property | Value | + +|---|---| + +| Default size | 880 × 620 | + +| Minimum size | 820 × 600 | + +| Resizable | Yes | + +| Title bar | \*\*System chrome\*\* — no custom title bar. Show app icon (16 px, monochrome) + "KusPus" wordmark on the left of the bar. | + +| Title bar height | 32 px | + +| Title bar background | `#1A1A1C` (dark) / `#EAEAEC` (light) | + +| Min / Max / Close | Native chrome behavior. Close button shows `#E81123` hover background. | + +| Closing | \*\*Hides the window\*\* — quit is only via the tray menu. | + +| Corner radius | 8 px on the outer window | + + + +\### 3.2 Layout + + + +Two-pane: \*\*left sidebar (200 px, fixed) + right content area (fills)\*\*. + + + +\#### Sidebar + +\- Background: `#1B1B1D` (dark) / `#ECECEE` (light). + +\- Vertical 1 px right divider (`rgba(255,255,255,0.06)` dark / `rgba(0,0,0,0.06)` light). + +\- Padding: 14 px vertical, 10 px horizontal. + +\- 6 tab buttons stacked vertically. Each: + + - 9 px × 12 px padding, 6 px corner radius, 12 px icon + label gap. + + - Icon (16 px, line-style, 1.4 px stroke). + + - Label in 12.5 px / 500. + + - \*\*Selected:\*\* elevated background (`#2A2A2C` dark / `#FFFFFF` light), icon turns mint (`#4DDBA6`), label gets full primary text color, soft `0 1 2 rgba(0,0,0,0.08)` shadow. + + - \*\*Selected accent stripe:\*\* 3 × 18 px mint pill, 2 px corner radius, sitting just left of the sidebar's inner padding. + + - \*\*Hover (non-selected):\*\* `rgba(255,255,255,0.05)` (dark) / `rgba(0,0,0,0.04)` (light). + +\- \*\*Sidebar footer pill:\*\* small status row at the very bottom of the sidebar — mint dot + `Idle · tiny.en` + monospaced hotkey glyph `⌃⊞`. + + + +\#### Content area + +\- Padding: 28 px top / 36 px sides / 32 px bottom. + +\- Vertical scroll only. + +\- Background: `#202020` (dark) / `#F3F3F3` (light). + + + +\### 3.3 The six tabs + + + +The order is fixed: \*\*General → Audio → Models → History → Privacy → About.\*\* + + + +\#### Tab 1 · General + + + +The most-changed settings. Three sections: + + + +1\. \*\*Hotkey\*\* \*(hero control)\* + + - Big tappable card (\~440 px wide). 20 × 22 px padding, 10 px corner radius, 1.5 px border (solid when idle, \*\*dashed mint when listening\*\*). + + - Eyebrow: `HOTKEY` uppercase 10.5 px, mint when listening. + + - Each chord key rendered as a \*\*monospaced keycap\*\*: 7 × 14 px padding, 6 px corner radius, surface background, 1 px strong border, faint `0 1 0 rgba(0,0,0,0.2)` bottom shadow. + + - `+` separator between keys in muted-text color. + + - Default chord: \*\*`LCtrl + LWin`\*\*. + + - \*\*Conflict warning row:\*\* amber dot + bold "Shortcut conflict." + explanation. Appears inline below the picker when the chord clashes with a Windows shortcut. + + + +2\. \*\*Startup\*\* + + - One row card: title `Launch KusPus when I sign in` + subtitle, with a toggle on the right. Default \*\*OFF\*\*. + + + +3\. \*\*Appearance\*\* + + - One row card: title `Theme` + 3-way segmented control `Auto / Light / Dark`. Default `Auto`. + + + +\#### Tab 2 · Audio + + + +1\. \*\*Input device\*\* — one row: device label + native-styled `