Files

24 KiB
Raw Permalink Blame History

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 (12 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:
      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 <uxtheme.h> 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<std::wstring> 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<int> 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 12 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<bool> 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):
    #pragma once
    #include <windows.h>
    #include <string>
    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):
    #pragma once
    #include <windows.h>
    #include <cstdio>
    #include <string>
    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:
      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.10.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