From 973b5c3356873151b05d31d19ca876c4b6392414 Mon Sep 17 00:00:00 2001 From: Josh Rufer Date: Wed, 23 Sep 2026 19:53:07 -0500 Subject: [PATCH 1/3] feat(overlay): never show the target visualizer over SYSTEM RESPONDING Stack the overlay's parts in a column and hide the recording visualizer while the SYSTEM RESPONDING pill is up, so the two never overlap. Command and MCP pills are shown alongside the visualizer during recording. - The visualizer stays mounted while the whole overlay fades out, so its outro transition still plays (an earlier draft unmounted it the instant recording stopped). It is dropped once recording ends only when the overlay stays up for a command/MCP pill. - Overlay tests restore the config store after themselves, wait out the overlay's 25ms repaint delay explicitly, and fail the mocked get_config so the store keeps its defaults instead of a status-shaped object. Co-Authored-By: Claude Opus 5.5 --- src/lib/Overlay/Overlay.svelte | 158 ++++++++++++++++++++++----------- tests/svelte/Overlay.test.ts | 76 +++++++++++++++- 2 files changed, 177 insertions(+), 57 deletions(-) diff --git a/src/lib/Overlay/Overlay.svelte b/src/lib/Overlay/Overlay.svelte index 1dbaeb7e..c50022a7 100644 --- a/src/lib/Overlay/Overlay.svelte +++ b/src/lib/Overlay/Overlay.svelte @@ -46,7 +46,27 @@ ($mcpRecording && $config.mcp.visual_feedback) || (commandOverlayActive && $config.ui.show_command_overlay) ); + + let isSystemResponding = $derived($speaking && $config.tts.enabled && $config.tts.response_overlay); let renderOverlay = $state(false); + + // The target visualizer belongs to recording. It gives way to the SYSTEM + // RESPONDING pill so the two never overlap, and is dropped once recording + // ends if the overlay stays up only for a command/MCP pill. When the whole + // overlay is fading out it stays mounted, so its outro transition can play. + let primaryVisualizerOn = $state(false); + $effect(() => { + if (!renderOverlay || isSystemResponding) { + primaryVisualizerOn = false; + } else if ($recording || $status.processing) { + primaryVisualizerOn = true; + } else if (isRecordingOrSpeaking) { + primaryVisualizerOn = false; + } + }); + let showPrimaryVisualizer = $derived( + $config.ui.overlay_style !== "none" && $config.ui.show_overlay && primaryVisualizerOn + ); let animateActive = $state(false); let timeoutId: any; let animateTimeoutId: any; @@ -311,58 +331,68 @@
{#if renderOverlay && visible} - {#if $config.ui.overlay_style === "waveform"} - - {:else if $config.ui.overlay_style === "pulse"} - - {:else if $config.ui.overlay_style === "blue_wave"} - - {:else if $config.ui.overlay_style === "mono_bars"} - - {:else if $config.ui.overlay_style === "spectrum"} - - {:else if $config.ui.overlay_style === "terminal"} - - {:else if $config.ui.overlay_style === "vinyl"} - - {:else if activeCustomOverlay} - {@html ``} -
- {@html processedHtml} -
- {:else if $config.ui.overlay_style !== "none"} - - {/if} - - {#if $speaking} -
- - {#each [0, 1, 2, 3, 4] as i} - - {/each} - - - SYSTEM RESPONDING - ▸ {targetLabel} - -
- {:else if commandOverlayActive} -
- ⚡ - - {commandOverlayName.toUpperCase()} - ▸ {commandOverlayText} - -
- {:else if $mcpRecording} -
- - - RECORDING - ▸ {targetLabel} - -
- {/if} +
+ {#if showPrimaryVisualizer} +
+ {#if $config.ui.overlay_style === "waveform"} + + {:else if $config.ui.overlay_style === "pulse"} + + {:else if $config.ui.overlay_style === "blue_wave"} + + {:else if $config.ui.overlay_style === "mono_bars"} + + {:else if $config.ui.overlay_style === "spectrum"} + + {:else if $config.ui.overlay_style === "terminal"} + + {:else if $config.ui.overlay_style === "vinyl"} + + {:else if activeCustomOverlay} + {@html ``} +
+ {@html processedHtml} +
+ {:else if $config.ui.overlay_style !== "none"} + + {/if} +
+ {/if} + + {#if commandOverlayActive && $config.ui.show_command_overlay && !isSystemResponding} +
+ ⚡ + + {commandOverlayName.toUpperCase()} + ▸ {commandOverlayText} + +
+ {/if} + + {#if isSystemResponding} +
+ + {#each [0, 1, 2, 3, 4] as i} + + {/each} + + + SYSTEM RESPONDING + ▸ {targetLabel} + +
+ {/if} + + {#if $mcpRecording && $config.mcp.visual_feedback} +
+ + + RECORDING + ▸ {targetLabel} + +
+ {/if} +
{/if}
@@ -395,8 +425,26 @@ overflow: hidden !important; } + .overlay-stack { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + max-width: 100%; + max-height: 100%; + pointer-events: none; + } + + .visualizer-container { + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + } + .system-response-box { - position: absolute; + position: relative; z-index: 10; display: flex; align-items: center; @@ -406,10 +454,12 @@ border-radius: 23px; font-family: 'Outfit', 'Inter', system-ui, sans-serif; opacity: 0; - transform: translateY(20px); + transform: translateY(10px); transition: transform 0.34s cubic-bezier(0.175, 0.885, 0.32, 1.25), opacity 0.28s ease; + pointer-events: none; + flex-shrink: 0; } .system-response-box.on { diff --git a/tests/svelte/Overlay.test.ts b/tests/svelte/Overlay.test.ts index b1a935b8..21cff49f 100644 --- a/tests/svelte/Overlay.test.ts +++ b/tests/svelte/Overlay.test.ts @@ -1,9 +1,14 @@ -import { describe, test, expect, vi, beforeEach } from "vitest"; +import { describe, test, expect, vi, beforeEach, afterEach } from "vitest"; +import { get } from "svelte/store"; import { render } from "@testing-library/svelte"; // Mock tauri IPC used by the overlay components and the status store vi.mock("@tauri-apps/api/core", () => ({ - invoke: vi.fn(async () => ({ + // `get_config` has no backend here: failing it leaves the config store on + // its defaults instead of overwriting it with this status-shaped reply. + invoke: vi.fn(async (cmd: string) => { + if (cmd === "get_config") throw new Error("no backend in tests"); + return { recording: false, processing: false, speaking: false, @@ -12,7 +17,8 @@ vi.mock("@tauri-apps/api/core", () => ({ word_count: 0, active_target_id: "default", active_target_label: "Focused Window", - })), + }; + }), })); vi.mock("@tauri-apps/api/event", () => ({ @@ -29,6 +35,8 @@ vi.stubGlobal( vi.stubGlobal("cancelAnimationFrame", (id: number) => clearTimeout(id)); import { status, type AppStatus } from "../../src/stores/status"; +import { config } from "../../src/stores/config"; +import Overlay from "../../src/lib/Overlay/Overlay.svelte"; import Waveform from "../../src/lib/Overlay/Waveform.svelte"; import Pulse from "../../src/lib/Overlay/Pulse.svelte"; import BlueWave from "../../src/lib/Overlay/BlueWave.svelte"; @@ -189,3 +197,65 @@ describe("VoiceCard.svelte (membership card)", () => { expect(procContainer.querySelector(".field-value")?.textContent).toContain("Reading the card…"); }); }); + +describe("Overlay.svelte (root layout)", () => { + // These tests reconfigure the shared config store; put it back afterwards + // so later suites see the defaults. + let savedConfig: ReturnType>; + beforeEach(() => { + savedConfig = get(config); + config.update((c) => ({ + ...c, + ui: { ...c.ui, show_overlay: true, overlay_style: "waveform" }, + tts: { ...c.tts, enabled: true, response_overlay: true }, + })); + }); + afterEach(() => { + config.set(savedConfig); + }); + + // The overlay mounts its content after a 25ms repaint delay (see the + // overlay_style effect), so wait past that rather than a single tick. + const settle = (ms = 50) => new Promise((r) => setTimeout(r, ms)); + + test("hides target visualizer and displays SYSTEM RESPONDING when speaking", async () => { + setStatus({ speaking: true, recording: false, active_target_label: "Kitty Terminal" }); + + const { container } = render(Overlay); + await settle(); + + expect(container.textContent).toContain("SYSTEM RESPONDING"); + expect(container.textContent).toContain("Kitty Terminal"); + // The target visualizer (Waveform) must not be rendered alongside it + expect(container.querySelector(".scope")).toBeNull(); + expect(container.textContent).not.toContain("WAVEFORM // OSC-01"); + }); + + test("shows target visualizer when recording", async () => { + setStatus({ speaking: false, recording: true, active_target_label: "Code Editor" }); + + const { container } = render(Overlay); + await settle(); + + expect(container.querySelector(".scope")).not.toBeNull(); + expect(container.textContent).toContain("WAVEFORM // OSC-01"); + expect(container.textContent).not.toContain("SYSTEM RESPONDING"); + }); + + test("keeps the visualizer mounted while the overlay fades out after recording", async () => { + setStatus({ recording: true }); + const { container } = render(Overlay); + await settle(); + expect(container.querySelector(".scope")).not.toBeNull(); + + setStatus({ recording: false }); + await settle(); + // Still inside the 450ms outro window: the visualizer animates out rather + // than vanishing the instant recording stops... + expect(container.querySelector(".scope")).not.toBeNull(); + + // ...and is gone once the outro has finished. + await settle(500); + expect(container.querySelector(".scope")).toBeNull(); + }); +}); From 4e47af37bd85105263ea2dae23796160663375df Mon Sep 17 00:00:00 2001 From: Josh Rufer Date: Wed, 23 Sep 2026 20:04:01 -0500 Subject: [PATCH 2/3] feat(overlay): recording takes priority over SYSTEM RESPONDING; keep pills in view - While recording, show the target visualizer and hide the SYSTEM RESPONDING pill. Starting to dictate interrupts playback, but the speaking flag can lag behind it, which briefly hid all recording feedback. - Let the visualizer box shrink (trimmed, not scaled) so a tall custom overlay can't push the command/MCP pills out of the 444px window. Built-in visualizers (at most ~152px) plus two pills fit with room left. Co-Authored-By: Claude Opus 5.5 --- src/lib/Overlay/Overlay.svelte | 14 +++++++++++++- tests/svelte/Overlay.test.ts | 11 +++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/lib/Overlay/Overlay.svelte b/src/lib/Overlay/Overlay.svelte index c50022a7..ae8e5026 100644 --- a/src/lib/Overlay/Overlay.svelte +++ b/src/lib/Overlay/Overlay.svelte @@ -47,7 +47,12 @@ (commandOverlayActive && $config.ui.show_command_overlay) ); - let isSystemResponding = $derived($speaking && $config.tts.enabled && $config.tts.response_overlay); + // Recording wins: starting to dictate interrupts playback (begin_recording + // stops TTS), and until the speaking flag catches up the user must still + // see that they are being recorded rather than a stale SYSTEM RESPONDING. + let isSystemResponding = $derived( + $speaking && $config.tts.enabled && $config.tts.response_overlay && !$recording + ); let renderOverlay = $state(false); // The target visualizer belongs to recording. It gives way to the SYSTEM @@ -441,6 +446,13 @@ align-items: center; justify-content: center; pointer-events: none; + /* Built-in visualizers are at most ~152px, leaving room in the 444px + window for two pills below. A custom overlay may be taller: let this + box give way (trimmed, not scaled) so the pills are never pushed out + of the window. */ + min-height: 0; + flex-shrink: 1; + overflow: hidden; } .system-response-box { diff --git a/tests/svelte/Overlay.test.ts b/tests/svelte/Overlay.test.ts index 21cff49f..9f284a64 100644 --- a/tests/svelte/Overlay.test.ts +++ b/tests/svelte/Overlay.test.ts @@ -242,6 +242,17 @@ describe("Overlay.svelte (root layout)", () => { expect(container.textContent).not.toContain("SYSTEM RESPONDING"); }); + test("recording over a spoken reply shows the visualizer, not SYSTEM RESPONDING", async () => { + // begin_recording stops playback, but the speaking flag can lag behind it. + setStatus({ speaking: true, recording: true }); + + const { container } = render(Overlay); + await settle(); + + expect(container.querySelector(".scope")).not.toBeNull(); + expect(container.textContent).not.toContain("SYSTEM RESPONDING"); + }); + test("keeps the visualizer mounted while the overlay fades out after recording", async () => { setStatus({ recording: true }); const { container } = render(Overlay); From 0b03fda97484c19c2b51939055f2b71b947831b1 Mon Sep 17 00:00:00 2001 From: Josh Rufer Date: Wed, 23 Sep 2026 20:19:30 -0500 Subject: [PATCH 3/3] test(overlay): answer the status poll with the test's own status The status store polls get_status every second when no status-tick events arrive, which in tests is always. The mock replied with a fixed idle status, so whenever a run was slow enough for a poll to land inside a test's settle window (as on CI), speaking/recording were reset under it and 'hides target visualizer and displays SYSTEM RESPONDING' failed. The mock now returns the status store's current value, so a poll is a no-op. Co-Authored-By: Claude Opus 5.5 --- tests/svelte/Overlay.test.ts | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/tests/svelte/Overlay.test.ts b/tests/svelte/Overlay.test.ts index 9f284a64..230cfcc3 100644 --- a/tests/svelte/Overlay.test.ts +++ b/tests/svelte/Overlay.test.ts @@ -4,20 +4,18 @@ import { render } from "@testing-library/svelte"; // Mock tauri IPC used by the overlay components and the status store vi.mock("@tauri-apps/api/core", () => ({ - // `get_config` has no backend here: failing it leaves the config store on - // its defaults instead of overwriting it with this status-shaped reply. invoke: vi.fn(async (cmd: string) => { + // `get_config` has no backend here: failing it leaves the config store on + // its defaults. if (cmd === "get_config") throw new Error("no backend in tests"); - return { - recording: false, - processing: false, - speaking: false, - mcp_recording: false, - audio_ready: true, - word_count: 0, - active_target_id: "default", - active_target_label: "Focused Window", - }; + // The status store falls back to polling `get_status` every second when + // no status-tick events arrive, which in tests is always. Answer with + // whatever status the test has set, so a poll landing mid-test changes + // nothing — a fixed idle reply here reset `speaking`/`recording` under + // the tests whenever a run was slow enough for the poll to fire. + const { status } = await import("../../src/stores/status"); + const { get } = await import("svelte/store"); + return get(status); }), }));