diff --git a/src/lib/Overlay/Overlay.svelte b/src/lib/Overlay/Overlay.svelte index 1dbaeb7..ae8e502 100644 --- a/src/lib/Overlay/Overlay.svelte +++ b/src/lib/Overlay/Overlay.svelte @@ -46,7 +46,32 @@ ($mcpRecording && $config.mcp.visual_feedback) || (commandOverlayActive && $config.ui.show_command_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 + // 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 +336,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 +430,33 @@ 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; + /* 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 { - position: absolute; + position: relative; z-index: 10; display: flex; align-items: center; @@ -406,10 +466,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 b1a935b..230cfcc 100644 --- a/tests/svelte/Overlay.test.ts +++ b/tests/svelte/Overlay.test.ts @@ -1,18 +1,22 @@ -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 () => ({ - recording: false, - processing: false, - speaking: false, - mcp_recording: false, - audio_ready: true, - word_count: 0, - active_target_id: "default", - active_target_label: "Focused Window", - })), + 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"); + // 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); + }), })); vi.mock("@tauri-apps/api/event", () => ({ @@ -29,6 +33,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 +195,76 @@ 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("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); + 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(); + }); +});