Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 116 additions & 54 deletions src/lib/Overlay/Overlay.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -311,58 +336,68 @@

<div class="overlay-root" data-recording={$recording} data-speaking={$speaking} data-processing={$status.processing}>
{#if renderOverlay && visible}
{#if $config.ui.overlay_style === "waveform"}
<Waveform recording={$recording} active={animateActive} />
{:else if $config.ui.overlay_style === "pulse"}
<Pulse recording={$recording} active={animateActive} />
{:else if $config.ui.overlay_style === "blue_wave"}
<BlueWave recording={$recording} speaking={$speaking} active={animateActive} />
{:else if $config.ui.overlay_style === "mono_bars"}
<MonoBars recording={$recording} active={animateActive} />
{:else if $config.ui.overlay_style === "spectrum"}
<Spectrum recording={$recording} active={animateActive} />
{:else if $config.ui.overlay_style === "terminal"}
<Terminal recording={$recording} active={animateActive} />
{:else if $config.ui.overlay_style === "vinyl"}
<Vinyl recording={$recording} active={animateActive} />
{:else if activeCustomOverlay}
{@html `<style>${activeCustomOverlay.css}</style>`}
<div class="custom-overlay-content" class:active={animateActive} use:executeScripts>
{@html processedHtml}
</div>
{:else if $config.ui.overlay_style !== "none"}
<VoiceCard recording={$recording} speaking={$speaking} active={animateActive} />
{/if}

{#if $speaking}
<div class="system-response-box speaking" class:on={animateActive}>
<span class="mini-eq">
{#each [0, 1, 2, 3, 4] as i}
<span class="eq-bar" style="animation-delay: {i * 0.13}s"></span>
{/each}
</span>
<span class="pill-text">
<span class="pill-title">SYSTEM RESPONDING</span>
<span class="pill-target">▸ {targetLabel}</span>
</span>
</div>
{:else if commandOverlayActive}
<div class="system-response-box command" class:on={animateActive}>
<span class="cmd-icon">⚡</span>
<span class="pill-text">
<span class="pill-title">{commandOverlayName.toUpperCase()}</span>
<span class="pill-target">▸ {commandOverlayText}</span>
</span>
</div>
{:else if $mcpRecording}
<div class="system-response-box mcp" class:on={animateActive}>
<span class="pulse-dot"></span>
<span class="pill-text">
<span class="pill-title">RECORDING</span>
<span class="pill-target">▸ {targetLabel}</span>
</span>
</div>
{/if}
<div class="overlay-stack">
{#if showPrimaryVisualizer}
<div class="visualizer-container">
{#if $config.ui.overlay_style === "waveform"}
<Waveform recording={$recording} active={animateActive} />
{:else if $config.ui.overlay_style === "pulse"}
<Pulse recording={$recording} active={animateActive} />
{:else if $config.ui.overlay_style === "blue_wave"}
<BlueWave recording={$recording} speaking={$speaking} active={animateActive} />
{:else if $config.ui.overlay_style === "mono_bars"}
<MonoBars recording={$recording} active={animateActive} />
{:else if $config.ui.overlay_style === "spectrum"}
<Spectrum recording={$recording} active={animateActive} />
{:else if $config.ui.overlay_style === "terminal"}
<Terminal recording={$recording} active={animateActive} />
{:else if $config.ui.overlay_style === "vinyl"}
<Vinyl recording={$recording} active={animateActive} />
{:else if activeCustomOverlay}
{@html `<style>${activeCustomOverlay.css}</style>`}
<div class="custom-overlay-content" class:active={animateActive} use:executeScripts>
{@html processedHtml}
</div>
{:else if $config.ui.overlay_style !== "none"}
<VoiceCard recording={$recording} speaking={$speaking} active={animateActive} />
{/if}
</div>
{/if}

{#if commandOverlayActive && $config.ui.show_command_overlay && !isSystemResponding}
<div class="system-response-box command" class:on={animateActive}>
<span class="cmd-icon">⚡</span>
<span class="pill-text">
<span class="pill-title">{commandOverlayName.toUpperCase()}</span>
<span class="pill-target">▸ {commandOverlayText}</span>
</span>
</div>
{/if}

{#if isSystemResponding}
<div class="system-response-box speaking" class:on={animateActive}>
<span class="mini-eq">
{#each [0, 1, 2, 3, 4] as i}
<span class="eq-bar" style="animation-delay: {i * 0.13}s"></span>
{/each}
</span>
<span class="pill-text">
<span class="pill-title">SYSTEM RESPONDING</span>
<span class="pill-target">▸ {targetLabel}</span>
</span>
</div>
{/if}

{#if $mcpRecording && $config.mcp.visual_feedback}
<div class="system-response-box mcp" class:on={animateActive}>
<span class="pulse-dot"></span>
<span class="pill-text">
<span class="pill-title">RECORDING</span>
<span class="pill-target">▸ {targetLabel}</span>
</span>
</div>
{/if}
</div>
{/if}
</div>

Expand Down Expand Up @@ -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;
Expand All @@ -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 {
Expand Down
101 changes: 90 additions & 11 deletions tests/svelte/Overlay.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => ({
Expand All @@ -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";
Expand Down Expand Up @@ -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<typeof get<typeof config>>;
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();
});
});
Loading