# Win Dictation — Developer Task List A prioritised, junior-friendly backlog to finish polishing the app. Each task says **what**, **which files**, **steps**, **the code** (inline, or a pointer to the verbatim block in a companion doc), and **how to know it's done**. **Companion docs (full code lives here — don't retype, copy from them):** - `ARCHITECTURE-AND-DEVGUIDE.md` — Part B1 (chrome removal), Part B2 (progress + ETA). Verbatim code. - `MODERN-UI-AND-FIXES.md` — the modern UI design + GDI+ helpers. - `FINDINGS-FIXES-TESTS.md` — the test harness (`tests/test_core.cpp`). **Baseline (already done — do NOT redo):** batch record-then-transcribe; crash fixes (null-context guard, `g_modelOk`, `g_initializing`, `m_cfg_mtx`); append behaviour (`text_util.h`); no-hide + safe auto-paste; Clear button; `run_inference`/`transcribe_sync` refactor; modern dark UI (GDI+, rounded panel, owner-draw buttons, custom VU, dark caption); resizable window; `test-core` CMake target. **Conventions:** Effort = XS (<30 min) · S (≤2 h) · M (half-day) · L (1–2 days). Do phases in order; tasks within a phase are mostly independent unless "Depends on" says otherwise. After every task: `cmake --build build --config Release` must succeed with **zero new warnings**, and the app must still launch. **Color tokens (already in `main.cpp`, reuse — never hard-code hex elsewhere):** `C_BG #0F1115`, `C_SURFACE #181B22`, `C_SURFACEHI #20242D`, `C_BORDER #262B36`, `C_TEXT #E7E9EE`, `C_TEXTDIM #9AA0AB`, `C_ACCENT #6E8BFF`, `C_DANGER #FF5C5C`, `C_GOOD #46D39A`. --- ## Phase 0 — Repo hygiene (do first; clears traps) ### Task 0.1 — Delete the stale `src/CMakeLists.txt` - **Goal:** Remove a build file that references **removed** APIs (`init()`, `is_using_gpu()`) and a `test-audio` target the real build ignores. The authoritative build is the **root** `CMakeLists.txt`. - **Files:** `src/CMakeLists.txt` (delete). - **Steps:** Confirm `build.ps1` configures from repo root (`-S $RepoRoot`). It does. Delete `src/CMakeLists.txt`. - **Done when:** Clean build from root still works; no other file `add_subdirectory(src)`. - **Effort:** XS ### Task 0.2 — Remove the broken `src/test-audio.cpp` (replaced in Phase 4) - **Goal:** `src/test-audio.cpp` calls `m_transcriber.init(...)` / `is_using_gpu()` which no longer exist — it cannot compile. It's superseded by `tests/test_core.cpp` (Task 4.1). - **Files:** `src/test-audio.cpp` (delete), `src/record-test-audio.ps1` (keep — still useful for capturing WAVs), `src/TESTING.md` (mark superseded in Task 4.4). - **Done when:** No target references `test-audio.cpp`. - **Effort:** XS ### Task 0.3 — Create the `tests/` folder + placeholder - **Goal:** The root `CMakeLists.txt` already declares `add_executable(test-core tests/test_core.cpp ...)`, but the file doesn't exist yet → configure fails if anyone builds `test-core`. - **Steps:** Create `tests/` and add `tests/test_core.cpp` (full content in Task 4.1). Until then, the `test-core` target can stay; just don't build it. - **Done when:** `tests/test_core.cpp` exists and `cmake --build build --target test-core` compiles (after Task 4.1). - **Effort:** XS · **Depends on:** 4.1 for real content --- ## Phase 1 — Visual polish & assets ### Task 1.1 — App icon asset (custom) - **Goal:** Replace the placeholder icon with a clean, modern app icon used for the window, taskbar, and tray. - **Files:** `assets/icon-source.png` (new, 1024×1024), `src/icon.ico` (generated), `src/convert_icon.py` (fix paths), `src/win-dictation.rc` (already references `101 ICON "icon.ico"`). - **Design spec:** Flat, minimal. A single rounded **microphone** glyph, centered, on a dark charcoal rounded-square (`#15171C`). Mic filled with the indigo accent (`#6E8BFF`), subtle top-down gradient to `#5B7BFF`. No text. Must read clearly at **16×16**. Keep ~12% padding around the glyph. - Ready-to-use generation prompt (AI image tool): *"Minimalist modern app icon, a single simple microphone glyph centered on a dark charcoal rounded square, microphone filled indigo #6E8BFF with a soft vertical gradient, flat design, crisp clean edges, no text, high contrast, legible at small sizes, 1024×1024."* - Or design in Figma/Inkscape and export 1024×1024 PNG. - **Steps:** 1. Put the source PNG at `assets/icon-source.png`. 2. Fix `convert_icon.py` to use real paths and multi-size output: ```python from PIL import Image img = Image.open("assets/icon-source.png").convert("RGBA") img.save("src/icon.ico", format="ICO", sizes=[(256,256),(64,64),(48,48),(32,32),(16,16)]) print("wrote src/icon.ico") ``` 3. Run it (`python src/convert_icon.py` from repo root). Confirm `src/icon.ico` exists. 4. Rebuild; the resource compiler picks up `src/icon.ico` via the `.rc`. - **Done when:** The new icon shows on the title bar, taskbar, Alt-Tab, and tray — sharp at all sizes. - **Effort:** S ### Task 1.2 — Tray icon reflects recording state (optional but nice) - **Goal:** When recording (window may be hidden), the **tray** icon turns red so state is visible at a glance. - **Files:** `assets/icon-rec-source.png` (new), `src/icon-rec.ico`, `src/win-dictation.rc` (add `102 ICON "icon-rec.ico"`), `src/main.cpp`. - **Steps:** 1. Create a red variant of the icon (mic in `#FF5C5C`). Convert to `src/icon-rec.ico` (same sizes), add `102 ICON "icon-rec.ico"` to the `.rc`. 2. In `main.cpp`, load both icons once: `HICON g_icoIdle, g_icoRec;` via `LoadIcon(hInst, MAKEINTRESOURCE(101/102))`. 3. Add a helper `void SetTrayIcon(bool rec){ nid.uFlags = NIF_ICON; nid.hIcon = rec?g_icoRec:g_icoIdle; Shell_NotifyIcon(NIM_MODIFY,&nid); }`. 4. Call `SetTrayIcon(true)` when recording starts, `SetTrayIcon(false)` on stop/cancel/result. - **Done when:** Start recording, hide the window — the tray icon is red; after transcription it returns to normal. - **Effort:** S · **Depends on:** 1.1 ### Task 1.3 — Kill the button hairlines + focus rectangles (B1.1) - **Goal:** Remove the thin light line around *Pinned* and the left/top lines on Copy/Paste/Clear. - **Files:** `src/main.cpp`; link `uxtheme.lib`. - **Steps (full code in `ARCHITECTURE-AND-DEVGUIDE.md` §B1.1):** 1. Add `#include ` and `#pragma comment(lib, "uxtheme.lib")`. 2. After creating each owner-draw button (Record, Pin, Copy, Paste, Clear), call `SetWindowTheme(hBtn, L"", L"");` (before/after `SetWindowSubclass` is fine). 3. Add `WS_CLIPCHILDREN` to the main window style in `CreateWindowExW`. 4. After all controls are created (end of the create block), call once: `SendMessageW(hMainWnd, WM_CHANGEUISTATE, MAKEWPARAM(UIS_SET, UISF_HIDEFOCUS), 0);` - **Done when:** No hairline around any button; tabbing between controls draws no dotted focus rect. - **Effort:** S ### Task 1.4 — Replace comboboxes with custom dropdowns (B1.2) - **Goal:** Remove the native Windows dropdown button (the "second arrow") on the mic + model selectors. - **Files:** `src/main.cpp`. - **Steps (full code — `DrawSelect`, `PopupProc`, `ShowSelectPopup`, `WM_APP_SELECT` — in `ARCHITECTURE-AND-DEVGUIDE.md` §B1.2):** 1. Add label/selection state: `std::vector g_audioItems; int g_audioSel=0;` and `g_modelItems`/`g_modelSel`. Populate them in `RefreshAudioDevices`/`RefreshModelList` (keep populating `g_modelComboPaths` in parallel). 2. Replace the two `COMBOBOX` creations with `BS_OWNERDRAW` buttons `ID_SEL_AUDIO`/`ID_SEL_MODEL`; subclass with `BtnProc`; `SetWindowTheme(.., L"", L"")`. 3. Add `#define WM_APP_SELECT (WM_USER + 5)` and `#define ID_SEL_AUDIO/ID_SEL_MODEL`. 4. Paste `DrawSelect`, route both IDs in `WM_DRAWITEM`. Paste `PopupProc` + `ShowSelectPopup`. On `WM_COMMAND` for the two IDs call `ShowSelectPopup(...)`. Handle `WM_APP_SELECT` to apply the choice (set `capture_id` / reload model). 5. Delete the now-unused `DrawCombo`, `WM_MEASUREITEM` combo branch, and `WM_CTLCOLORLISTBOX`. 6. Update `LayoutControls` to position `ID_SEL_AUDIO`/`ID_SEL_MODEL` where the combos were. - **Done when:** Each selector shows exactly one (our) chevron, opens a dark rounded popup, hover highlights rows, selecting reloads the model / switches mic, and clicking elsewhere dismisses it. - **Effort:** M · **Depends on:** 1.3 (shared `SetWindowTheme`) --- ## Phase 2 — Progress feedback & control ### Task 2.1 — Transcription progress + ETA (B2) - **Goal:** Replace the static "Transcribing…" with a moving progress bar + live status like **"Transcribing 1:40 · 45% · ~9s left"**. - **Files:** `src/transcriber.h`, `src/transcriber.cpp`, `src/main.cpp`. - **Steps (full code in `ARCHITECTURE-AND-DEVGUIDE.md` §B2.1–§B2.3):** 1. `transcriber.h`: add `set_progress_callback`, `audio_seconds()`, private `m_on_progress`, `m_audio_seconds`, and the static `s_progress` trampoline. 2. `transcriber.cpp`: implement `s_progress`; in `run_inference` set `m_audio_seconds`, `wp.progress_callback = &Transcriber::s_progress; wp.progress_callback_user_data = this;`. 3. `main.cpp`: add `#define WM_APP_PROGRESS (WM_USER + 6)`, `std::atomic g_progress{0};`, `DWORD g_busyStart;`. Register `set_progress_callback([](int p){ PostMessage(hMainWnd, WM_APP_PROGRESS, p, 0); })`. 4. Set `g_busyStart = GetTickCount(); g_progress = 0;` right before `stop_and_transcribe()`. 5. Handle `WM_APP_PROGRESS` (store + invalidate `g_vuRect`). Replace the `is_busy()` branch of `UpdateStatus` with the ETA formatter. Add `DrawProgress` and, in `WM_PAINT`, draw the progress bar in `g_vuRect` while busy (VU otherwise). In `WM_TIMER`, also invalidate `g_vuRect` while busy so the bar/ETA tick. - **Edge cases:** `progress < 3%` → show "Transcribing m:ss of audio…" (ETA not stable yet). Clamp `remain >= 0`. - **Done when:** A 1–2 min clip shows a filling bar + percentage + shrinking ETA and completes; short clips still feel instant. - **Effort:** M ### Task 2.2 — Cancel a running transcription (B2.4) - **Goal:** Let the user abort a long/incorrect transcription instead of waiting it out. - **Files:** `src/transcriber.h/.cpp`, `src/main.cpp`. - **Steps:** 1. `transcriber.h`: add `void request_cancel(){ m_abort = true; }`, private `std::atomic m_abort{false};`, static `s_abort`. 2. `transcriber.cpp`: in `run_inference`, `m_abort = false;` at the top, and set `wp.abort_callback = &Transcriber::s_abort; wp.abort_callback_user_data = this;` (skip if your `whisper.h` lacks `abort_callback`). 3. `main.cpp`: at the very top of the `HK_TOGGLE` handler add `if (g_tx.is_busy()) { g_tx.request_cancel(); SetStatus(hWnd, L"Cancelling…"); break; }` — so the Record button becomes "cancel" while busy. 4. In `WM_APP_RESULT`, when the result is empty *and* a cancel was requested, show "Cancelled" instead of "No speech detected" (track a `g_cancelRequested` flag, reset each Stop). - **Done when:** Pressing the button (or hotkey) mid-transcription stops it within ~1 s and the status reads "Cancelled". - **Effort:** S · **Depends on:** 2.1 --- ## Phase 3 — Robustness & persistence ### Task 3.1 — Persist settings between launches - **Goal:** Remember mic, model, pin state, auto-paste, auto-hide, and window position. Today everything resets each launch. - **Files:** `src/settings.h` (new, header-only), `src/main.cpp`. - **Code (`src/settings.h`):** ```cpp #pragma once #include #include struct AppSettings { int captureId = 0; std::wstring modelFile; // e.g. L"models\\ggml-tiny.en.bin" ("" = auto) bool pinned = true, autoPaste = true, autoHide = false; int winX = CW_USEDEFAULT, winY = CW_USEDEFAULT, winW = 400, winH = 340; }; inline std::wstring SettingsPath() { wchar_t buf[MAX_PATH]; GetModuleFileNameW(nullptr, buf, MAX_PATH); std::wstring p(buf); p = p.substr(0, p.find_last_of(L"\\/")); return p + L"\\win-dictation.ini"; } inline int GetIni(const wchar_t* k, int d){ return GetPrivateProfileIntW(L"app", k, d, SettingsPath().c_str()); } inline void PutIni(const wchar_t* k, int v){ wchar_t b[32]; wsprintfW(b, L"%d", v); WritePrivateProfileStringW(L"app", k, b, SettingsPath().c_str()); } inline void LoadSettings(AppSettings& s){ s.captureId = GetIni(L"captureId", s.captureId); s.pinned = GetIni(L"pinned", s.pinned) != 0; s.autoPaste = GetIni(L"autoPaste", s.autoPaste) != 0; s.autoHide = GetIni(L"autoHide", s.autoHide) != 0; s.winX = GetIni(L"winX", s.winX); s.winY = GetIni(L"winY", s.winY); s.winW = GetIni(L"winW", s.winW); s.winH = GetIni(L"winH", s.winH); wchar_t m[MAX_PATH]; GetPrivateProfileStringW(L"app", L"modelFile", L"", m, MAX_PATH, SettingsPath().c_str()); s.modelFile = m; } inline void SaveSettings(const AppSettings& s){ PutIni(L"captureId", s.captureId); PutIni(L"pinned", s.pinned); PutIni(L"autoPaste", s.autoPaste); PutIni(L"autoHide", s.autoHide); PutIni(L"winX", s.winX); PutIni(L"winY", s.winY); PutIni(L"winW", s.winW); PutIni(L"winH", s.winH); WritePrivateProfileStringW(L"app", L"modelFile", s.modelFile.c_str(), SettingsPath().c_str()); } ``` - **Wiring (`main.cpp`):** 1. Add a global `AppSettings g_set;` Call `LoadSettings(g_set);` **before** creating the window. 2. Apply: use `g_set.winX/Y/W/H` in `CreateWindowExW` (validate on-screen; fall back to `CW_USEDEFAULT` if off all monitors). Set `g_pinned = g_set.pinned`, `g_autoPaste = g_set.autoPaste`, `g_autoHide = g_set.autoHide`, `g_config.capture_id = g_set.captureId`. If `g_set.modelFile` is non-empty and the file exists, use it instead of `SelectOptimalModel`. 3. Save on change: after toggling pin/auto-paste/auto-hide, after a model/mic change, and on `WM_EXITSIZEMOVE` (window moved/resized → store rect) and `WM_DESTROY` (final save). A `void PersistNow()` that copies the live globals into `g_set` then `SaveSettings(g_set)` keeps it DRY. - **Done when:** Change mic/model, move/resize the window, toggle pin, quit, relaunch → everything is restored. Deleting the `.ini` restores defaults. - **Effort:** M ### Task 3.2 — Quick toggles in the tray menu - **Goal:** Expose Auto-paste, Always-on-top, and Auto-hide without building a settings panel. - **Files:** `src/main.cpp` (`ShowContextMenu`, `WM_COMMAND`). - **Steps:** Add checkable items to the tray popup (`MF_STRING | (flag?MF_CHECKED:0)`) with new IDs (`ID_TRAY_AUTOPASTE`, `ID_TRAY_TOPMOST`, `ID_TRAY_AUTOHIDE`). In `WM_COMMAND`, flip the matching global, apply (for top-most call `SetWindowPos(... HWND_TOPMOST/NOTOPMOST ...)`), then `PersistNow()`. - **Done when:** Right-click tray → toggles show check state, take effect immediately, and survive a relaunch. - **Effort:** S · **Depends on:** 3.1 ### Task 3.3 — Bound the recording length - **Goal:** A forgotten recording shouldn't grow memory without limit (~1.9 MB/30 s today, uncapped). - **Files:** `src/transcriber.h/.cpp`, `src/main.cpp`. - **Steps:** 1. `transcriber.h`: add `float recorded_seconds() const;` returning `m_capture` size / `WHISPER_SAMPLE_RATE` under `m_capture_mtx` (or maintain an atomic sample counter incremented in `on_audio`). 2. `main.cpp` `WM_TIMER` (recording branch): if `g_tx.recorded_seconds() >= kMaxRecordSeconds` (e.g. 600), auto-stop by posting the same path as a manual Stop, and set status "Max length reached — transcribing". - **Done when:** Recording auto-stops at the cap and transcribes what was captured; normal short clips unaffected. - **Effort:** S ### Task 3.4 — Surface hotkey-registration failures + make hotkeys configurable - **Goal:** Today `RegisterHotKey` return values are ignored — if another app owns `Ctrl+Shift+Space`, the hotkey silently dies. Also allow remapping. - **Files:** `src/main.cpp`, `src/settings.h`. - **Steps:** 1. Capture the return of both `RegisterHotKey` calls. If either fails, show a non-blocking status ("Hotkey in use — set another in win-dictation.ini") and still allow the on-screen Record button to work. 2. Read modifiers + key from the INI (`hkMods`, `hkVk`, defaulting to `MOD_CONTROL|MOD_SHIFT` + `VK_SPACE` / `'H'`); register those. (Full remap UI is a Phase 5 stretch — INI is enough now.) - **Done when:** With a conflicting global hotkey registered by another app, the app launches, warns, and the button still records; editing the INI changes the hotkey. - **Effort:** S · **Depends on:** 3.1 ### Task 3.5 — Lightweight logging - **Goal:** One small log file so field issues are diagnosable without a debugger. - **Files:** `src/logging.h` (new), `src/main.cpp`, `src/transcriber.cpp`. - **Code (`src/logging.h`):** ```cpp #pragma once #include #include #include inline void LogLine(const char* msg) { wchar_t buf[MAX_PATH]; GetModuleFileNameW(nullptr, buf, MAX_PATH); std::wstring p(buf); p = p.substr(0, p.find_last_of(L"\\/")) + L"\\win-dictation.log"; FILE* f = _wfopen(p.c_str(), L"a"); if (!f) return; SYSTEMTIME t; GetLocalTime(&t); fprintf(f, "%04d-%02d-%02d %02d:%02d:%02d %s\n", t.wYear,t.wMonth,t.wDay,t.wHour,t.wMinute,t.wSecond, msg); fclose(f); } ``` - **Log at minimum:** startup (model path, threads, GPU on/off), model load success/failure, mic-open failure, and each transcription (audio seconds, elapsed ms, output char count). Keep messages one line, ASCII. - **Optional:** if the file exceeds ~1 MB at startup, rename to `.log.1` (simple 1-file rotation). - **Done when:** `win-dictation.log` appears next to the exe and records a startup line + one line per transcription. - **Effort:** S --- ## Phase 4 — Tests, docs & packaging ### Task 4.1 — Create `tests/test_core.cpp` - **Goal:** Headless regression tests that catch the bugs we already fixed and lock in progress reporting. - **Files:** `tests/test_core.cpp` (new — **full content in `FINDINGS-FIXES-TESTS.md` §4.2**). - **Steps:** 1. Copy the test program from `FINDINGS-FIXES-TESTS.md` §4.2 into `tests/test_core.cpp`. 2. Add the progress assertion from `ARCHITECTURE-AND-DEVGUIDE.md` §B3 (max progress ≥95, monotonic). 3. Build + run: ```powershell cmake --build build --config Release --target test-core cd build\bin\Release copy ..\..\..\deps\SDL2-2.28.5\lib\x64\SDL2.dll . # if missing .\test-core.exe models\ggml-tiny.en.bin ..\..\..\samples\jfk.wav ``` - **Tests covered:** append rule; bad model path → no crash + empty; real clip → contains "country"; short audio → no crash; progress reaches ~100% and is non-decreasing. - **Done when:** `test-core.exe` prints "ALL TESTS PASSED" and exits 0. - **Effort:** S · **Depends on:** 2.1 (for the progress test) ### Task 4.2 — (Optional) CI workflow - **Goal:** Run the build + `test-core` on every push. - **Files:** `.github/workflows/build.yml` (new). - **Steps:** Windows runner → configure CMake (CPU-only) → build `win-dictation` + `test-core` → download `ggml-tiny.en.bin` → run `test-core` with `jfk.wav`. Fail the job on non-zero exit. - **Done when:** A pushed branch shows a green check that actually ran the tests. - **Effort:** M · **Depends on:** 4.1 ### Task 4.3 — Desktop / Start-Menu shortcut on install - **Goal:** One-click launch (the user already pins to taskbar; a shortcut makes first run easy). - **Files:** `src/package.ps1` (extend) or a new `install-shortcut.ps1`. - **Steps:** After packaging, create a `.lnk` via `WScript.Shell` with `TargetPath` = the exe and `WorkingDirectory` = its folder (so `models\` resolves even though we also use `exe_dir()`), `IconLocation` = the exe. (Snippet in `MODERN-UI-AND-FIXES.md` §9 / spec doc §9.) - **Done when:** Running the script creates a working Desktop shortcut that launches the app with the icon. - **Effort:** S ### Task 4.4 — Fix the documentation (it describes the OLD app) - **Goal:** `README.md`, `src/README.md`, `src/CHANGES.md`, `src/CUDA-SETUP.md`, `src/QUICK-REBUILD-GPU.md`, `src/TESTING.md`, `src/DESIGN.md`, `src/FIXES-APPLIED.md` still describe the **streaming / ring-buffer / 24-thread / Ctrl+Shift+R** design and RTX-3090 benchmarks — all now wrong/misleading. - **Steps:** 1. Rewrite the top-level `README.md` to describe the **current** app: push-to-talk batch transcription, `Ctrl+Shift+Space` to record/stop, `Ctrl+Shift+H` to hide, tray, always-on-top, copy + auto-paste, model/mic selectors, CPU-tuned (physical-core threads, tiny.en default). Remove ring-buffer/VAD/24-thread/streaming claims and the RTX benchmarks (or move GPU notes to an "optional" aside). 2. Update `build.ps1` end-of-run messages ("Hotkey: Ctrl+Shift+R", "Model: base.en") to match reality. 3. Mark `CHANGES.md`, `FIXES-APPLIED.md`, `TESTING.md`, `QUICK-REBUILD-GPU.md`, `CUDA-SETUP.md` as **historical/superseded** (a one-line banner at top), or fold the still-true bits into the README and delete the rest. Keep `DESIGN.md` only if updated to the batch architecture. - **Done when:** A new reader following `README.md` gets accurate hotkeys, model behaviour, and build steps; no doc claims a ring buffer or 24 threads. - **Effort:** M --- ## Phase 5 — Stretch features (nice-to-have) ### Task 5.1 — Hold-to-talk mode - **Goal:** Option to record only while a key is held (vs. toggle). - **Files:** `src/main.cpp`, `src/settings.h`. - **Steps:** Add a low-level keyboard hook (`SetWindowsHookEx(WH_KEYBOARD_LL, ...)`); on key-down of the chosen key start recording, on key-up `stop_and_transcribe`; debounce auto-repeat with a flag. Gate behind a `holdToTalk` INI setting; keep toggle as default. (Outline in the original spec doc §7.) - **Done when:** With the setting on, holding the key records and releasing transcribes; toggle mode still available. - **Effort:** M ### Task 5.2 — Settings panel (graduate from tray toggles + INI) - **Goal:** A small in-app settings popup (reuse the custom popup window from Task 1.4) for mic, model, auto-paste, auto-hide, hold-to-talk, and hotkey capture. - **Done when:** All settings are editable in-app and persist (Task 3.1). - **Effort:** L · **Depends on:** 1.4, 3.1 ### Task 5.3 — Export / save transcript - **Goal:** Save the transcript box to a `.txt` (and timestamped filename) from the tray menu or a button. - **Effort:** S ### Task 5.4 — Multi-language support - **Goal:** Allow non-English models + a language selector (currently hard-wired `en`). Swap to a multilingual model (`ggml-base.bin`) and set `WhisperConfig.language` from a selector. - **Effort:** M · **Depends on:** 1.4 (selector), 3.1 --- ## Definition of Done (per task) - [ ] Builds clean (`cmake --build build --config Release`), **zero new warnings**. - [ ] App launches, records, transcribes, appends, copies/pastes — no regressions. - [ ] `test-core.exe` exits 0 (after Phase 4). - [ ] Any new setting persists across relaunch (after Phase 3). - [ ] Change is reflected in `README.md` if user-facing. ## Suggested order (fastest path to "feels finished") 1. **0.1–0.3** (hygiene) → **1.3** (hairlines, 5-min win) → **2.1** (progress — biggest UX gain). 2. **1.1** (icon) → **1.4** (custom dropdowns) → **1.2** (tray state) → **2.2** (cancel). 3. **3.1** (persistence) → **3.2** (tray toggles) → **3.4** (hotkey safety) → **3.3** (length cap) → **3.5** (logging). 4. **4.1** (tests) → **4.4** (docs) → **4.3** (shortcut) → **4.2** (CI). 5. Stretch (**5.x**) as desired. ## Verification matrix (final smoke test) | Area | Check | |---|---| | Crash-free | Record/stop 10× incl. a 2-min clip; window never vanishes; process stable | | Progress | 2-min clip shows filling bar + % + shrinking ETA; cancel works | | Chrome | No hairlines/focus rects; selectors have one chevron + dark popup | | Paste | Hotkey-from-another-app pastes the latest utterance; button = copy only | | Persistence | mic/model/pin/auto-paste/window pos restored after relaunch | | Assets | New icon crisp in title bar, taskbar, Alt-Tab, tray; red tray icon while recording | | Robustness | Missing model → clear message (no crash); hotkey conflict → warned; long record auto-stops | | Tests/docs | `test-core` green; README matches actual hotkeys/behaviour |