diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 584788c06..c726c859b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -7,9 +7,8 @@ on:
- main
jobs:
- frontend-quality:
+ quality:
runs-on: ubuntu-latest
-
steps:
- uses: actions/checkout@v4
@@ -22,5 +21,115 @@ jobs:
- name: Typecheck app + web
run: bun run typecheck
- - name: Build web smoke test
+ - name: Build web
run: bun run build:web
+
+ - name: Upload web build
+ uses: actions/upload-artifact@v4
+ with:
+ name: web-dist
+ path: web/dist
+ retention-days: 1
+
+ unit:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+
+ - name: Install dependencies
+ run: bun install --frozen-lockfile
+
+ - name: Cache Playwright browsers
+ uses: actions/cache@v4
+ with:
+ path: ~/.cache/ms-playwright
+ key: playwright-${{ runner.os }}-${{ hashFiles('bun.lock') }}
+
+ - name: Install Chromium
+ run: bunx playwright install chromium --with-deps
+
+ - name: Vitest (unit + browser)
+ run: bunx vitest run
+
+ e2e:
+ # Informational while the suite beds in; flip to blocking once it has
+ # a sustained green run.
+ continue-on-error: true
+ needs: quality
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+
+ - name: Install dependencies
+ run: bun install --frozen-lockfile
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+ cache: pip
+ cache-dependency-path: backend/requirements-ci.txt
+
+ - name: Install backend (CPU)
+ run: |
+ pip install torch --index-url https://download.pytorch.org/whl/cpu
+ pip install -r backend/requirements-ci.txt
+
+ - name: Cache Playwright browsers
+ uses: actions/cache@v4
+ with:
+ path: ~/.cache/ms-playwright
+ key: playwright-${{ runner.os }}-${{ hashFiles('bun.lock') }}
+
+ - name: Install Chromium
+ run: bunx playwright install chromium --with-deps
+
+ - name: Playwright E2E
+ run: bunx playwright test -c e2e
+ env:
+ VOICEBOX_PYTHON: python
+
+ - name: Upload Playwright report
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: playwright-report
+ path: |
+ playwright-report
+ test-results
+ retention-days: 7
+
+ backend-tests:
+ # Informational: 30 pre-existing pytest files that have never run in CI.
+ continue-on-error: true
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+ cache: pip
+ cache-dependency-path: backend/requirements-ci.txt
+
+ - name: Install backend (CPU)
+ run: |
+ pip install torch --index-url https://download.pytorch.org/whl/cpu
+ pip install -r backend/requirements-ci.txt
+ pip install pytest pytest-asyncio
+
+ - name: Pytest
+ run: python -m pytest backend/tests -v --ignore=backend/tests/test_all_models_e2e.py
diff --git a/.gitignore b/.gitignore
index 853c50609..3b6c46e14 100644
Binary files a/.gitignore and b/.gitignore differ
diff --git a/.node-version b/.node-version
new file mode 100644
index 000000000..2bd5a0a98
--- /dev/null
+++ b/.node-version
@@ -0,0 +1 @@
+22
diff --git a/.vitest-attachments/3cb88173a3c966ff32fd6131b2a7f621a51b4b8d.png b/.vitest-attachments/3cb88173a3c966ff32fd6131b2a7f621a51b4b8d.png
new file mode 100644
index 000000000..4087dd2e4
Binary files /dev/null and b/.vitest-attachments/3cb88173a3c966ff32fd6131b2a7f621a51b4b8d.png differ
diff --git a/app/index.html b/app/index.html
deleted file mode 100644
index 2a155139a..000000000
--- a/app/index.html
+++ /dev/null
@@ -1,29 +0,0 @@
-
-
-
-
-
-
- voicebox
-
-
-
-
-
-
-
diff --git a/app/package.json b/app/package.json
index 56bf162a3..621155dff 100644
--- a/app/package.json
+++ b/app/package.json
@@ -4,10 +4,7 @@
"private": true,
"type": "module",
"scripts": {
- "dev": "vite",
- "build": "vite build",
"typecheck": "tsc -p tsconfig.json --noEmit",
- "preview": "vite preview",
"lint": "biome lint src",
"lint:fix": "biome lint --write src",
"format": "biome format --write src",
diff --git a/app/src/App.browser.test.tsx b/app/src/App.browser.test.tsx
new file mode 100644
index 000000000..91c0b5044
--- /dev/null
+++ b/app/src/App.browser.test.tsx
@@ -0,0 +1,134 @@
+import { mockIPC } from '@tauri-apps/api/mocks';
+import { afterEach, beforeEach, expect, it } from 'vitest';
+import App from '@/App';
+import { createMockPlatform } from '@/test/mockPlatform';
+import { buildModelStatus, buildProfile } from '@/test/msw/fixtures';
+import {
+ captureHandlers,
+ effectsHandlers,
+ historyHandlers,
+ modelHandlers,
+ profileHandlers,
+ settingsHandlers,
+ storyHandlers,
+ taskHandlers,
+} from '@/test/msw/handlers';
+import { worker } from '@/test/msw/worker';
+import { renderWithProviders } from '@/test/render';
+
+const originalUrl = window.location.href;
+
+// useChordSync and the permission gates call the Tauri IPC modules directly,
+// outside the Platform abstraction. There is no Tauri runtime in the test
+// browser, so `invoke`/`listen` would reject with a TypeError that some
+// callers (e.g. useChordSync's `listen('dictate:warm-request')`) never get a
+// chance to handle, surfacing as unhandled rejections. mockIPC installs the
+// official in-memory IPC shim; `shouldMockEvents` covers listen/emit too.
+//
+// Reinstalled per test for a fresh listener map, but never cleared: the
+// harness unmounts components after this file's afterEach, and those unmount
+// cleanups still `unlisten` through the shim. The per-file iframe throws the
+// window state away anyway.
+beforeEach(() => {
+ mockIPC(
+ (cmd) => {
+ // Permission checks treat the result as a trusted boolean — grant
+ // them so no permission banners pop over the UI under test.
+ if (cmd.startsWith('check_')) return true;
+ return null;
+ },
+ { shouldMockEvents: true },
+ );
+});
+
+afterEach(() => {
+ window.history.replaceState(null, '', originalUrl);
+ delete window.__voiceboxServerStartedByApp;
+});
+
+/**
+ * Everything the index route (MainEditor + app chrome) fetches on mount.
+ * Unstubbed requests fail the test loudly, so this is the full route budget.
+ */
+function useHappyPathHandlers() {
+ worker.use(
+ ...profileHandlers([buildProfile({ name: 'Ada Lovelace' })]),
+ ...historyHandlers([]),
+ ...captureHandlers([]),
+ ...settingsHandlers(),
+ ...modelHandlers([buildModelStatus()]),
+ ...storyHandlers([]),
+ ...effectsHandlers([]),
+ ...taskHandlers(),
+ );
+}
+
+// App reads window.location at render time: `?view=dictate` picks the pill
+// window, and the router matches the real browser path. Point the URL at the
+// state under test before mounting; afterEach restores the runner's URL.
+function setAppUrl(path: string) {
+ window.history.replaceState(null, '', path);
+}
+
+it('skips the startup gate outside Tauri and renders the router', async () => {
+ useHappyPathHandlers();
+ setAppUrl('/');
+
+ const screen = await renderWithProviders();
+
+ // Index route is MainEditor — the profile list proves the router mounted.
+ await expect.element(screen.getByText('Ada Lovelace')).toBeVisible();
+
+ // Web mode assumes an external server: no lifecycle management at all.
+ expect(screen.platform.lifecycle.startServer).not.toHaveBeenCalled();
+ expect(screen.platform.lifecycle.setupWindowCloseHandler).not.toHaveBeenCalled();
+});
+
+it('skips server auto-start in Tauri dev mode and still reaches the router', async () => {
+ // App gates auto-start on `import.meta.env.PROD`, which is false under
+ // vitest. The reachable Tauri branch is therefore the dev one: window
+ // close handler installed, auto-start skipped, serverReady forced true.
+ //
+ // The PROD-only branches — `lifecycle.startServer`, the health-check
+ // polling fallback, and the startup-error screen with its Retry button —
+ // are unreachable here without mocking import.meta.env, so they are
+ // intentionally not covered.
+ useHappyPathHandlers();
+ setAppUrl('/');
+ const platform = createMockPlatform({ metadata: { isTauri: true } });
+
+ const screen = await renderWithProviders(, { platform });
+
+ await expect.element(screen.getByText('Ada Lovelace')).toBeVisible();
+
+ expect(platform.lifecycle.startServer).not.toHaveBeenCalled();
+ expect(platform.lifecycle.setupWindowCloseHandler).toHaveBeenCalled();
+ // Startup syncs the keep-server-running setting into Rust.
+ expect(platform.lifecycle.setKeepServerRunning).toHaveBeenCalledWith(expect.any(Boolean));
+ // Auto-updater runs its mount check in Tauri.
+ expect(platform.updater.checkForUpdates).toHaveBeenCalled();
+ // Dev mode records that the app does not own the server process.
+ expect(window.__voiceboxServerStartedByApp).toBe(false);
+});
+
+it('renders the dictate pill window for ?view=dictate without booting the main app', async () => {
+ // No route handlers on purpose: the dictate view must not touch any of the
+ // main app's endpoints, and an unhandled request would fail the test.
+ setAppUrl('/?view=dictate');
+
+ const screen = await renderWithProviders();
+
+ // DictateWindow forces the document transparent so the Tauri window takes
+ // the pill's shape — the observable signal that it mounted without
+ // throwing under the non-Tauri mock platform.
+ await expect.poll(() => document.body.style.background).toBe('transparent');
+
+ // The pill starts hidden: the wrapper renders but contains no CapturePill.
+ const wrapper = screen.container.firstElementChild as HTMLElement;
+ expect(wrapper.className).toContain('h-screen');
+ expect(wrapper.childElementCount).toBe(0);
+
+ // The startup gate never ran — no server lifecycle calls from this window.
+ expect(screen.platform.lifecycle.startServer).not.toHaveBeenCalled();
+ expect(screen.platform.lifecycle.setupWindowCloseHandler).not.toHaveBeenCalled();
+});
diff --git a/app/src/components/AudioTab/AudioTab.tsx b/app/src/components/AudioTab/AudioTab.tsx
deleted file mode 100644
index 5f7549fbd..000000000
--- a/app/src/components/AudioTab/AudioTab.tsx
+++ /dev/null
@@ -1,675 +0,0 @@
-import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
-import { Check, CheckCircle2, Edit, Plus, Speaker, Trash2 } from 'lucide-react';
-import { useState } from 'react';
-import { useTranslation } from 'react-i18next';
-import { Badge } from '@/components/ui/badge';
-import { Button } from '@/components/ui/button';
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from '@/components/ui/dialog';
-import { Input } from '@/components/ui/input';
-import { Label } from '@/components/ui/label';
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from '@/components/ui/select';
-import { apiClient } from '@/lib/api/client';
-import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
-import { cn } from '@/lib/utils/cn';
-import { usePlatform } from '@/platform/PlatformContext';
-import { usePlayerStore } from '@/stores/playerStore';
-
-interface AudioDevice {
- id: string;
- name: string;
- is_default: boolean;
-}
-
-export function AudioTab() {
- const { t } = useTranslation();
- const platform = usePlatform();
- const [createDialogOpen, setCreateDialogOpen] = useState(false);
- const [editingChannel, setEditingChannel] = useState(null);
- const [selectedChannelId, setSelectedChannelId] = useState(null);
- const queryClient = useQueryClient();
- const audioUrl = usePlayerStore((state) => state.audioUrl);
- const isPlayerVisible = !!audioUrl;
-
- const { data: channels, isLoading: channelsLoading } = useQuery({
- queryKey: ['channels'],
- queryFn: () => apiClient.listChannels(),
- });
-
- const { data: devices, isLoading: devicesLoading } = useQuery({
- queryKey: ['audio-devices'],
- queryFn: async () => {
- if (!platform.metadata.isTauri) {
- return [];
- }
- try {
- return await platform.audio.listOutputDevices();
- } catch (error) {
- console.error('Failed to list audio devices:', error);
- return [];
- }
- },
- enabled: platform.metadata.isTauri,
- });
-
- const { data: profiles } = useQuery({
- queryKey: ['profiles'],
- queryFn: () => apiClient.listProfiles(),
- });
-
- const createChannel = useMutation({
- mutationFn: (data: { name: string; device_ids: string[] }) => apiClient.createChannel(data),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['channels'] });
- setCreateDialogOpen(false);
- },
- });
-
- const updateChannel = useMutation({
- mutationFn: ({
- channelId,
- data,
- }: {
- channelId: string;
- data: { name?: string; device_ids?: string[] };
- }) => apiClient.updateChannel(channelId, data),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['channels'] });
- queryClient.invalidateQueries({ queryKey: ['profile-channels'] });
- setEditingChannel(null);
- },
- });
-
- const deleteChannel = useMutation({
- mutationFn: (channelId: string) => apiClient.deleteChannel(channelId),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['channels'] });
- queryClient.invalidateQueries({ queryKey: ['profile-channels'] });
- },
- });
-
- const { data: channelVoices } = useQuery({
- queryKey: ['channel-voices', editingChannel],
- queryFn: async () => {
- if (!editingChannel) return { profile_ids: [] };
- return apiClient.getChannelVoices(editingChannel);
- },
- enabled: !!editingChannel,
- });
-
- const setChannelVoices = useMutation({
- mutationFn: ({ channelId, profileIds }: { channelId: string; profileIds: string[] }) =>
- apiClient.setChannelVoices(channelId, profileIds),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['channel-voices'] });
- queryClient.invalidateQueries({ queryKey: ['profile-channels'] });
- },
- });
-
- if (channelsLoading || devicesLoading) {
- return (
-
-
{t('audioChannels.loading')}
-
- );
- }
-
- const handleChannelDelete = async (e: React.MouseEvent, channelId: string) => {
- e.stopPropagation();
- if (await confirm(t('audioChannels.confirmDelete'))) {
- deleteChannel.mutate(channelId);
- }
- };
-
- const allChannels = channels || [];
- const allDevices = devices || [];
- const selectedChannel = selectedChannelId
- ? allChannels.find((c) => c.id === selectedChannelId)
- : null;
-
- return (
-
-
-
{t('audioChannels.title')}
-
-
-
-
- {/* Left Column - Channels */}
-
- {allChannels.length === 0 ? (
-
-
-
{t('audioChannels.empty.message')}
-
-
- ) : (
-
- {allChannels.map((channel) => {
- const isSelected = selectedChannelId === channel.id;
- return (
-
- );
- })}
-
- )}
-
-
- {/* Right Column - Available Devices */}
-
-
-
{t('audioChannels.devices.title')}
-
- {selectedChannelId
- ? selectedChannel?.is_default
- ? t('audioChannels.devices.defaultNote')
- : t('audioChannels.devices.toggleHint')
- : t('audioChannels.devices.selectHint')}
-
-
- {allDevices.length > 0 ? (
-
- {allDevices.map((device) => {
- const isConnected =
- selectedChannelId &&
- selectedChannel &&
- (selectedChannel.device_ids.length === 0
- ? device.is_default
- : selectedChannel.device_ids.includes(device.id));
- const canToggle =
- selectedChannelId && selectedChannel && !selectedChannel.is_default;
-
- const handleDeviceClick = () => {
- if (!canToggle || !selectedChannel) return;
-
- const currentDeviceIds = selectedChannel.device_ids;
- const newDeviceIds = isConnected
- ? currentDeviceIds.filter((id) => id !== device.id)
- : [...currentDeviceIds, device.id];
-
- updateChannel.mutate({
- channelId: selectedChannelId,
- data: { device_ids: newDeviceIds },
- });
- };
-
- return (
-
- {canToggle ? (
-
- {isConnected && }
-
- ) : device.is_default ? (
-
- ) : null}
-
- {device.name}
-
-
- );
- })}
-
- ) : (
-
-
-
- {platform.metadata.isTauri
- ? t('audioChannels.devices.empty')
- : t('audioChannels.devices.requiresTauri')}
-
-
- )}
-
-
-
- {/* Create Channel Dialog */}
-
{
- createChannel.mutate({ name, device_ids: deviceIds });
- }}
- />
-
- {/* Edit Channel Dialog */}
- {editingChannel &&
- (() => {
- const channel = channels?.find((c) => c.id === editingChannel);
- return channel ? (
- !open && setEditingChannel(null)}
- channel={channel}
- devices={devices || []}
- profiles={profiles || []}
- channelVoices={channelVoices?.profile_ids || []}
- onUpdate={(name, deviceIds) => {
- updateChannel.mutate({
- channelId: editingChannel,
- data: { name, device_ids: deviceIds },
- });
- }}
- onSetVoices={(profileIds) => {
- setChannelVoices.mutate({
- channelId: editingChannel,
- profileIds,
- });
- }}
- />
- ) : null;
- })()}
-
- );
-}
-
-function ChannelVoicesList({ channelId }: { channelId: string }) {
- const { t } = useTranslation();
- const { data: voices } = useQuery({
- queryKey: ['channel-voices', channelId],
- queryFn: () => apiClient.getChannelVoices(channelId),
- });
-
- const { data: profiles } = useQuery({
- queryKey: ['profiles'],
- queryFn: () => apiClient.listProfiles(),
- });
-
- const voiceNames =
- voices?.profile_ids.map((id) => profiles?.find((p) => p.id === id)?.name).filter(Boolean) || [];
-
- return (
-
- {voiceNames.length > 0 ? (
- voiceNames.map((name) => (
-
- {name}
-
- ))
- ) : (
- {t('audioChannels.noVoicesAssigned')}
- )}
-
- );
-}
-
-interface CreateChannelDialogProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- devices: AudioDevice[];
- onCreate: (name: string, deviceIds: string[]) => void;
-}
-
-function CreateChannelDialog({ open, onOpenChange, devices, onCreate }: CreateChannelDialogProps) {
- const { t } = useTranslation();
- const [name, setName] = useState('');
- const [selectedDevices, setSelectedDevices] = useState([]);
-
- const handleSubmit = () => {
- if (name.trim()) {
- onCreate(name.trim(), selectedDevices);
- setName('');
- setSelectedDevices([]);
- }
- };
-
- return (
-
- );
-}
-
-interface EditChannelDialogProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- channel: {
- id: string;
- name: string;
- device_ids: string[];
- };
- devices: AudioDevice[];
- profiles: Array<{ id: string; name: string }>;
- channelVoices: string[];
- onUpdate: (name: string, deviceIds: string[]) => void;
- onSetVoices: (profileIds: string[]) => void;
-}
-
-function EditChannelDialog({
- open,
- onOpenChange,
- channel,
- devices,
- profiles,
- channelVoices,
- onUpdate,
- onSetVoices,
-}: EditChannelDialogProps) {
- const { t } = useTranslation();
- const [name, setName] = useState(channel.name);
- const [selectedDevices, setSelectedDevices] = useState(channel.device_ids);
- const [selectedVoices, setSelectedVoices] = useState(channelVoices);
-
- const handleSubmit = () => {
- if (name.trim()) {
- onUpdate(name.trim(), selectedDevices);
- onSetVoices(selectedVoices);
- }
- };
-
- return (
-
- );
-}
diff --git a/app/src/components/CapturesTab/CapturesTab.tsx b/app/src/components/CapturesTab/CapturesTab.tsx
index 7492d320d..bb54c2fc5 100644
--- a/app/src/components/CapturesTab/CapturesTab.tsx
+++ b/app/src/components/CapturesTab/CapturesTab.tsx
@@ -1,8 +1,6 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
-import { save } from '@tauri-apps/plugin-dialog';
-import { writeFile, writeTextFile } from '@tauri-apps/plugin-fs';
import {
Captions,
Check,
@@ -27,6 +25,14 @@ import { AudioBars } from '@/components/AudioBars';
import { CapturePill } from '@/components/CapturePill/CapturePill';
import { CaptureInlinePlayer } from '@/components/CapturesTab/CaptureInlinePlayer';
import { DictationReadinessChecklist } from '@/components/CapturesTab/DictationReadinessChecklist';
+import {
+ ListPane,
+ ListPaneHeader,
+ ListPaneScroll,
+ ListPaneSearch,
+ ListPaneTitle,
+ ListPaneTitleRow,
+} from '@/components/ListPane';
import {
AlertDialog,
AlertDialogAction,
@@ -48,14 +54,6 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Textarea } from '@/components/ui/textarea';
-import {
- ListPane,
- ListPaneHeader,
- ListPaneScroll,
- ListPaneSearch,
- ListPaneTitle,
- ListPaneTitleRow,
-} from '@/components/ListPane';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
import type {
@@ -72,6 +70,7 @@ import { useCaptureSettings } from '@/lib/hooks/useSettings';
import { cn } from '@/lib/utils/cn';
import { formatAbsoluteDate, formatDate } from '@/lib/utils/format';
import { displayLabelForKey, modifierSideHint } from '@/lib/utils/keyCodes';
+import { usePlatform } from '@/platform/PlatformContext';
import { useGenerationStore } from '@/stores/generationStore';
import { usePlayerStore } from '@/stores/playerStore';
@@ -135,6 +134,7 @@ export function CapturesTab() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { toast } = useToast();
+ const platform = usePlatform();
const fileInputRef = useRef(null);
const uploadInputRef = useRef(null);
@@ -202,6 +202,7 @@ export function CapturesTab() {
// the race window between ``setSelectedId(new)`` and the refetched list
// actually containing the new row.
useEffect(() => {
+ if (!platform.metadata.isTauri) return;
const unlistens: Promise[] = [];
unlistens.push(
listen<{ capture: CaptureResponse }>('capture:created', (event) => {
@@ -225,7 +226,7 @@ export function CapturesTab() {
return () => {
for (const p of unlistens) p.then((fn) => fn()).catch(() => {});
};
- }, [queryClient]);
+ }, [queryClient, platform.metadata.isTauri]);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
@@ -243,9 +244,7 @@ export function CapturesTab() {
// referenced profile was deleted) fall through to the first profile.
const storedVoiceId = captureSettings?.default_playback_voice_id ?? null;
const playAsVoice =
- (storedVoiceId && profiles?.find((p) => p.id === storedVoiceId)) ||
- profiles?.[0] ||
- null;
+ (storedVoiceId && profiles?.find((p) => p.id === storedVoiceId)) || profiles?.[0] || null;
const playAsVoiceId = playAsVoice?.id ?? null;
const deleteMutation = useMutation({
@@ -255,12 +254,22 @@ export function CapturesTab() {
queryClient.invalidateQueries({ queryKey: ['captures'] });
},
onError: (err: Error) => {
- toast({ title: t('captures.toast.deleteFailed'), description: err.message, variant: 'destructive' });
+ toast({
+ title: t('captures.toast.deleteFailed'),
+ description: err.message,
+ variant: 'destructive',
+ });
},
});
const playAsMutation = useMutation({
- mutationFn: async ({ capture, voice }: { capture: CaptureResponse; voice: VoiceProfileResponse }) => {
+ mutationFn: async ({
+ capture,
+ voice,
+ }: {
+ capture: CaptureResponse;
+ voice: VoiceProfileResponse;
+ }) => {
const text = capture.transcript_refined || capture.transcript_raw;
if (!text.trim()) throw new Error(t('captures.noTranscriptError'));
const language = (capture.language || voice.language) as LanguageCode;
@@ -268,8 +277,13 @@ export function CapturesTab() {
// profile's stored engine preference. Cloned profiles without an
// override fall through to whatever the backend picks.
const engine = voice.default_engine as
- | 'qwen' | 'qwen_custom_voice' | 'luxtts' | 'chatterbox'
- | 'chatterbox_turbo' | 'tada' | 'kokoro'
+ | 'qwen'
+ | 'qwen_custom_voice'
+ | 'luxtts'
+ | 'chatterbox'
+ | 'chatterbox_turbo'
+ | 'tada'
+ | 'kokoro'
| undefined;
return apiClient.generateSpeech({
profile_id: voice.id,
@@ -286,7 +300,11 @@ export function CapturesTab() {
addPendingGeneration(result.id);
},
onError: (err: Error) => {
- toast({ title: t('captures.toast.playAsFailed'), description: err.message, variant: 'destructive' });
+ toast({
+ title: t('captures.toast.playAsFailed'),
+ description: err.message,
+ variant: 'destructive',
+ });
},
});
@@ -336,16 +354,15 @@ export function CapturesTab() {
const handleExportAudio = async () => {
if (!selected) return;
try {
- const dest = await save({
- defaultPath: `capture_${selected.id.slice(0, 8)}.wav`,
- filters: [{ name: 'Audio', extensions: ['wav'] }],
- });
- if (!dest) return;
const res = await fetch(apiClient.getCaptureAudioUrl(selected.id));
if (!res.ok) throw new Error(`HTTP ${res.status}`);
- const buf = new Uint8Array(await res.arrayBuffer());
- await writeFile(dest, buf);
- exportToastSuccess(dest);
+ const blob = new Blob([await res.arrayBuffer()], { type: 'audio/wav' });
+ const dest = await platform.filesystem.saveFile(
+ `capture_${selected.id.slice(0, 8)}.wav`,
+ blob,
+ [{ name: 'Audio', extensions: ['wav'] }],
+ );
+ if (dest) exportToastSuccess(dest);
} catch (err) {
exportToastError(err);
}
@@ -359,13 +376,12 @@ export function CapturesTab() {
return;
}
try {
- const dest = await save({
- defaultPath: `capture_${selected.id.slice(0, 8)}.txt`,
- filters: [{ name: 'Text', extensions: ['txt'] }],
- });
- if (!dest) return;
- await writeTextFile(dest, text);
- exportToastSuccess(dest);
+ const dest = await platform.filesystem.saveFile(
+ `capture_${selected.id.slice(0, 8)}.txt`,
+ new Blob([text], { type: 'text/plain' }),
+ [{ name: 'Text', extensions: ['txt'] }],
+ );
+ if (dest) exportToastSuccess(dest);
} catch (err) {
exportToastError(err);
}
@@ -376,7 +392,8 @@ export function CapturesTab() {
lines.push(`# Capture ${capture.id}`, '');
lines.push(`- **Source:** ${capture.source}`);
lines.push(`- **Created:** ${capture.created_at}`);
- if (capture.duration_ms != null) lines.push(`- **Duration:** ${formatDuration(capture.duration_ms)}`);
+ if (capture.duration_ms != null)
+ lines.push(`- **Duration:** ${formatDuration(capture.duration_ms)}`);
if (capture.language) lines.push(`- **Language:** ${capture.language}`);
if (capture.stt_model) lines.push(`- **STT model:** ${capture.stt_model}`);
if (capture.llm_model) lines.push(`- **LLM model:** ${capture.llm_model}`);
@@ -398,13 +415,12 @@ export function CapturesTab() {
return;
}
try {
- const dest = await save({
- defaultPath: `capture_${selected.id.slice(0, 8)}.md`,
- filters: [{ name: 'Markdown', extensions: ['md'] }],
- });
- if (!dest) return;
- await writeTextFile(dest, buildCaptureMarkdown(selected));
- exportToastSuccess(dest);
+ const dest = await platform.filesystem.saveFile(
+ `capture_${selected.id.slice(0, 8)}.md`,
+ new Blob([buildCaptureMarkdown(selected)], { type: 'text/markdown' }),
+ [{ name: 'Markdown', extensions: ['md'] }],
+ );
+ if (dest) exportToastSuccess(dest);
} catch (err) {
exportToastError(err);
}
@@ -486,48 +502,48 @@ export function CapturesTab() {
) : (
filtered.map((capture) => {
- const isActive = selectedId === capture.id;
- const refined = !!capture.transcript_refined;
- return (
- setSelectedId(capture.id)}
- className={cn(
- 'w-full text-left p-3 rounded-lg transition-colors block',
- isActive
- ? 'bg-muted/70 border border-border'
- : 'border border-transparent hover:bg-muted/30',
- )}
- >
-
-
- {formatDate(capture.created_at)}
-
-
-
- {formatDuration(capture.duration_ms)}
-
-
-
- {snippetOf(capture)}
-
-
-
- {refined && (
-
-
- {t('captures.transcript.refined')}
-
+ const isActive = selectedId === capture.id;
+ const refined = !!capture.transcript_refined;
+ return (
+ setSelectedId(capture.id)}
+ className={cn(
+ 'w-full text-left p-3 rounded-lg transition-colors block',
+ isActive
+ ? 'bg-muted/70 border border-border'
+ : 'border border-transparent hover:bg-muted/30',
)}
-
-
- );
- })
- )}
+ >
+
+
+ {formatDate(capture.created_at)}
+
+
+
+ {formatDuration(capture.duration_ms)}
+
+
+
+ {snippetOf(capture)}
+
+
+
+ {refined && (
+
+
+ {t('captures.transcript.refined')}
+
+ )}
+
+
+ );
+ })
+ )}
@@ -578,7 +594,9 @@ export function CapturesTab() {
) : (
)}
- {session.isUploading ? t('captures.actions.importing') : t('captures.actions.import')}
+ {session.isUploading
+ ? t('captures.actions.importing')
+ : t('captures.actions.import')}
)}
>
@@ -748,11 +766,7 @@ export function CapturesTab() {
{profiles?.map((v) => (
- handlePlayAs(v)}
- className="py-2"
- >
+ handlePlayAs(v)} className="py-2">
{v.name}
@@ -864,9 +878,7 @@ export function CapturesTab() {
) : null}
-
- {t('captures.empty.pressShortcut')}
-
+ {t('captures.empty.pressShortcut')}
) : (
@@ -888,7 +900,9 @@ export function CapturesTab() {
{t('captures.deleteDialog.title')}
- {t('captures.deleteDialog.description')}
+
+ {t('captures.deleteDialog.description')}
+
{t('common.cancel')}
@@ -898,7 +912,9 @@ export function CapturesTab() {
disabled={deleteMutation.isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
- {deleteMutation.isPending ? t('captures.deleteDialog.deleting') : t('common.delete')}
+ {deleteMutation.isPending
+ ? t('captures.deleteDialog.deleting')
+ : t('common.delete')}
diff --git a/app/src/components/ChordPicker/ChordPicker.browser.test.tsx b/app/src/components/ChordPicker/ChordPicker.browser.test.tsx
new file mode 100644
index 000000000..5d05a3379
--- /dev/null
+++ b/app/src/components/ChordPicker/ChordPicker.browser.test.tsx
@@ -0,0 +1,113 @@
+import { expect, it, vi } from 'vitest';
+import { ChordPicker } from '@/components/ChordPicker/ChordPicker';
+import { renderWithProviders } from '@/test/render';
+
+// ChordPicker listens on window in the capture phase and canonicalizes via
+// `event.code`, so raw KeyboardEvents give exact control over which physical
+// keys the picker sees (userEvent would depend on the host keyboard layout).
+function press(code: string) {
+ window.dispatchEvent(new KeyboardEvent('keydown', { code, bubbles: true, cancelable: true }));
+}
+
+function release(code: string) {
+ window.dispatchEvent(new KeyboardEvent('keyup', { code, bubbles: true, cancelable: true }));
+}
+
+async function renderPicker(initialKeys: string[] = []) {
+ const onSave = vi.fn();
+ const onCancel = vi.fn();
+ const screen = await renderWithProviders(
+ ,
+ );
+ return { screen, onSave, onCancel };
+}
+
+it('opens empty with save disabled and flags unsupported keys', async () => {
+ const { screen } = await renderPicker();
+
+ await expect.element(screen.getByText('Press your shortcut')).toBeVisible();
+ await expect.element(screen.getByText('No keys yet')).toBeVisible();
+ await expect.element(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
+
+ // NumpadEnter has no canonical chord name — the picker refuses it and
+ // stays empty instead of capturing garbage.
+ press('NumpadEnter');
+ await expect.element(screen.getByText(/isn't supported in chords/)).toBeVisible();
+ await expect.element(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
+});
+
+it('captures the held keys and saves them after release', async () => {
+ const { screen, onSave } = await renderPicker();
+
+ press('KeyJ');
+ await expect.element(screen.getByText('Capturing…')).toBeVisible();
+ await expect.element(screen.getByText('J', { exact: true })).toBeVisible();
+
+ press('KeyK');
+ await expect.element(screen.getByText('K', { exact: true })).toBeVisible();
+
+ // Releasing everything freezes the peak so the user can save hands-free.
+ release('KeyK');
+ release('KeyJ');
+ await expect.element(screen.getByText('Press your shortcut')).toBeVisible();
+ await expect.element(screen.getByText('J', { exact: true })).toBeVisible();
+ await expect.element(screen.getByText('K', { exact: true })).toBeVisible();
+
+ await screen.getByRole('button', { name: 'Save' }).click();
+ expect(onSave).toHaveBeenCalledExactlyOnceWith(['KeyJ', 'KeyK']);
+});
+
+it('keeps the peak set when a key is released mid-chord', async () => {
+ const { screen, onSave } = await renderPicker();
+
+ press('KeyA');
+ press('KeyB');
+ press('KeyC');
+ await expect.element(screen.getByText('B', { exact: true })).toBeVisible();
+
+ // Mid-chord the display tracks only the currently held keys...
+ release('KeyB');
+ await expect.element(screen.getByText('B', { exact: true })).not.toBeInTheDocument();
+ await expect.element(screen.getByText('A', { exact: true })).toBeVisible();
+
+ // ...but the captured peak still includes the released key.
+ release('KeyA');
+ release('KeyC');
+ await expect.element(screen.getByText('B', { exact: true })).toBeVisible();
+
+ await screen.getByRole('button', { name: 'Save' }).click();
+ expect(onSave).toHaveBeenCalledExactlyOnceWith(['KeyA', 'KeyB', 'KeyC']);
+});
+
+it('replaces a longer saved chord with a fresh shorter one', async () => {
+ const { screen, onSave } = await renderPicker(['KeyA', 'KeyB', 'KeyC']);
+
+ await expect.element(screen.getByText('A', { exact: true })).toBeVisible();
+
+ // The first key of a new sequence resets the peak, so a single key can
+ // beat the three-key seed.
+ press('KeyZ');
+ release('KeyZ');
+ await expect.element(screen.getByText('Z', { exact: true })).toBeVisible();
+ await expect.element(screen.getByText('A', { exact: true })).not.toBeInTheDocument();
+
+ await screen.getByRole('button', { name: 'Save' }).click();
+ expect(onSave).toHaveBeenCalledExactlyOnceWith(['KeyZ']);
+});
+
+it('cancel fires the cancel callback and never saves', async () => {
+ const { screen, onSave, onCancel } = await renderPicker(['KeyA']);
+
+ press('KeyQ');
+ release('KeyQ');
+ await screen.getByRole('button', { name: 'Cancel' }).click();
+
+ expect(onCancel).toHaveBeenCalledOnce();
+ expect(onSave).not.toHaveBeenCalled();
+});
diff --git a/app/src/components/DictateWindow/DictateWindow.tsx b/app/src/components/DictateWindow/DictateWindow.tsx
index 1d917e696..6766b01d2 100644
--- a/app/src/components/DictateWindow/DictateWindow.tsx
+++ b/app/src/components/DictateWindow/DictateWindow.tsx
@@ -5,6 +5,7 @@ import { CapturePill } from '@/components/CapturePill/CapturePill';
import { apiClient } from '@/lib/api/client';
import type { FocusSnapshot } from '@/lib/api/types';
import { useCaptureRecordingSession } from '@/lib/hooks/useCaptureRecordingSession';
+import { usePlatform } from '@/platform/PlatformContext';
/**
* Floating dictate surface shown in a separate transparent Tauri window.
@@ -22,6 +23,9 @@ import { useCaptureRecordingSession } from '@/lib/hooks/useCaptureRecordingSessi
* ``dictate:hide`` so Rust tucks the window away.
*/
export function DictateWindow() {
+ const platform = usePlatform();
+ const isTauri = platform.metadata.isTauri;
+
// Force the host document chrome to be transparent so the Tauri window
// takes on the pill's own shape.
useEffect(() => {
@@ -70,6 +74,7 @@ export function DictateWindow() {
sessionRef.current = session;
useEffect(() => {
+ if (!isTauri) return;
let disposed = false;
const unlistens: UnlistenFn[] = [];
const registrations = [
@@ -98,7 +103,7 @@ export function DictateWindow() {
disposed = true;
for (const unlisten of unlistens) unlisten();
};
- }, []);
+ }, [isTauri]);
useEffect(() => {
if (micWarm) void session.prewarm();
@@ -157,9 +162,7 @@ export function DictateWindow() {
audio.onplaying = () => {
emit('dictate:show').catch(() => {});
setSpeaking((prev) =>
- prev && prev.generationId === generationId
- ? { ...prev, startedAt: Date.now() }
- : prev,
+ prev && prev.generationId === generationId ? { ...prev, startedAt: Date.now() } : prev,
);
setSpeakElapsed(0);
};
@@ -171,6 +174,7 @@ export function DictateWindow() {
};
useEffect(() => {
+ if (!isTauri) return;
const unlistens: Promise[] = [];
// Rust emits the SSE payload as a JSON *string* (not a parsed object);
@@ -265,7 +269,7 @@ export function DictateWindow() {
for (const p of unlistens) p.then((fn) => fn()).catch(() => {});
dismissSpeak();
};
- }, []);
+ }, [isTauri]);
// Advance the pill's elapsed-time label while audio is playing. Paused
// during the pre-playback generation window (startedAt is null) so the
diff --git a/app/src/components/Generation/FloatingGenerateBox.browser.test.tsx b/app/src/components/Generation/FloatingGenerateBox.browser.test.tsx
new file mode 100644
index 000000000..012e6cec3
--- /dev/null
+++ b/app/src/components/Generation/FloatingGenerateBox.browser.test.tsx
@@ -0,0 +1,185 @@
+import { HttpResponse, http } from 'msw';
+import { expect, it } from 'vitest';
+import type { VoiceProfileResponse } from '@/lib/api/types';
+import { useGenerationStore } from '@/stores/generationStore';
+import { useUIStore } from '@/stores/uiStore';
+import { buildGeneration, buildModelStatus, buildProfile } from '@/test/msw/fixtures';
+import {
+ captureHandlers,
+ effectsHandlers,
+ historyHandlers,
+ modelHandlers,
+ profileHandlers,
+ settingsHandlers,
+ storyHandlers,
+ taskHandlers,
+} from '@/test/msw/handlers';
+import { worker } from '@/test/msw/worker';
+import { renderRoute } from '@/test/render';
+import { sseController } from '@/test/sse';
+
+/**
+ * FloatingGenerateBox calls useMatchRoute, so it needs router context; the
+ * SSE completion loop (useGenerationProgress) lives in the router's root
+ * layout. Mounting the index route exercises the real wiring for both.
+ * History handlers are registered per test so requests can be counted.
+ */
+function stubAppRequests(profiles: VoiceProfileResponse[]) {
+ worker.use(
+ ...profileHandlers(profiles),
+ ...captureHandlers([]),
+ ...settingsHandlers(),
+ ...modelHandlers([buildModelStatus()]),
+ ...storyHandlers([]),
+ ...effectsHandlers([]),
+ ...taskHandlers(),
+ );
+}
+
+it('renders the generate box wired to the selected profile', async () => {
+ const profile = buildProfile({ name: 'Ada Lovelace' });
+ stubAppRequests([profile]);
+ worker.use(...historyHandlers([]));
+ useUIStore.getState().setSelectedProfileId(profile.id);
+
+ const screen = await renderRoute('/');
+
+ await expect
+ .element(screen.getByPlaceholder('Generate speech using Ada Lovelace…'))
+ .toBeVisible();
+ await expect.element(screen.getByRole('button', { name: 'Generate speech' })).toBeEnabled();
+ expect(useUIStore.getState().selectedProfileId).toBe(profile.id);
+});
+
+it('posts to /generate on submit and tracks the pending generation', async () => {
+ const profile = buildProfile({ name: 'Ada Lovelace' });
+ const generation = buildGeneration({
+ profile_id: profile.id,
+ status: 'generating',
+ audio_path: undefined,
+ });
+ const generateBodies: unknown[] = [];
+ const sse = sseController();
+ stubAppRequests([profile]);
+ worker.use(
+ ...historyHandlers([]),
+ http.post('*/generate', async ({ request }) => {
+ generateBodies.push(await request.json());
+ return HttpResponse.json(generation);
+ }),
+ http.get('*/generate/:id/status', () => sse.response()),
+ );
+ useUIStore.getState().setSelectedProfileId(profile.id);
+
+ const screen = await renderRoute('/');
+
+ const input = screen.getByPlaceholder('Generate speech using Ada Lovelace…');
+ await input.fill('Hello from the browser test');
+ await screen.getByRole('button', { name: 'Generate speech' }).click();
+
+ await expect.poll(() => generateBodies.length).toBe(1);
+ expect(generateBodies[0]).toMatchObject({
+ profile_id: profile.id,
+ text: 'Hello from the browser test',
+ language: 'en',
+ engine: 'qwen',
+ });
+ await expect
+ .poll(() => useGenerationStore.getState().pendingGenerationIds.has(generation.id))
+ .toBe(true);
+ // The form resets as soon as the request is accepted.
+ await expect.element(input).toHaveValue('');
+ sse.close();
+});
+
+it('clears pending state and refetches history when SSE reports completion', async () => {
+ const profile = buildProfile({ name: 'Ada Lovelace' });
+ const generation = buildGeneration({
+ profile_id: profile.id,
+ status: 'generating',
+ audio_path: undefined,
+ });
+ const sse = sseController();
+ let sseConnections = 0;
+ let historyGets = 0;
+ stubAppRequests([profile]);
+ worker.use(
+ http.get('*/history', () => {
+ historyGets += 1;
+ return HttpResponse.json({ items: [], total: 0 });
+ }),
+ http.post('*/generate', () => HttpResponse.json(generation)),
+ http.get('*/generate/:id/status', () => {
+ sseConnections += 1;
+ return sse.response();
+ }),
+ // Autoplay is off via settingsHandlers, but keep audio stubbed so a
+ // completion-triggered player fetch could never fail the run loudly.
+ http.get(
+ '*/audio/:id',
+ () =>
+ new HttpResponse(new Blob([new Uint8Array(64)]), {
+ headers: { 'Content-Type': 'audio/wav' },
+ }),
+ ),
+ );
+ useUIStore.getState().setSelectedProfileId(profile.id);
+
+ const screen = await renderRoute('/');
+
+ await screen.getByPlaceholder('Generate speech using Ada Lovelace…').fill('Progress please');
+ await screen.getByRole('button', { name: 'Generate speech' }).click();
+
+ await expect
+ .poll(() => useGenerationStore.getState().pendingGenerationIds.has(generation.id))
+ .toBe(true);
+ await expect.poll(() => sseConnections).toBe(1);
+ // Initial mount fetch + post-submit invalidation — wait for both so the
+ // final count increase can only come from the SSE completion refetch.
+ await expect.poll(() => historyGets).toBe(2);
+
+ sse.push({ data: { id: generation.id, status: 'generating' } });
+ sse.push({ data: { id: generation.id, status: 'completed', duration: 1.5 } });
+
+ await expect.poll(() => useGenerationStore.getState().pendingGenerationIds.size).toBe(0);
+ await expect.poll(() => historyGets).toBe(3);
+ sse.close();
+});
+
+it('disables the input and generate button when no profile is selected', async () => {
+ stubAppRequests([]);
+ worker.use(...historyHandlers([]));
+
+ const screen = await renderRoute('/');
+
+ await expect
+ .element(screen.getByRole('button', { name: 'Select a voice profile first' }))
+ .toBeDisabled();
+ await expect.element(screen.getByPlaceholder('Select a voice profile above…')).toBeDisabled();
+});
+
+it('does not post to /generate when the text is empty', async () => {
+ const profile = buildProfile({ name: 'Ada Lovelace' });
+ let generateCalls = 0;
+ stubAppRequests([profile]);
+ worker.use(
+ ...historyHandlers([]),
+ http.post('*/generate', () => {
+ generateCalls += 1;
+ return HttpResponse.json(buildGeneration());
+ }),
+ );
+ useUIStore.getState().setSelectedProfileId(profile.id);
+
+ const screen = await renderRoute('/');
+
+ const button = screen.getByRole('button', { name: 'Generate speech' });
+ await expect.element(button).toBeEnabled();
+ await button.click();
+
+ // Validation rejects empty text before any request is made — give a
+ // would-be submission ample time to surface, then assert it never did.
+ await new Promise((resolve) => setTimeout(resolve, 300));
+ expect(generateCalls).toBe(0);
+ expect(useGenerationStore.getState().pendingGenerationIds.size).toBe(0);
+});
diff --git a/app/src/components/History/HistoryTable.browser.test.tsx b/app/src/components/History/HistoryTable.browser.test.tsx
new file mode 100644
index 000000000..85a66511e
--- /dev/null
+++ b/app/src/components/History/HistoryTable.browser.test.tsx
@@ -0,0 +1,133 @@
+import { HttpResponse, http } from 'msw';
+import { expect, it, vi } from 'vitest';
+import { HistoryTable } from '@/components/History/HistoryTable';
+import { usePlayerStore } from '@/stores/playerStore';
+import { buildHistoryItem } from '@/test/msw/fixtures';
+import { historyHandlers } from '@/test/msw/handlers';
+import { worker } from '@/test/msw/worker';
+import { renderWithProviders } from '@/test/render';
+
+it('renders history rows with profile names and transcripts', async () => {
+ const ada = buildHistoryItem({
+ profile_name: 'Ada Lovelace',
+ text: 'The analytical engine speaks.',
+ });
+ const grace = buildHistoryItem({
+ profile_name: 'Grace Hopper',
+ text: 'A compiler for the spoken word.',
+ });
+ worker.use(...historyHandlers([ada, grace]));
+
+ const screen = await renderWithProviders();
+
+ await expect.element(screen.getByText('Ada Lovelace')).toBeVisible();
+ await expect.element(screen.getByText('Grace Hopper')).toBeVisible();
+ await expect
+ .element(screen.getByRole('textbox', { name: /Transcript for sample from Ada Lovelace/ }))
+ .toHaveValue('The analytical engine speaks.');
+ await expect
+ .element(screen.getByRole('textbox', { name: /Transcript for sample from Grace Hopper/ }))
+ .toHaveValue('A compiler for the spoken word.');
+});
+
+it('shows the empty state when there is no history', async () => {
+ worker.use(...historyHandlers([]));
+
+ const screen = await renderWithProviders();
+
+ await expect.element(screen.getByText('No voice generations', { exact: false })).toBeVisible();
+});
+
+it('loads a clicked row into the player store with auto-play intent', async () => {
+ const item = buildHistoryItem({ profile_name: 'Ada Lovelace', text: 'Play me back.' });
+ worker.use(...historyHandlers([item]));
+
+ const screen = await renderWithProviders();
+
+ // Click the profile-name cell — the row's mousedown handler ignores clicks
+ // that land on the transcript textarea.
+ await screen.getByText('Ada Lovelace').click();
+
+ await expect.poll(() => usePlayerStore.getState().audioId).toBe(item.id);
+ const player = usePlayerStore.getState();
+ expect(player.audioUrl).toContain(`/audio/${item.id}`);
+ expect(player.profileId).toBe(item.profile_id);
+ expect(player.shouldAutoPlay).toBe(true);
+ // isPlaying flips only once the AudioPlayer (not mounted here) starts playback.
+ expect(player.isPlaying).toBe(false);
+});
+
+it('toggles favorite via POST and reflects the refetched state', async () => {
+ const item = buildHistoryItem({ profile_name: 'Ada Lovelace' });
+ let favorited = false;
+ const favoriteRequests: string[] = [];
+ worker.use(
+ http.get('*/history', () =>
+ HttpResponse.json({ items: [{ ...item, is_favorited: favorited }], total: 1 }),
+ ),
+ http.post('*/history/:id/favorite', ({ params }) => {
+ favoriteRequests.push(params.id as string);
+ favorited = true;
+ return HttpResponse.json({ is_favorited: favorited });
+ }),
+ );
+
+ const screen = await renderWithProviders();
+
+ await screen.getByRole('button', { name: 'Favorite' }).click();
+
+ await expect.poll(() => favoriteRequests).toEqual([item.id]);
+ // History was invalidated and refetched — the star now reads as favorited.
+ await expect.element(screen.getByRole('button', { name: 'Unfavorite' })).toBeVisible();
+});
+
+it('deletes a generation after confirming the dialog', async () => {
+ const item = buildHistoryItem({ profile_name: 'Ada Lovelace' });
+ let items = [item];
+ const deleteRequests: string[] = [];
+ worker.use(
+ http.get('*/history', () => HttpResponse.json({ items, total: items.length })),
+ http.delete('*/history/:id', ({ params }) => {
+ deleteRequests.push(params.id as string);
+ items = items.filter((i) => i.id !== params.id);
+ return HttpResponse.json({ status: 'deleted' });
+ }),
+ );
+
+ const screen = await renderWithProviders();
+
+ await screen.getByRole('button', { name: 'Actions' }).click();
+ await screen.getByRole('menuitem', { name: 'Delete' }).click();
+ await expect.element(screen.getByText('Delete Generation')).toBeVisible();
+ await screen.getByRole('button', { name: 'Delete' }).click();
+
+ await expect.poll(() => deleteRequests).toEqual([item.id]);
+ // The refetched (now empty) list replaces the row.
+ await expect.element(screen.getByText('No voice generations', { exact: false })).toBeVisible();
+});
+
+it('exports audio through platform.filesystem.saveFile', async () => {
+ const item = buildHistoryItem({ profile_name: 'Ada Lovelace', text: 'Export me please' });
+ worker.use(
+ ...historyHandlers([item]),
+ http.get(
+ '*/history/:id/export-audio',
+ () =>
+ new HttpResponse(new Blob([new Uint8Array(64)]), {
+ headers: { 'Content-Type': 'audio/wav' },
+ }),
+ ),
+ );
+
+ const screen = await renderWithProviders();
+
+ await screen.getByRole('button', { name: 'Actions' }).click();
+ await screen.getByRole('menuitem', { name: 'Export Audio' }).click();
+
+ const saveFile = vi.mocked(screen.platform.filesystem.saveFile);
+ await expect.poll(() => saveFile.mock.calls.length).toBe(1);
+ const [filename, blob, filters] = saveFile.mock.calls[0];
+ expect(filename).toBe('export-me-please.wav');
+ expect(blob).toBeInstanceOf(Blob);
+ expect(filters).toEqual([{ name: 'Audio File', extensions: ['wav'] }]);
+});
diff --git a/app/src/components/ServerTab/AboutPage.browser.test.tsx b/app/src/components/ServerTab/AboutPage.browser.test.tsx
new file mode 100644
index 000000000..1bff9aa29
--- /dev/null
+++ b/app/src/components/ServerTab/AboutPage.browser.test.tsx
@@ -0,0 +1,15 @@
+import { expect, it } from 'vitest';
+import { AboutPage } from '@/components/ServerTab/AboutPage';
+import { createMockPlatform } from '@/test/mockPlatform';
+import { renderWithProviders } from '@/test/render';
+
+it('renders and shows the platform version', async () => {
+ const platform = createMockPlatform({
+ metadata: { getVersion: async () => '9.9.9-test', isTauri: false },
+ });
+
+ const screen = await renderWithProviders(, { platform });
+
+ await expect.element(screen.getByAltText('Voicebox')).toBeVisible();
+ await expect.element(screen.getByText('9.9.9-test', { exact: false })).toBeVisible();
+});
diff --git a/app/src/components/ServerTab/__screenshots__/AboutPage.browser.test.tsx/renders-and-shows-the-platform-version-1.png b/app/src/components/ServerTab/__screenshots__/AboutPage.browser.test.tsx/renders-and-shows-the-platform-version-1.png
new file mode 100644
index 000000000..4087dd2e4
Binary files /dev/null and b/app/src/components/ServerTab/__screenshots__/AboutPage.browser.test.tsx/renders-and-shows-the-platform-version-1.png differ
diff --git a/app/src/hooks/useAutoUpdater.ts b/app/src/hooks/useAutoUpdater.ts
deleted file mode 100644
index b46764c8e..000000000
--- a/app/src/hooks/useAutoUpdater.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-import { useCallback, useEffect, useRef, useState } from 'react';
-import { usePlatform } from '@/platform/PlatformContext';
-import type { UpdateStatus } from '@/platform/types';
-
-// Re-export UpdateStatus for backwards compatibility
-export type { UpdateStatus };
-
-interface UseAutoUpdaterOptions {
- checkOnMount?: boolean;
- showToast?: boolean;
-}
-
-export function useAutoUpdater(options: boolean | UseAutoUpdaterOptions = false) {
- const { checkOnMount } =
- typeof options === 'boolean' ? { checkOnMount: options } : { checkOnMount: options.checkOnMount ?? false };
-
- const platform = usePlatform();
- const [status, setStatus] = useState(platform.updater.getStatus());
- const hasCheckedRef = useRef(false);
-
- // Subscribe to updater status changes
- useEffect(() => {
- const unsubscribe = platform.updater.subscribe((newStatus) => {
- setStatus(newStatus);
- });
- return unsubscribe;
- // Empty dependency array - platform is stable from context
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [platform.updater.subscribe]);
-
- const checkForUpdates = useCallback(async () => {
- await platform.updater.checkForUpdates();
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [platform.updater.checkForUpdates]);
-
- const downloadAndInstall = useCallback(async () => {
- await platform.updater.downloadAndInstall();
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [platform.updater.downloadAndInstall]);
-
- const restartAndInstall = useCallback(async () => {
- await platform.updater.restartAndInstall();
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [platform.updater.restartAndInstall]);
-
- useEffect(() => {
- if (checkOnMount && platform.metadata.isTauri && !hasCheckedRef.current) {
- hasCheckedRef.current = true;
- checkForUpdates().catch((error) => {
- console.error('Auto update check failed:', error);
- });
- }
- }, [checkOnMount, checkForUpdates, platform.metadata.isTauri]);
-
- return {
- status,
- checkForUpdates,
- downloadAndInstall,
- restartAndInstall,
- };
-}
diff --git a/app/src/lib/api/core/ApiError.ts b/app/src/lib/api/core/ApiError.ts
deleted file mode 100644
index 657a30a30..000000000
--- a/app/src/lib/api/core/ApiError.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-/* generated using openapi-typescript-codegen -- do not edit */
-/* istanbul ignore file */
-/* tslint:disable */
-/* eslint-disable */
-import type { ApiRequestOptions } from './ApiRequestOptions';
-import type { ApiResult } from './ApiResult';
-
-export class ApiError extends Error {
- public readonly url: string;
- public readonly status: number;
- public readonly statusText: string;
- public readonly body: any;
- public readonly request: ApiRequestOptions;
-
- constructor(request: ApiRequestOptions, response: ApiResult, message: string) {
- super(message);
-
- this.name = 'ApiError';
- this.url = response.url;
- this.status = response.status;
- this.statusText = response.statusText;
- this.body = response.body;
- this.request = request;
- }
-}
diff --git a/app/src/lib/api/core/ApiRequestOptions.ts b/app/src/lib/api/core/ApiRequestOptions.ts
deleted file mode 100644
index 40aab6c03..000000000
--- a/app/src/lib/api/core/ApiRequestOptions.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-/* generated using openapi-typescript-codegen -- do not edit */
-/* istanbul ignore file */
-/* tslint:disable */
-/* eslint-disable */
-export type ApiRequestOptions = {
- readonly method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'OPTIONS' | 'HEAD' | 'PATCH';
- readonly url: string;
- readonly path?: Record;
- readonly cookies?: Record;
- readonly headers?: Record;
- readonly query?: Record;
- readonly formData?: Record;
- readonly body?: any;
- readonly mediaType?: string;
- readonly responseHeader?: string;
- readonly errors?: Record;
-};
diff --git a/app/src/lib/api/core/ApiResult.ts b/app/src/lib/api/core/ApiResult.ts
deleted file mode 100644
index 24c93fc1b..000000000
--- a/app/src/lib/api/core/ApiResult.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-/* generated using openapi-typescript-codegen -- do not edit */
-/* istanbul ignore file */
-/* tslint:disable */
-/* eslint-disable */
-export type ApiResult = {
- readonly url: string;
- readonly ok: boolean;
- readonly status: number;
- readonly statusText: string;
- readonly body: any;
-};
diff --git a/app/src/lib/api/core/CancelablePromise.ts b/app/src/lib/api/core/CancelablePromise.ts
deleted file mode 100644
index d94a263e9..000000000
--- a/app/src/lib/api/core/CancelablePromise.ts
+++ /dev/null
@@ -1,130 +0,0 @@
-/* generated using openapi-typescript-codegen -- do not edit */
-/* istanbul ignore file */
-/* tslint:disable */
-/* eslint-disable */
-export class CancelError extends Error {
- constructor(message: string) {
- super(message);
- this.name = 'CancelError';
- }
-
- public get isCancelled(): boolean {
- return true;
- }
-}
-
-export interface OnCancel {
- readonly isResolved: boolean;
- readonly isRejected: boolean;
- readonly isCancelled: boolean;
-
- (cancelHandler: () => void): void;
-}
-
-export class CancelablePromise implements Promise {
- #isResolved: boolean;
- #isRejected: boolean;
- #isCancelled: boolean;
- readonly #cancelHandlers: (() => void)[];
- readonly #promise: Promise;
- #resolve?: (value: T | PromiseLike) => void;
- #reject?: (reason?: any) => void;
-
- constructor(
- executor: (
- resolve: (value: T | PromiseLike) => void,
- reject: (reason?: any) => void,
- onCancel: OnCancel,
- ) => void,
- ) {
- this.#isResolved = false;
- this.#isRejected = false;
- this.#isCancelled = false;
- this.#cancelHandlers = [];
- this.#promise = new Promise((resolve, reject) => {
- this.#resolve = resolve;
- this.#reject = reject;
-
- const onResolve = (value: T | PromiseLike): void => {
- if (this.#isResolved || this.#isRejected || this.#isCancelled) {
- return;
- }
- this.#isResolved = true;
- if (this.#resolve) this.#resolve(value);
- };
-
- const onReject = (reason?: any): void => {
- if (this.#isResolved || this.#isRejected || this.#isCancelled) {
- return;
- }
- this.#isRejected = true;
- if (this.#reject) this.#reject(reason);
- };
-
- const onCancel = (cancelHandler: () => void): void => {
- if (this.#isResolved || this.#isRejected || this.#isCancelled) {
- return;
- }
- this.#cancelHandlers.push(cancelHandler);
- };
-
- Object.defineProperty(onCancel, 'isResolved', {
- get: (): boolean => this.#isResolved,
- });
-
- Object.defineProperty(onCancel, 'isRejected', {
- get: (): boolean => this.#isRejected,
- });
-
- Object.defineProperty(onCancel, 'isCancelled', {
- get: (): boolean => this.#isCancelled,
- });
-
- return executor(onResolve, onReject, onCancel as OnCancel);
- });
- }
-
- get [Symbol.toStringTag]() {
- return 'Cancellable Promise';
- }
-
- public then(
- onFulfilled?: ((value: T) => TResult1 | PromiseLike) | null,
- onRejected?: ((reason: any) => TResult2 | PromiseLike) | null,
- ): Promise {
- return this.#promise.then(onFulfilled, onRejected);
- }
-
- public catch(
- onRejected?: ((reason: any) => TResult | PromiseLike) | null,
- ): Promise {
- return this.#promise.catch(onRejected);
- }
-
- public finally(onFinally?: (() => void) | null): Promise {
- return this.#promise.finally(onFinally);
- }
-
- public cancel(): void {
- if (this.#isResolved || this.#isRejected || this.#isCancelled) {
- return;
- }
- this.#isCancelled = true;
- if (this.#cancelHandlers.length) {
- try {
- for (const cancelHandler of this.#cancelHandlers) {
- cancelHandler();
- }
- } catch (error) {
- console.warn('Cancellation threw an error', error);
- return;
- }
- }
- this.#cancelHandlers.length = 0;
- if (this.#reject) this.#reject(new CancelError('Request aborted'));
- }
-
- public get isCancelled(): boolean {
- return this.#isCancelled;
- }
-}
diff --git a/app/src/lib/api/core/OpenAPI.ts b/app/src/lib/api/core/OpenAPI.ts
deleted file mode 100644
index a7242371e..000000000
--- a/app/src/lib/api/core/OpenAPI.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-/* generated using openapi-typescript-codegen -- do not edit */
-/* istanbul ignore file */
-/* tslint:disable */
-/* eslint-disable */
-import type { ApiRequestOptions } from './ApiRequestOptions';
-
-type Resolver = (options: ApiRequestOptions) => Promise;
-type Headers = Record;
-
-export type OpenAPIConfig = {
- BASE: string;
- VERSION: string;
- WITH_CREDENTIALS: boolean;
- CREDENTIALS: 'include' | 'omit' | 'same-origin';
- TOKEN?: string | Resolver | undefined;
- USERNAME?: string | Resolver | undefined;
- PASSWORD?: string | Resolver | undefined;
- HEADERS?: Headers | Resolver | undefined;
- ENCODE_PATH?: ((path: string) => string) | undefined;
-};
-
-export const OpenAPI: OpenAPIConfig = {
- BASE: '',
- VERSION: '0.1.0',
- WITH_CREDENTIALS: false,
- CREDENTIALS: 'include',
- TOKEN: undefined,
- USERNAME: undefined,
- PASSWORD: undefined,
- HEADERS: undefined,
- ENCODE_PATH: undefined,
-};
diff --git a/app/src/lib/api/core/request.ts b/app/src/lib/api/core/request.ts
deleted file mode 100644
index ac97e19be..000000000
--- a/app/src/lib/api/core/request.ts
+++ /dev/null
@@ -1,341 +0,0 @@
-/* generated using openapi-typescript-codegen -- do not edit */
-/* istanbul ignore file */
-/* tslint:disable */
-/* eslint-disable */
-import { ApiError } from './ApiError';
-import type { ApiRequestOptions } from './ApiRequestOptions';
-import type { ApiResult } from './ApiResult';
-import { CancelablePromise } from './CancelablePromise';
-import type { OnCancel } from './CancelablePromise';
-import type { OpenAPIConfig } from './OpenAPI';
-
-export const isDefined = (
- value: T | null | undefined,
-): value is Exclude => {
- return value !== undefined && value !== null;
-};
-
-export const isString = (value: any): value is string => {
- return typeof value === 'string';
-};
-
-export const isStringWithValue = (value: any): value is string => {
- return isString(value) && value !== '';
-};
-
-export const isBlob = (value: any): value is Blob => {
- return (
- typeof value === 'object' &&
- typeof value.type === 'string' &&
- typeof value.stream === 'function' &&
- typeof value.arrayBuffer === 'function' &&
- typeof value.constructor === 'function' &&
- typeof value.constructor.name === 'string' &&
- /^(Blob|File)$/.test(value.constructor.name) &&
- /^(Blob|File)$/.test(value[Symbol.toStringTag])
- );
-};
-
-export const isFormData = (value: any): value is FormData => {
- return value instanceof FormData;
-};
-
-export const base64 = (str: string): string => {
- try {
- return btoa(str);
- } catch (err) {
- // @ts-ignore
- return Buffer.from(str).toString('base64');
- }
-};
-
-export const getQueryString = (params: Record): string => {
- const qs: string[] = [];
-
- const append = (key: string, value: any) => {
- qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
- };
-
- const process = (key: string, value: any) => {
- if (isDefined(value)) {
- if (Array.isArray(value)) {
- value.forEach((v) => {
- process(key, v);
- });
- } else if (typeof value === 'object') {
- Object.entries(value).forEach(([k, v]) => {
- process(`${key}[${k}]`, v);
- });
- } else {
- append(key, value);
- }
- }
- };
-
- Object.entries(params).forEach(([key, value]) => {
- process(key, value);
- });
-
- if (qs.length > 0) {
- return `?${qs.join('&')}`;
- }
-
- return '';
-};
-
-const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => {
- const encoder = config.ENCODE_PATH || encodeURI;
-
- const path = options.url
- .replace('{api-version}', config.VERSION)
- .replace(/{(.*?)}/g, (substring: string, group: string) => {
- if (options.path?.hasOwnProperty(group)) {
- return encoder(String(options.path[group]));
- }
- return substring;
- });
-
- const url = `${config.BASE}${path}`;
- if (options.query) {
- return `${url}${getQueryString(options.query)}`;
- }
- return url;
-};
-
-export const getFormData = (options: ApiRequestOptions): FormData | undefined => {
- if (options.formData) {
- const formData = new FormData();
-
- const process = (key: string, value: any) => {
- if (isString(value) || isBlob(value)) {
- formData.append(key, value);
- } else {
- formData.append(key, JSON.stringify(value));
- }
- };
-
- Object.entries(options.formData)
- .filter(([_, value]) => isDefined(value))
- .forEach(([key, value]) => {
- if (Array.isArray(value)) {
- value.forEach((v) => process(key, v));
- } else {
- process(key, value);
- }
- });
-
- return formData;
- }
- return undefined;
-};
-
-type Resolver = (options: ApiRequestOptions) => Promise;
-
-export const resolve = async (
- options: ApiRequestOptions,
- resolver?: T | Resolver,
-): Promise => {
- if (typeof resolver === 'function') {
- return (resolver as Resolver)(options);
- }
- return resolver;
-};
-
-export const getHeaders = async (
- config: OpenAPIConfig,
- options: ApiRequestOptions,
-): Promise => {
- const [token, username, password, additionalHeaders] = await Promise.all([
- resolve(options, config.TOKEN),
- resolve(options, config.USERNAME),
- resolve(options, config.PASSWORD),
- resolve(options, config.HEADERS),
- ]);
-
- const headers = Object.entries({
- Accept: 'application/json',
- ...additionalHeaders,
- ...options.headers,
- })
- .filter(([_, value]) => isDefined(value))
- .reduce(
- (headers, [key, value]) => ({
- ...headers,
- [key]: String(value),
- }),
- {} as Record,
- );
-
- if (isStringWithValue(token)) {
- headers['Authorization'] = `Bearer ${token}`;
- }
-
- if (isStringWithValue(username) && isStringWithValue(password)) {
- const credentials = base64(`${username}:${password}`);
- headers['Authorization'] = `Basic ${credentials}`;
- }
-
- if (options.body !== undefined) {
- if (options.mediaType) {
- headers['Content-Type'] = options.mediaType;
- } else if (isBlob(options.body)) {
- headers['Content-Type'] = options.body.type || 'application/octet-stream';
- } else if (isString(options.body)) {
- headers['Content-Type'] = 'text/plain';
- } else if (!isFormData(options.body)) {
- headers['Content-Type'] = 'application/json';
- }
- }
-
- return new Headers(headers);
-};
-
-export const getRequestBody = (options: ApiRequestOptions): any => {
- if (options.body !== undefined) {
- if (options.mediaType?.includes('/json')) {
- return JSON.stringify(options.body);
- } else if (isString(options.body) || isBlob(options.body) || isFormData(options.body)) {
- return options.body;
- } else {
- return JSON.stringify(options.body);
- }
- }
- return undefined;
-};
-
-export const sendRequest = async (
- config: OpenAPIConfig,
- options: ApiRequestOptions,
- url: string,
- body: any,
- formData: FormData | undefined,
- headers: Headers,
- onCancel: OnCancel,
-): Promise => {
- const controller = new AbortController();
-
- const request: RequestInit = {
- headers,
- body: body ?? formData,
- method: options.method,
- signal: controller.signal,
- };
-
- if (config.WITH_CREDENTIALS) {
- request.credentials = config.CREDENTIALS;
- }
-
- onCancel(() => controller.abort());
-
- return await fetch(url, request);
-};
-
-export const getResponseHeader = (
- response: Response,
- responseHeader?: string,
-): string | undefined => {
- if (responseHeader) {
- const content = response.headers.get(responseHeader);
- if (isString(content)) {
- return content;
- }
- }
- return undefined;
-};
-
-export const getResponseBody = async (response: Response): Promise => {
- if (response.status !== 204) {
- try {
- const contentType = response.headers.get('Content-Type');
- if (contentType) {
- const jsonTypes = ['application/json', 'application/problem+json'];
- const isJSON = jsonTypes.some((type) => contentType.toLowerCase().startsWith(type));
- if (isJSON) {
- return await response.json();
- } else {
- return await response.text();
- }
- }
- } catch (error) {
- console.error(error);
- }
- }
- return undefined;
-};
-
-export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): void => {
- const errors: Record = {
- 400: 'Bad Request',
- 401: 'Unauthorized',
- 403: 'Forbidden',
- 404: 'Not Found',
- 500: 'Internal Server Error',
- 502: 'Bad Gateway',
- 503: 'Service Unavailable',
- ...options.errors,
- };
-
- const error = errors[result.status];
- if (error) {
- throw new ApiError(options, result, error);
- }
-
- if (!result.ok) {
- const errorStatus = result.status ?? 'unknown';
- const errorStatusText = result.statusText ?? 'unknown';
- const errorBody = (() => {
- try {
- return JSON.stringify(result.body, null, 2);
- } catch (e) {
- return undefined;
- }
- })();
-
- throw new ApiError(
- options,
- result,
- `Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}`,
- );
- }
-};
-
-/**
- * Request method
- * @param config The OpenAPI configuration object
- * @param options The request options from the service
- * @returns CancelablePromise
- * @throws ApiError
- */
-export const request = (
- config: OpenAPIConfig,
- options: ApiRequestOptions,
-): CancelablePromise => {
- return new CancelablePromise(async (resolve, reject, onCancel) => {
- try {
- const url = getUrl(config, options);
- const formData = getFormData(options);
- const body = getRequestBody(options);
- const headers = await getHeaders(config, options);
-
- if (!onCancel.isCancelled) {
- const response = await sendRequest(config, options, url, body, formData, headers, onCancel);
- const responseBody = await getResponseBody(response);
- const responseHeader = getResponseHeader(response, options.responseHeader);
-
- const result: ApiResult = {
- url,
- ok: response.ok,
- status: response.status,
- statusText: response.statusText,
- body: responseHeader ?? responseBody,
- };
-
- catchErrorCodes(options, result);
-
- resolve(result.body);
- }
- } catch (error) {
- reject(error);
- }
- });
-};
diff --git a/app/src/lib/api/index.ts b/app/src/lib/api/index.ts
deleted file mode 100644
index 58d71cd42..000000000
--- a/app/src/lib/api/index.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-/* generated using openapi-typescript-codegen -- do not edit */
-/* istanbul ignore file */
-/* tslint:disable */
-/* eslint-disable */
-export { ApiError } from './core/ApiError';
-export { CancelablePromise, CancelError } from './core/CancelablePromise';
-export { OpenAPI } from './core/OpenAPI';
-export type { OpenAPIConfig } from './core/OpenAPI';
-
-export type { Body_add_profile_sample_profiles__profile_id__samples_post } from './models/Body_add_profile_sample_profiles__profile_id__samples_post';
-export type { Body_transcribe_audio_transcribe_post } from './models/Body_transcribe_audio_transcribe_post';
-export type { GenerationRequest } from './models/GenerationRequest';
-export type { GenerationResponse } from './models/GenerationResponse';
-export type { HealthResponse } from './models/HealthResponse';
-export type { HistoryListResponse } from './models/HistoryListResponse';
-export type { HistoryResponse } from './models/HistoryResponse';
-export type { HTTPValidationError } from './models/HTTPValidationError';
-export type { ModelDownloadRequest } from './models/ModelDownloadRequest';
-export type { ModelStatus } from './models/ModelStatus';
-export type { ModelStatusListResponse } from './models/ModelStatusListResponse';
-export type { ProfileSampleResponse } from './models/ProfileSampleResponse';
-export type { TranscriptionResponse } from './models/TranscriptionResponse';
-export type { ValidationError } from './models/ValidationError';
-export type { VoiceProfileCreate } from './models/VoiceProfileCreate';
-export type { VoiceProfileResponse } from './models/VoiceProfileResponse';
-
-export { $Body_add_profile_sample_profiles__profile_id__samples_post } from './schemas/$Body_add_profile_sample_profiles__profile_id__samples_post';
-export { $Body_transcribe_audio_transcribe_post } from './schemas/$Body_transcribe_audio_transcribe_post';
-export { $GenerationRequest } from './schemas/$GenerationRequest';
-export { $GenerationResponse } from './schemas/$GenerationResponse';
-export { $HealthResponse } from './schemas/$HealthResponse';
-export { $HistoryListResponse } from './schemas/$HistoryListResponse';
-export { $HistoryResponse } from './schemas/$HistoryResponse';
-export { $HTTPValidationError } from './schemas/$HTTPValidationError';
-export { $ModelDownloadRequest } from './schemas/$ModelDownloadRequest';
-export { $ModelStatus } from './schemas/$ModelStatus';
-export { $ModelStatusListResponse } from './schemas/$ModelStatusListResponse';
-export { $ProfileSampleResponse } from './schemas/$ProfileSampleResponse';
-export { $TranscriptionResponse } from './schemas/$TranscriptionResponse';
-export { $ValidationError } from './schemas/$ValidationError';
-export { $VoiceProfileCreate } from './schemas/$VoiceProfileCreate';
-export { $VoiceProfileResponse } from './schemas/$VoiceProfileResponse';
-
-export { DefaultService } from './services/DefaultService';
diff --git a/app/src/lib/api/models/Body_add_profile_sample_profiles__profile_id__samples_post.ts b/app/src/lib/api/models/Body_add_profile_sample_profiles__profile_id__samples_post.ts
deleted file mode 100644
index 7229998b9..000000000
--- a/app/src/lib/api/models/Body_add_profile_sample_profiles__profile_id__samples_post.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-/* generated using openapi-typescript-codegen -- do not edit */
-/* istanbul ignore file */
-/* tslint:disable */
-/* eslint-disable */
-export type Body_add_profile_sample_profiles__profile_id__samples_post = {
- file: Blob;
- reference_text: string;
-};
diff --git a/app/src/lib/api/models/Body_transcribe_audio_transcribe_post.ts b/app/src/lib/api/models/Body_transcribe_audio_transcribe_post.ts
deleted file mode 100644
index b5851c86c..000000000
--- a/app/src/lib/api/models/Body_transcribe_audio_transcribe_post.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-/* generated using openapi-typescript-codegen -- do not edit */
-/* istanbul ignore file */
-/* tslint:disable */
-/* eslint-disable */
-export type Body_transcribe_audio_transcribe_post = {
- file: Blob;
- language?: string | null;
-};
diff --git a/app/src/lib/api/models/GenerationRequest.ts b/app/src/lib/api/models/GenerationRequest.ts
deleted file mode 100644
index 9c0e8bb0b..000000000
--- a/app/src/lib/api/models/GenerationRequest.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-/* generated using openapi-typescript-codegen -- do not edit */
-/* istanbul ignore file */
-/* tslint:disable */
-/* eslint-disable */
-/**
- * Request model for voice generation.
- */
-export type GenerationRequest = {
- profile_id: string;
- text: string;
- language?: string;
- seed?: number | null;
- model_size?: string | null;
- instruct?: string | null;
-};
diff --git a/app/src/lib/api/models/GenerationResponse.ts b/app/src/lib/api/models/GenerationResponse.ts
deleted file mode 100644
index 55599ae58..000000000
--- a/app/src/lib/api/models/GenerationResponse.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-/* generated using openapi-typescript-codegen -- do not edit */
-/* istanbul ignore file */
-/* tslint:disable */
-/* eslint-disable */
-/**
- * Response model for voice generation.
- */
-export type GenerationResponse = {
- id: string;
- profile_id: string;
- text: string;
- language: string;
- audio_path: string;
- duration: number;
- seed: number | null;
- instruct: string | null;
- created_at: string;
-};
diff --git a/app/src/lib/api/models/HTTPValidationError.ts b/app/src/lib/api/models/HTTPValidationError.ts
deleted file mode 100644
index ad8f623bd..000000000
--- a/app/src/lib/api/models/HTTPValidationError.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-/* generated using openapi-typescript-codegen -- do not edit */
-/* istanbul ignore file */
-/* tslint:disable */
-/* eslint-disable */
-import type { ValidationError } from './ValidationError';
-export type HTTPValidationError = {
- detail?: Array;
-};
diff --git a/app/src/lib/api/models/HealthResponse.ts b/app/src/lib/api/models/HealthResponse.ts
deleted file mode 100644
index d8aaf9b20..000000000
--- a/app/src/lib/api/models/HealthResponse.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-/* generated using openapi-typescript-codegen -- do not edit */
-/* istanbul ignore file */
-/* tslint:disable */
-/* eslint-disable */
-/**
- * Response model for health check.
- */
-export type HealthResponse = {
- status: string;
- model_loaded: boolean;
- model_downloaded?: boolean | null;
- model_size?: string | null;
- gpu_available: boolean;
- vram_used_mb?: number | null;
-};
diff --git a/app/src/lib/api/models/HistoryListResponse.ts b/app/src/lib/api/models/HistoryListResponse.ts
deleted file mode 100644
index 1bb92c245..000000000
--- a/app/src/lib/api/models/HistoryListResponse.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-/* generated using openapi-typescript-codegen -- do not edit */
-/* istanbul ignore file */
-/* tslint:disable */
-/* eslint-disable */
-import type { HistoryResponse } from './HistoryResponse';
-/**
- * Response model for history list.
- */
-export type HistoryListResponse = {
- items: Array;
- total: number;
-};
diff --git a/app/src/lib/api/models/HistoryResponse.ts b/app/src/lib/api/models/HistoryResponse.ts
deleted file mode 100644
index cc1805ab2..000000000
--- a/app/src/lib/api/models/HistoryResponse.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-/* generated using openapi-typescript-codegen -- do not edit */
-/* istanbul ignore file */
-/* tslint:disable */
-/* eslint-disable */
-/**
- * Response model for history entry (includes profile name).
- */
-export type HistoryResponse = {
- id: string;
- profile_id: string;
- profile_name: string;
- text: string;
- language: string;
- audio_path: string;
- duration: number;
- seed: number | null;
- instruct: string | null;
- created_at: string;
-};
diff --git a/app/src/lib/api/models/ModelDownloadRequest.ts b/app/src/lib/api/models/ModelDownloadRequest.ts
deleted file mode 100644
index 8722a24f7..000000000
--- a/app/src/lib/api/models/ModelDownloadRequest.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-/* generated using openapi-typescript-codegen -- do not edit */
-/* istanbul ignore file */
-/* tslint:disable */
-/* eslint-disable */
-/**
- * Request model for triggering model download.
- */
-export type ModelDownloadRequest = {
- model_name: string;
-};
diff --git a/app/src/lib/api/models/ModelStatus.ts b/app/src/lib/api/models/ModelStatus.ts
deleted file mode 100644
index fdba42855..000000000
--- a/app/src/lib/api/models/ModelStatus.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-/* generated using openapi-typescript-codegen -- do not edit */
-/* istanbul ignore file */
-/* tslint:disable */
-/* eslint-disable */
-/**
- * Response model for model status.
- */
-export type ModelStatus = {
- model_name: string;
- display_name: string;
- downloaded: boolean;
- downloading?: boolean; // True if download is in progress
- size_mb?: number | null;
- loaded?: boolean;
-};
diff --git a/app/src/lib/api/models/ModelStatusListResponse.ts b/app/src/lib/api/models/ModelStatusListResponse.ts
deleted file mode 100644
index 67ed3f560..000000000
--- a/app/src/lib/api/models/ModelStatusListResponse.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-/* generated using openapi-typescript-codegen -- do not edit */
-/* istanbul ignore file */
-/* tslint:disable */
-/* eslint-disable */
-import type { ModelStatus } from './ModelStatus';
-/**
- * Response model for model status list.
- */
-export type ModelStatusListResponse = {
- models: Array;
-};
diff --git a/app/src/lib/api/models/ProfileSampleResponse.ts b/app/src/lib/api/models/ProfileSampleResponse.ts
deleted file mode 100644
index 4f95dc786..000000000
--- a/app/src/lib/api/models/ProfileSampleResponse.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-/* generated using openapi-typescript-codegen -- do not edit */
-/* istanbul ignore file */
-/* tslint:disable */
-/* eslint-disable */
-/**
- * Response model for profile sample.
- */
-export type ProfileSampleResponse = {
- id: string;
- profile_id: string;
- audio_path: string;
- reference_text: string;
-};
diff --git a/app/src/lib/api/models/TranscriptionResponse.ts b/app/src/lib/api/models/TranscriptionResponse.ts
deleted file mode 100644
index 13b766479..000000000
--- a/app/src/lib/api/models/TranscriptionResponse.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-/* generated using openapi-typescript-codegen -- do not edit */
-/* istanbul ignore file */
-/* tslint:disable */
-/* eslint-disable */
-/**
- * Response model for transcription.
- */
-export type TranscriptionResponse = {
- text: string;
- duration: number;
-};
diff --git a/app/src/lib/api/models/ValidationError.ts b/app/src/lib/api/models/ValidationError.ts
deleted file mode 100644
index aa015bfd1..000000000
--- a/app/src/lib/api/models/ValidationError.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-/* generated using openapi-typescript-codegen -- do not edit */
-/* istanbul ignore file */
-/* tslint:disable */
-/* eslint-disable */
-export type ValidationError = {
- loc: Array;
- msg: string;
- type: string;
-};
diff --git a/app/src/lib/api/models/VoiceProfileCreate.ts b/app/src/lib/api/models/VoiceProfileCreate.ts
deleted file mode 100644
index 2039ff819..000000000
--- a/app/src/lib/api/models/VoiceProfileCreate.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-/* generated using openapi-typescript-codegen -- do not edit */
-/* istanbul ignore file */
-/* tslint:disable */
-/* eslint-disable */
-/**
- * Request model for creating a voice profile.
- */
-export type VoiceProfileCreate = {
- name: string;
- description?: string | null;
- language?: string;
-};
diff --git a/app/src/lib/api/models/VoiceProfileResponse.ts b/app/src/lib/api/models/VoiceProfileResponse.ts
deleted file mode 100644
index a59830498..000000000
--- a/app/src/lib/api/models/VoiceProfileResponse.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-/* generated using openapi-typescript-codegen -- do not edit */
-/* istanbul ignore file */
-/* tslint:disable */
-/* eslint-disable */
-/**
- * Response model for voice profile.
- */
-export type VoiceProfileResponse = {
- id: string;
- name: string;
- description: string | null;
- language: string;
- created_at: string;
- updated_at: string;
-};
diff --git a/app/src/lib/api/schemas/$Body_add_profile_sample_profiles__profile_id__samples_post.ts b/app/src/lib/api/schemas/$Body_add_profile_sample_profiles__profile_id__samples_post.ts
deleted file mode 100644
index 42ae7824f..000000000
--- a/app/src/lib/api/schemas/$Body_add_profile_sample_profiles__profile_id__samples_post.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-/* generated using openapi-typescript-codegen -- do not edit */
-/* istanbul ignore file */
-/* tslint:disable */
-/* eslint-disable */
-export const $Body_add_profile_sample_profiles__profile_id__samples_post = {
- properties: {
- file: {
- type: 'binary',
- isRequired: true,
- format: 'binary',
- },
- reference_text: {
- type: 'string',
- isRequired: true,
- },
- },
-} as const;
diff --git a/app/src/lib/api/schemas/$Body_transcribe_audio_transcribe_post.ts b/app/src/lib/api/schemas/$Body_transcribe_audio_transcribe_post.ts
deleted file mode 100644
index 1d692b0f3..000000000
--- a/app/src/lib/api/schemas/$Body_transcribe_audio_transcribe_post.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-/* generated using openapi-typescript-codegen -- do not edit */
-/* istanbul ignore file */
-/* tslint:disable */
-/* eslint-disable */
-export const $Body_transcribe_audio_transcribe_post = {
- properties: {
- file: {
- type: 'binary',
- isRequired: true,
- format: 'binary',
- },
- language: {
- type: 'any-of',
- contains: [
- {
- type: 'string',
- },
- {
- type: 'null',
- },
- ],
- },
- },
-} as const;
diff --git a/app/src/lib/api/schemas/$GenerationRequest.ts b/app/src/lib/api/schemas/$GenerationRequest.ts
deleted file mode 100644
index 9f308de8f..000000000
--- a/app/src/lib/api/schemas/$GenerationRequest.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-/* generated using openapi-typescript-codegen -- do not edit */
-/* istanbul ignore file */
-/* tslint:disable */
-/* eslint-disable */
-export const $GenerationRequest = {
- description: `Request model for voice generation.`,
- properties: {
- profile_id: {
- type: 'string',
- isRequired: true,
- },
- text: {
- type: 'string',
- isRequired: true,
- maxLength: 5000,
- minLength: 1,
- },
- language: {
- type: 'string',
- pattern: '^(en|zh)$',
- },
- seed: {
- type: 'any-of',
- contains: [
- {
- type: 'number',
- },
- {
- type: 'null',
- },
- ],
- },
- model_size: {
- type: 'any-of',
- contains: [
- {
- type: 'string',
- pattern: '^(1\\.7B|0\\.6B)$',
- },
- {
- type: 'null',
- },
- ],
- },
- },
-} as const;
diff --git a/app/src/lib/api/schemas/$GenerationResponse.ts b/app/src/lib/api/schemas/$GenerationResponse.ts
deleted file mode 100644
index dc185ad0b..000000000
--- a/app/src/lib/api/schemas/$GenerationResponse.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-/* generated using openapi-typescript-codegen -- do not edit */
-/* istanbul ignore file */
-/* tslint:disable */
-/* eslint-disable */
-export const $GenerationResponse = {
- description: `Response model for voice generation.`,
- properties: {
- id: {
- type: 'string',
- isRequired: true,
- },
- profile_id: {
- type: 'string',
- isRequired: true,
- },
- text: {
- type: 'string',
- isRequired: true,
- },
- language: {
- type: 'string',
- isRequired: true,
- },
- audio_path: {
- type: 'string',
- isRequired: true,
- },
- duration: {
- type: 'number',
- isRequired: true,
- },
- seed: {
- type: 'any-of',
- contains: [
- {
- type: 'number',
- },
- {
- type: 'null',
- },
- ],
- isRequired: true,
- },
- created_at: {
- type: 'string',
- isRequired: true,
- format: 'date-time',
- },
- },
-} as const;
diff --git a/app/src/lib/api/schemas/$HTTPValidationError.ts b/app/src/lib/api/schemas/$HTTPValidationError.ts
deleted file mode 100644
index 3e0176df4..000000000
--- a/app/src/lib/api/schemas/$HTTPValidationError.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-/* generated using openapi-typescript-codegen -- do not edit */
-/* istanbul ignore file */
-/* tslint:disable */
-/* eslint-disable */
-export const $HTTPValidationError = {
- properties: {
- detail: {
- type: 'array',
- contains: {
- type: 'ValidationError',
- },
- },
- },
-} as const;
diff --git a/app/src/lib/api/schemas/$HealthResponse.ts b/app/src/lib/api/schemas/$HealthResponse.ts
deleted file mode 100644
index c957f0fe3..000000000
--- a/app/src/lib/api/schemas/$HealthResponse.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-/* generated using openapi-typescript-codegen -- do not edit */
-/* istanbul ignore file */
-/* tslint:disable */
-/* eslint-disable */
-export const $HealthResponse = {
- description: `Response model for health check.`,
- properties: {
- status: {
- type: 'string',
- isRequired: true,
- },
- model_loaded: {
- type: 'boolean',
- isRequired: true,
- },
- model_downloaded: {
- type: 'any-of',
- contains: [
- {
- type: 'boolean',
- },
- {
- type: 'null',
- },
- ],
- },
- model_size: {
- type: 'any-of',
- contains: [
- {
- type: 'string',
- },
- {
- type: 'null',
- },
- ],
- },
- gpu_available: {
- type: 'boolean',
- isRequired: true,
- },
- vram_used_mb: {
- type: 'any-of',
- contains: [
- {
- type: 'number',
- },
- {
- type: 'null',
- },
- ],
- },
- },
-} as const;
diff --git a/app/src/lib/api/schemas/$HistoryListResponse.ts b/app/src/lib/api/schemas/$HistoryListResponse.ts
deleted file mode 100644
index 23c4ca15e..000000000
--- a/app/src/lib/api/schemas/$HistoryListResponse.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-/* generated using openapi-typescript-codegen -- do not edit */
-/* istanbul ignore file */
-/* tslint:disable */
-/* eslint-disable */
-export const $HistoryListResponse = {
- description: `Response model for history list.`,
- properties: {
- items: {
- type: 'array',
- contains: {
- type: 'HistoryResponse',
- },
- isRequired: true,
- },
- total: {
- type: 'number',
- isRequired: true,
- },
- },
-} as const;
diff --git a/app/src/lib/api/schemas/$HistoryResponse.ts b/app/src/lib/api/schemas/$HistoryResponse.ts
deleted file mode 100644
index 2f9ea2849..000000000
--- a/app/src/lib/api/schemas/$HistoryResponse.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-/* generated using openapi-typescript-codegen -- do not edit */
-/* istanbul ignore file */
-/* tslint:disable */
-/* eslint-disable */
-export const $HistoryResponse = {
- description: `Response model for history entry (includes profile name).`,
- properties: {
- id: {
- type: 'string',
- isRequired: true,
- },
- profile_id: {
- type: 'string',
- isRequired: true,
- },
- profile_name: {
- type: 'string',
- isRequired: true,
- },
- text: {
- type: 'string',
- isRequired: true,
- },
- language: {
- type: 'string',
- isRequired: true,
- },
- audio_path: {
- type: 'string',
- isRequired: true,
- },
- duration: {
- type: 'number',
- isRequired: true,
- },
- seed: {
- type: 'any-of',
- contains: [
- {
- type: 'number',
- },
- {
- type: 'null',
- },
- ],
- isRequired: true,
- },
- created_at: {
- type: 'string',
- isRequired: true,
- format: 'date-time',
- },
- },
-} as const;
diff --git a/app/src/lib/api/schemas/$ModelDownloadRequest.ts b/app/src/lib/api/schemas/$ModelDownloadRequest.ts
deleted file mode 100644
index aaabeaab9..000000000
--- a/app/src/lib/api/schemas/$ModelDownloadRequest.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-/* generated using openapi-typescript-codegen -- do not edit */
-/* istanbul ignore file */
-/* tslint:disable */
-/* eslint-disable */
-export const $ModelDownloadRequest = {
- description: `Request model for triggering model download.`,
- properties: {
- model_name: {
- type: 'string',
- isRequired: true,
- },
- },
-} as const;
diff --git a/app/src/lib/api/schemas/$ModelStatus.ts b/app/src/lib/api/schemas/$ModelStatus.ts
deleted file mode 100644
index 765476f78..000000000
--- a/app/src/lib/api/schemas/$ModelStatus.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-/* generated using openapi-typescript-codegen -- do not edit */
-/* istanbul ignore file */
-/* tslint:disable */
-/* eslint-disable */
-export const $ModelStatus = {
- description: `Response model for model status.`,
- properties: {
- model_name: {
- type: 'string',
- isRequired: true,
- },
- display_name: {
- type: 'string',
- isRequired: true,
- },
- downloaded: {
- type: 'boolean',
- isRequired: true,
- },
- size_mb: {
- type: 'any-of',
- contains: [
- {
- type: 'number',
- },
- {
- type: 'null',
- },
- ],
- },
- loaded: {
- type: 'boolean',
- },
- },
-} as const;
diff --git a/app/src/lib/api/schemas/$ModelStatusListResponse.ts b/app/src/lib/api/schemas/$ModelStatusListResponse.ts
deleted file mode 100644
index ddc76d799..000000000
--- a/app/src/lib/api/schemas/$ModelStatusListResponse.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-/* generated using openapi-typescript-codegen -- do not edit */
-/* istanbul ignore file */
-/* tslint:disable */
-/* eslint-disable */
-export const $ModelStatusListResponse = {
- description: `Response model for model status list.`,
- properties: {
- models: {
- type: 'array',
- contains: {
- type: 'ModelStatus',
- },
- isRequired: true,
- },
- },
-} as const;
diff --git a/app/src/lib/api/schemas/$ProfileSampleResponse.ts b/app/src/lib/api/schemas/$ProfileSampleResponse.ts
deleted file mode 100644
index 5a15e9895..000000000
--- a/app/src/lib/api/schemas/$ProfileSampleResponse.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-/* generated using openapi-typescript-codegen -- do not edit */
-/* istanbul ignore file */
-/* tslint:disable */
-/* eslint-disable */
-export const $ProfileSampleResponse = {
- description: `Response model for profile sample.`,
- properties: {
- id: {
- type: 'string',
- isRequired: true,
- },
- profile_id: {
- type: 'string',
- isRequired: true,
- },
- audio_path: {
- type: 'string',
- isRequired: true,
- },
- reference_text: {
- type: 'string',
- isRequired: true,
- },
- },
-} as const;
diff --git a/app/src/lib/api/schemas/$TranscriptionResponse.ts b/app/src/lib/api/schemas/$TranscriptionResponse.ts
deleted file mode 100644
index a85d524de..000000000
--- a/app/src/lib/api/schemas/$TranscriptionResponse.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-/* generated using openapi-typescript-codegen -- do not edit */
-/* istanbul ignore file */
-/* tslint:disable */
-/* eslint-disable */
-export const $TranscriptionResponse = {
- description: `Response model for transcription.`,
- properties: {
- text: {
- type: 'string',
- isRequired: true,
- },
- duration: {
- type: 'number',
- isRequired: true,
- },
- },
-} as const;
diff --git a/app/src/lib/api/schemas/$ValidationError.ts b/app/src/lib/api/schemas/$ValidationError.ts
deleted file mode 100644
index f3c6906ec..000000000
--- a/app/src/lib/api/schemas/$ValidationError.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-/* generated using openapi-typescript-codegen -- do not edit */
-/* istanbul ignore file */
-/* tslint:disable */
-/* eslint-disable */
-export const $ValidationError = {
- properties: {
- loc: {
- type: 'array',
- contains: {
- type: 'any-of',
- contains: [
- {
- type: 'string',
- },
- {
- type: 'number',
- },
- ],
- },
- isRequired: true,
- },
- msg: {
- type: 'string',
- isRequired: true,
- },
- type: {
- type: 'string',
- isRequired: true,
- },
- },
-} as const;
diff --git a/app/src/lib/api/schemas/$VoiceProfileCreate.ts b/app/src/lib/api/schemas/$VoiceProfileCreate.ts
deleted file mode 100644
index 6b8146e71..000000000
--- a/app/src/lib/api/schemas/$VoiceProfileCreate.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-/* generated using openapi-typescript-codegen -- do not edit */
-/* istanbul ignore file */
-/* tslint:disable */
-/* eslint-disable */
-export const $VoiceProfileCreate = {
- description: `Request model for creating a voice profile.`,
- properties: {
- name: {
- type: 'string',
- isRequired: true,
- maxLength: 100,
- minLength: 1,
- },
- description: {
- type: 'any-of',
- contains: [
- {
- type: 'string',
- maxLength: 500,
- },
- {
- type: 'null',
- },
- ],
- },
- language: {
- type: 'string',
- pattern: '^(en|zh)$',
- },
- },
-} as const;
diff --git a/app/src/lib/api/schemas/$VoiceProfileResponse.ts b/app/src/lib/api/schemas/$VoiceProfileResponse.ts
deleted file mode 100644
index 741cca8df..000000000
--- a/app/src/lib/api/schemas/$VoiceProfileResponse.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-/* generated using openapi-typescript-codegen -- do not edit */
-/* istanbul ignore file */
-/* tslint:disable */
-/* eslint-disable */
-export const $VoiceProfileResponse = {
- description: `Response model for voice profile.`,
- properties: {
- id: {
- type: 'string',
- isRequired: true,
- },
- name: {
- type: 'string',
- isRequired: true,
- },
- description: {
- type: 'any-of',
- contains: [
- {
- type: 'string',
- },
- {
- type: 'null',
- },
- ],
- isRequired: true,
- },
- language: {
- type: 'string',
- isRequired: true,
- },
- created_at: {
- type: 'string',
- isRequired: true,
- format: 'date-time',
- },
- updated_at: {
- type: 'string',
- isRequired: true,
- format: 'date-time',
- },
- },
-} as const;
diff --git a/app/src/lib/api/services/DefaultService.ts b/app/src/lib/api/services/DefaultService.ts
deleted file mode 100644
index 640c8cbce..000000000
--- a/app/src/lib/api/services/DefaultService.ts
+++ /dev/null
@@ -1,459 +0,0 @@
-/* generated using openapi-typescript-codegen -- do not edit */
-/* istanbul ignore file */
-/* tslint:disable */
-/* eslint-disable */
-import type { Body_add_profile_sample_profiles__profile_id__samples_post } from '../models/Body_add_profile_sample_profiles__profile_id__samples_post';
-import type { Body_transcribe_audio_transcribe_post } from '../models/Body_transcribe_audio_transcribe_post';
-import type { GenerationRequest } from '../models/GenerationRequest';
-import type { GenerationResponse } from '../models/GenerationResponse';
-import type { HealthResponse } from '../models/HealthResponse';
-import type { HistoryListResponse } from '../models/HistoryListResponse';
-import type { HistoryResponse } from '../models/HistoryResponse';
-import type { ModelDownloadRequest } from '../models/ModelDownloadRequest';
-import type { ModelStatusListResponse } from '../models/ModelStatusListResponse';
-import type { ProfileSampleResponse } from '../models/ProfileSampleResponse';
-import type { TranscriptionResponse } from '../models/TranscriptionResponse';
-import type { VoiceProfileCreate } from '../models/VoiceProfileCreate';
-import type { VoiceProfileResponse } from '../models/VoiceProfileResponse';
-import type { CancelablePromise } from '../core/CancelablePromise';
-import { OpenAPI } from '../core/OpenAPI';
-import { request as __request } from '../core/request';
-export class DefaultService {
- /**
- * Root
- * Root endpoint.
- * @returns any Successful Response
- * @throws ApiError
- */
- public static rootGet(): CancelablePromise {
- return __request(OpenAPI, {
- method: 'GET',
- url: '/',
- });
- }
- /**
- * Health
- * Health check endpoint.
- * @returns HealthResponse Successful Response
- * @throws ApiError
- */
- public static healthHealthGet(): CancelablePromise {
- return __request(OpenAPI, {
- method: 'GET',
- url: '/health',
- });
- }
- /**
- * List Profiles
- * List all voice profiles.
- * @returns VoiceProfileResponse Successful Response
- * @throws ApiError
- */
- public static listProfilesProfilesGet(): CancelablePromise> {
- return __request(OpenAPI, {
- method: 'GET',
- url: '/profiles',
- });
- }
- /**
- * Create Profile
- * Create a new voice profile.
- * @returns VoiceProfileResponse Successful Response
- * @throws ApiError
- */
- public static createProfileProfilesPost({
- requestBody,
- }: {
- requestBody: VoiceProfileCreate;
- }): CancelablePromise {
- return __request(OpenAPI, {
- method: 'POST',
- url: '/profiles',
- body: requestBody,
- mediaType: 'application/json',
- errors: {
- 422: `Validation Error`,
- },
- });
- }
- /**
- * Get Profile
- * Get a voice profile by ID.
- * @returns VoiceProfileResponse Successful Response
- * @throws ApiError
- */
- public static getProfileProfilesProfileIdGet({
- profileId,
- }: {
- profileId: string;
- }): CancelablePromise {
- return __request(OpenAPI, {
- method: 'GET',
- url: '/profiles/{profile_id}',
- path: {
- profile_id: profileId,
- },
- errors: {
- 422: `Validation Error`,
- },
- });
- }
- /**
- * Update Profile
- * Update a voice profile.
- * @returns VoiceProfileResponse Successful Response
- * @throws ApiError
- */
- public static updateProfileProfilesProfileIdPut({
- profileId,
- requestBody,
- }: {
- profileId: string;
- requestBody: VoiceProfileCreate;
- }): CancelablePromise {
- return __request(OpenAPI, {
- method: 'PUT',
- url: '/profiles/{profile_id}',
- path: {
- profile_id: profileId,
- },
- body: requestBody,
- mediaType: 'application/json',
- errors: {
- 422: `Validation Error`,
- },
- });
- }
- /**
- * Delete Profile
- * Delete a voice profile.
- * @returns any Successful Response
- * @throws ApiError
- */
- public static deleteProfileProfilesProfileIdDelete({
- profileId,
- }: {
- profileId: string;
- }): CancelablePromise {
- return __request(OpenAPI, {
- method: 'DELETE',
- url: '/profiles/{profile_id}',
- path: {
- profile_id: profileId,
- },
- errors: {
- 422: `Validation Error`,
- },
- });
- }
- /**
- * Add Profile Sample
- * Add a sample to a voice profile.
- * @returns ProfileSampleResponse Successful Response
- * @throws ApiError
- */
- public static addProfileSampleProfilesProfileIdSamplesPost({
- profileId,
- formData,
- }: {
- profileId: string;
- formData: Body_add_profile_sample_profiles__profile_id__samples_post;
- }): CancelablePromise {
- return __request(OpenAPI, {
- method: 'POST',
- url: '/profiles/{profile_id}/samples',
- path: {
- profile_id: profileId,
- },
- formData: formData,
- mediaType: 'multipart/form-data',
- errors: {
- 422: `Validation Error`,
- },
- });
- }
- /**
- * Get Profile Samples
- * Get all samples for a profile.
- * @returns ProfileSampleResponse Successful Response
- * @throws ApiError
- */
- public static getProfileSamplesProfilesProfileIdSamplesGet({
- profileId,
- }: {
- profileId: string;
- }): CancelablePromise> {
- return __request(OpenAPI, {
- method: 'GET',
- url: '/profiles/{profile_id}/samples',
- path: {
- profile_id: profileId,
- },
- errors: {
- 422: `Validation Error`,
- },
- });
- }
- /**
- * Delete Profile Sample
- * Delete a profile sample.
- * @returns any Successful Response
- * @throws ApiError
- */
- public static deleteProfileSampleProfilesSamplesSampleIdDelete({
- sampleId,
- }: {
- sampleId: string;
- }): CancelablePromise {
- return __request(OpenAPI, {
- method: 'DELETE',
- url: '/profiles/samples/{sample_id}',
- path: {
- sample_id: sampleId,
- },
- errors: {
- 422: `Validation Error`,
- },
- });
- }
- /**
- * Generate Speech
- * Generate speech from text using a voice profile.
- * @returns GenerationResponse Successful Response
- * @throws ApiError
- */
- public static generateSpeechGeneratePost({
- requestBody,
- }: {
- requestBody: GenerationRequest;
- }): CancelablePromise {
- return __request(OpenAPI, {
- method: 'POST',
- url: '/generate',
- body: requestBody,
- mediaType: 'application/json',
- errors: {
- 422: `Validation Error`,
- },
- });
- }
- /**
- * List History
- * List generation history with optional filters.
- * @returns HistoryListResponse Successful Response
- * @throws ApiError
- */
- public static listHistoryHistoryGet({
- profileId,
- search,
- limit = 50,
- offset,
- }: {
- profileId?: string | null;
- search?: string | null;
- limit?: number;
- offset?: number;
- }): CancelablePromise {
- return __request(OpenAPI, {
- method: 'GET',
- url: '/history',
- query: {
- profile_id: profileId,
- search: search,
- limit: limit,
- offset: offset,
- },
- errors: {
- 422: `Validation Error`,
- },
- });
- }
- /**
- * Get Generation
- * Get a generation by ID.
- * @returns HistoryResponse Successful Response
- * @throws ApiError
- */
- public static getGenerationHistoryGenerationIdGet({
- generationId,
- }: {
- generationId: string;
- }): CancelablePromise {
- return __request(OpenAPI, {
- method: 'GET',
- url: '/history/{generation_id}',
- path: {
- generation_id: generationId,
- },
- errors: {
- 422: `Validation Error`,
- },
- });
- }
- /**
- * Delete Generation
- * Delete a generation.
- * @returns any Successful Response
- * @throws ApiError
- */
- public static deleteGenerationHistoryGenerationIdDelete({
- generationId,
- }: {
- generationId: string;
- }): CancelablePromise {
- return __request(OpenAPI, {
- method: 'DELETE',
- url: '/history/{generation_id}',
- path: {
- generation_id: generationId,
- },
- errors: {
- 422: `Validation Error`,
- },
- });
- }
- /**
- * Get Stats
- * Get generation statistics.
- * @returns any Successful Response
- * @throws ApiError
- */
- public static getStatsHistoryStatsGet(): CancelablePromise {
- return __request(OpenAPI, {
- method: 'GET',
- url: '/history/stats',
- });
- }
- /**
- * Transcribe Audio
- * Transcribe audio file to text.
- * @returns TranscriptionResponse Successful Response
- * @throws ApiError
- */
- public static transcribeAudioTranscribePost({
- formData,
- }: {
- formData: Body_transcribe_audio_transcribe_post;
- }): CancelablePromise {
- return __request(OpenAPI, {
- method: 'POST',
- url: '/transcribe',
- formData: formData,
- mediaType: 'multipart/form-data',
- errors: {
- 422: `Validation Error`,
- },
- });
- }
- /**
- * Get Audio
- * Serve generated audio file.
- * @returns any Successful Response
- * @throws ApiError
- */
- public static getAudioAudioGenerationIdGet({
- generationId,
- }: {
- generationId: string;
- }): CancelablePromise {
- return __request(OpenAPI, {
- method: 'GET',
- url: '/audio/{generation_id}',
- path: {
- generation_id: generationId,
- },
- errors: {
- 422: `Validation Error`,
- },
- });
- }
- /**
- * Load Model
- * Manually load TTS model.
- * @returns any Successful Response
- * @throws ApiError
- */
- public static loadModelModelsLoadPost({
- modelSize = '1.7B',
- }: {
- modelSize?: string;
- }): CancelablePromise {
- return __request(OpenAPI, {
- method: 'POST',
- url: '/models/load',
- query: {
- model_size: modelSize,
- },
- errors: {
- 422: `Validation Error`,
- },
- });
- }
- /**
- * Unload Model
- * Unload TTS model to free memory.
- * @returns any Successful Response
- * @throws ApiError
- */
- public static unloadModelModelsUnloadPost(): CancelablePromise {
- return __request(OpenAPI, {
- method: 'POST',
- url: '/models/unload',
- });
- }
- /**
- * Get Model Progress
- * Get model download progress via Server-Sent Events.
- * @returns any Successful Response
- * @throws ApiError
- */
- public static getModelProgressModelsProgressModelNameGet({
- modelName,
- }: {
- modelName: string;
- }): CancelablePromise {
- return __request(OpenAPI, {
- method: 'GET',
- url: '/models/progress/{model_name}',
- path: {
- model_name: modelName,
- },
- errors: {
- 422: `Validation Error`,
- },
- });
- }
- /**
- * Get Model Status
- * Get status of all available models.
- * @returns ModelStatusListResponse Successful Response
- * @throws ApiError
- */
- public static getModelStatusModelsStatusGet(): CancelablePromise {
- return __request(OpenAPI, {
- method: 'GET',
- url: '/models/status',
- });
- }
- /**
- * Trigger Model Download
- * Trigger download of a specific model.
- * @returns any Successful Response
- * @throws ApiError
- */
- public static triggerModelDownloadModelsDownloadPost({
- requestBody,
- }: {
- requestBody: ModelDownloadRequest;
- }): CancelablePromise {
- return __request(OpenAPI, {
- method: 'POST',
- url: '/models/download',
- body: requestBody,
- mediaType: 'application/json',
- errors: {
- 422: `Validation Error`,
- },
- });
- }
-}
diff --git a/app/src/main.tsx b/app/src/main.tsx
deleted file mode 100644
index 52dc90445..000000000
--- a/app/src/main.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import { QueryClientProvider } from '@tanstack/react-query';
-// import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
-import React from 'react';
-import ReactDOM from 'react-dom/client';
-import App from './App';
-import './i18n';
-import './index.css';
-import { queryClient } from './lib/queryClient';
-
-ReactDOM.createRoot(document.getElementById('root')!).render(
-
-
-
- {/* */}
-
- ,
-);
diff --git a/app/src/platform/types.ts b/app/src/platform/types.ts
index 2e11af9a1..ef1203587 100644
--- a/app/src/platform/types.ts
+++ b/app/src/platform/types.ts
@@ -9,7 +9,8 @@ export interface FileFilter {
}
export interface PlatformFilesystem {
- saveFile(filename: string, blob: Blob, filters?: FileFilter[]): Promise;
+ /** Returns the saved path (or filename on web), or null if the user cancelled. */
+ saveFile(filename: string, blob: Blob, filters?: FileFilter[]): Promise;
openPath(path: string): Promise;
pickDirectory(title: string): Promise;
}
diff --git a/app/src/router.tsx b/app/src/router.tsx
index 940e7c525..13ba45d5f 100644
--- a/app/src/router.tsx
+++ b/app/src/router.tsx
@@ -113,7 +113,7 @@ const voicesRoute = createRoute({
component: VoicesTab,
});
-// Captures route (prototype — will replace AudioTab once the new flow is ready)
+// Captures route
const capturesRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/captures',
@@ -199,8 +199,8 @@ const serverRedirectRoute = createRoute({
},
});
-// Route tree
-const routeTree = rootRoute.addChildren([
+// Route tree — exported so tests can build routers over memory history
+export const routeTree = rootRoute.addChildren([
indexRoute,
storiesRoute,
capturesRoute,
diff --git a/app/src/stores/serverStore.test.ts b/app/src/stores/serverStore.test.ts
new file mode 100644
index 000000000..78a2f70d4
--- /dev/null
+++ b/app/src/stores/serverStore.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it, vi } from 'vitest';
+import { queryClient } from '@/lib/queryClient';
+import { isLoopbackVoiceboxServerUrl, useServerStore } from '@/stores/serverStore';
+
+describe('serverStore', () => {
+ it('invalidates all queries when the server url changes', () => {
+ const spy = vi.spyOn(queryClient, 'invalidateQueries');
+
+ useServerStore.getState().setServerUrl('http://10.0.0.5:17493');
+
+ expect(useServerStore.getState().serverUrl).toBe('http://10.0.0.5:17493');
+ expect(spy).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not invalidate queries when the url is unchanged', () => {
+ const url = useServerStore.getState().serverUrl;
+ const spy = vi.spyOn(queryClient, 'invalidateQueries');
+
+ useServerStore.getState().setServerUrl(url);
+
+ expect(spy).not.toHaveBeenCalled();
+ });
+});
+
+describe('isLoopbackVoiceboxServerUrl', () => {
+ it('matches loopback hosts on the voicebox port', () => {
+ expect(isLoopbackVoiceboxServerUrl('http://127.0.0.1:17493')).toBe(true);
+ expect(isLoopbackVoiceboxServerUrl('http://localhost:17493')).toBe(true);
+ expect(isLoopbackVoiceboxServerUrl('http://[::1]:17493')).toBe(true);
+ });
+
+ it('rejects other hosts, ports, and junk', () => {
+ expect(isLoopbackVoiceboxServerUrl('http://10.0.0.5:17493')).toBe(false);
+ expect(isLoopbackVoiceboxServerUrl('http://127.0.0.1:8000')).toBe(false);
+ expect(isLoopbackVoiceboxServerUrl('not a url')).toBe(false);
+ });
+});
diff --git a/app/src/stores/uiStore.test.ts b/app/src/stores/uiStore.test.ts
new file mode 100644
index 000000000..d93eccbc8
--- /dev/null
+++ b/app/src/stores/uiStore.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from 'vitest';
+import { useUIStore } from '@/stores/uiStore';
+
+describe('uiStore', () => {
+ it('applies the dark class when theme is set to dark', () => {
+ useUIStore.getState().setTheme('dark');
+
+ expect(useUIStore.getState().theme).toBe('dark');
+ expect(document.documentElement.classList.contains('dark')).toBe(true);
+ });
+
+ it('removes the dark class when theme is set to light', () => {
+ useUIStore.getState().setTheme('dark');
+ useUIStore.getState().setTheme('light');
+
+ expect(document.documentElement.classList.contains('dark')).toBe(false);
+ });
+
+ it('persists only theme and selectedProfileId', () => {
+ useUIStore.getState().setTheme('light');
+ useUIStore.getState().setSidebarOpen(false);
+ useUIStore.getState().setSelectedEngine('kokoro');
+
+ const persisted = JSON.parse(localStorage.getItem('voicebox-ui') ?? '{}');
+ expect(persisted.state).toEqual({ selectedProfileId: null, theme: 'light' });
+ });
+});
diff --git a/app/src/test/harness.browser.test.tsx b/app/src/test/harness.browser.test.tsx
new file mode 100644
index 000000000..77af25e1e
--- /dev/null
+++ b/app/src/test/harness.browser.test.tsx
@@ -0,0 +1,58 @@
+import { http } from 'msw';
+import { expect, it } from 'vitest';
+import { buildModelStatus, buildProfile } from './msw/fixtures';
+import {
+ captureHandlers,
+ effectsHandlers,
+ historyHandlers,
+ modelHandlers,
+ profileHandlers,
+ settingsHandlers,
+ storyHandlers,
+ taskHandlers,
+} from './msw/handlers';
+import { worker } from './msw/worker';
+import { renderRoute } from './render';
+import { sseController } from './sse';
+
+function useHappyPathHandlers() {
+ worker.use(
+ ...profileHandlers([buildProfile({ name: 'Ada Lovelace' })]),
+ ...historyHandlers([]),
+ ...captureHandlers([]),
+ ...settingsHandlers(),
+ ...modelHandlers([buildModelStatus()]),
+ ...storyHandlers([]),
+ ...effectsHandlers([]),
+ ...taskHandlers(),
+ );
+}
+
+it('renders the /voices route with the full app chrome', async () => {
+ useHappyPathHandlers();
+
+ const screen = await renderRoute('/voices');
+
+ await expect.element(screen.getByText('Ada Lovelace')).toBeVisible();
+});
+
+it('feeds EventSource through the SSE controller', async () => {
+ const sse = sseController();
+ worker.use(http.get('*/generate/:id/status', () => sse.response()));
+
+ const source = new EventSource('/generate/gen-1/status');
+ const statuses: string[] = [];
+ source.onmessage = (message) => {
+ statuses.push((JSON.parse(message.data) as { status: string }).status);
+ };
+ await new Promise((resolve) => {
+ source.onopen = resolve;
+ });
+
+ sse.push({ data: { status: 'generating' } });
+ sse.push({ data: { status: 'completed' } });
+
+ await expect.poll(() => statuses).toEqual(['generating', 'completed']);
+ source.close();
+ sse.close();
+});
diff --git a/app/src/test/mockPlatform.ts b/app/src/test/mockPlatform.ts
new file mode 100644
index 000000000..10ec79a3a
--- /dev/null
+++ b/app/src/test/mockPlatform.ts
@@ -0,0 +1,86 @@
+import { vi } from 'vitest';
+import type { Platform, UpdateStatus } from '@/platform/types';
+
+export interface MockPlatform extends Platform {
+ /** Push a new updater status to all subscribers, as the real updater would. */
+ emitUpdateStatus(status: UpdateStatus): void;
+}
+
+export interface MockPlatformOverrides {
+ filesystem?: Partial;
+ updater?: Partial;
+ audio?: Partial;
+ lifecycle?: Partial;
+ metadata?: Partial;
+}
+
+const INITIAL_UPDATE_STATUS: UpdateStatus = {
+ checking: false,
+ available: false,
+ downloading: false,
+ installing: false,
+ readyToInstall: false,
+};
+
+export const TEST_SERVER_URL = 'http://127.0.0.1:17493';
+
+/**
+ * A fully spy-able Platform. Every method is a vi.fn with a benign default
+ * (browser-like: no system audio, isTauri false), so tests can assert calls
+ * or override behavior per section via `overrides`.
+ */
+export function createMockPlatform(overrides: MockPlatformOverrides = {}): MockPlatform {
+ let updateStatus = { ...INITIAL_UPDATE_STATUS };
+ const subscribers = new Set<(status: UpdateStatus) => void>();
+
+ return {
+ filesystem: {
+ saveFile: vi.fn(async (filename: string) => filename),
+ openPath: vi.fn(async () => {}),
+ pickDirectory: vi.fn(async () => null),
+ ...overrides.filesystem,
+ },
+ updater: {
+ checkForUpdates: vi.fn(async () => {}),
+ downloadAndInstall: vi.fn(async () => {}),
+ restartAndInstall: vi.fn(async () => {}),
+ getStatus: vi.fn(() => ({ ...updateStatus })),
+ subscribe: vi.fn((callback: (status: UpdateStatus) => void) => {
+ subscribers.add(callback);
+ callback(updateStatus);
+ return () => {
+ subscribers.delete(callback);
+ };
+ }),
+ ...overrides.updater,
+ },
+ audio: {
+ isSystemAudioSupported: vi.fn(async () => false),
+ startSystemAudioCapture: vi.fn(async () => {}),
+ stopSystemAudioCapture: vi.fn(async () => new Blob()),
+ listOutputDevices: vi.fn(async () => []),
+ playToDevices: vi.fn(async () => {}),
+ stopPlayback: vi.fn(),
+ ...overrides.audio,
+ },
+ lifecycle: {
+ startServer: vi.fn(async () => TEST_SERVER_URL),
+ stopServer: vi.fn(async () => {}),
+ restartServer: vi.fn(async () => TEST_SERVER_URL),
+ setKeepServerRunning: vi.fn(async () => {}),
+ setBackendOverride: vi.fn(async () => {}),
+ setupWindowCloseHandler: vi.fn(async () => {}),
+ subscribeToServerLogs: vi.fn(() => () => {}),
+ ...overrides.lifecycle,
+ },
+ metadata: {
+ getVersion: vi.fn(async () => '0.0.0-test'),
+ isTauri: false,
+ ...overrides.metadata,
+ },
+ emitUpdateStatus(status: UpdateStatus) {
+ updateStatus = { ...status };
+ for (const callback of subscribers) callback(updateStatus);
+ },
+ };
+}
diff --git a/app/src/test/msw/fixtures.ts b/app/src/test/msw/fixtures.ts
new file mode 100644
index 000000000..36bb15be1
--- /dev/null
+++ b/app/src/test/msw/fixtures.ts
@@ -0,0 +1,216 @@
+import type {
+ CaptureListResponse,
+ CaptureReadinessResponse,
+ CaptureResponse,
+ CaptureSettings,
+ EffectPresetResponse,
+ GenerationResponse,
+ GenerationSettings,
+ HealthResponse,
+ HistoryListResponse,
+ HistoryResponse,
+ ModelStatus,
+ StoryDetailResponse,
+ StoryItemDetail,
+ StoryResponse,
+ VoiceProfileResponse,
+} from '@/lib/api/types';
+
+// Deterministic id counter — no randomness so failures reproduce exactly.
+let seq = 0;
+export function nextId(prefix: string): string {
+ seq += 1;
+ return `${prefix}-${String(seq).padStart(4, '0')}`;
+}
+
+const CREATED_AT = '2026-01-01T00:00:00Z';
+
+export function buildProfile(overrides: Partial = {}): VoiceProfileResponse {
+ return {
+ id: nextId('profile'),
+ name: 'Test Voice',
+ language: 'en',
+ voice_type: 'cloned',
+ generation_count: 0,
+ sample_count: 1,
+ created_at: CREATED_AT,
+ updated_at: CREATED_AT,
+ ...overrides,
+ };
+}
+
+export function buildGeneration(overrides: Partial = {}): GenerationResponse {
+ return {
+ id: nextId('gen'),
+ profile_id: 'profile-0001',
+ text: 'Hello from the test suite.',
+ language: 'en',
+ status: 'completed',
+ audio_path: '/audio/fake.wav',
+ duration: 1.5,
+ created_at: CREATED_AT,
+ ...overrides,
+ };
+}
+
+export function buildHistoryItem(overrides: Partial = {}): HistoryResponse {
+ return {
+ ...buildGeneration(),
+ profile_name: 'Test Voice',
+ ...overrides,
+ };
+}
+
+export function buildHistoryList(items: HistoryResponse[]): HistoryListResponse {
+ return { items, total: items.length };
+}
+
+export function buildCapture(overrides: Partial = {}): CaptureResponse {
+ return {
+ id: nextId('capture'),
+ audio_path: '/captures/fake.wav',
+ source: 'dictation',
+ language: 'en',
+ duration_ms: 2400,
+ transcript_raw: 'raw transcript text',
+ transcript_refined: 'Refined transcript text.',
+ created_at: CREATED_AT,
+ ...overrides,
+ };
+}
+
+export function buildCaptureList(items: CaptureResponse[]): CaptureListResponse {
+ return { items, total: items.length };
+}
+
+export function buildCaptureSettings(overrides: Partial = {}): CaptureSettings {
+ return {
+ stt_model: 'turbo',
+ language: 'en',
+ auto_refine: true,
+ llm_model: '0.6B',
+ smart_cleanup: true,
+ self_correction: true,
+ preserve_technical: true,
+ allow_auto_paste: false,
+ default_playback_voice_id: null,
+ hotkey_enabled: false,
+ keep_mic_warm: false,
+ chord_push_to_talk_keys: [],
+ chord_toggle_to_talk_keys: [],
+ ...overrides,
+ };
+}
+
+export function buildCaptureReadiness(
+ overrides: Partial = {},
+): CaptureReadinessResponse {
+ return {
+ stt: {
+ ready: true,
+ model_name: 'whisper-turbo',
+ display_name: 'Whisper Turbo',
+ size: '1.6 GB',
+ },
+ llm: {
+ ready: true,
+ model_name: 'qwen3-0.6b',
+ display_name: 'Qwen3 0.6B',
+ size: '600 MB',
+ },
+ ...overrides,
+ };
+}
+
+export function buildGenerationSettings(
+ overrides: Partial = {},
+): GenerationSettings {
+ return {
+ max_chunk_chars: 400,
+ crossfade_ms: 60,
+ normalize_audio: true,
+ autoplay_on_generate: false,
+ ...overrides,
+ };
+}
+
+export function buildModelStatus(overrides: Partial = {}): ModelStatus {
+ return {
+ model_name: 'qwen-tts-1.7b',
+ display_name: 'Qwen TTS 1.7B',
+ downloaded: true,
+ downloading: false,
+ loaded: false,
+ size_mb: 3400,
+ ...overrides,
+ };
+}
+
+export function buildStory(overrides: Partial = {}): StoryResponse {
+ return {
+ id: nextId('story'),
+ name: 'Test Story',
+ created_at: CREATED_AT,
+ updated_at: CREATED_AT,
+ item_count: 0,
+ ...overrides,
+ };
+}
+
+export function buildStoryItem(overrides: Partial = {}): StoryItemDetail {
+ return {
+ id: nextId('story-item'),
+ story_id: 'story-0001',
+ generation_id: 'gen-0001',
+ start_time_ms: 0,
+ track: 0,
+ trim_start_ms: 0,
+ trim_end_ms: 0,
+ created_at: CREATED_AT,
+ profile_id: 'profile-0001',
+ profile_name: 'Test Voice',
+ text: 'Hello from the test suite.',
+ language: 'en',
+ audio_path: '/audio/fake.wav',
+ duration: 1.5,
+ volume: 1,
+ generation_created_at: CREATED_AT,
+ ...overrides,
+ };
+}
+
+export function buildStoryDetail(
+ overrides: Partial = {},
+): StoryDetailResponse {
+ return {
+ id: 'story-0001',
+ name: 'Test Story',
+ created_at: CREATED_AT,
+ updated_at: CREATED_AT,
+ items: [],
+ ...overrides,
+ };
+}
+
+export function buildEffectPreset(
+ overrides: Partial = {},
+): EffectPresetResponse {
+ return {
+ id: nextId('preset'),
+ name: 'Test Preset',
+ effects_chain: [{ type: 'reverb', enabled: true, params: { wet: 0.3 } }],
+ is_builtin: false,
+ created_at: CREATED_AT,
+ ...overrides,
+ };
+}
+
+export function buildHealth(overrides: Partial = {}): HealthResponse {
+ return {
+ status: 'ok',
+ model_loaded: false,
+ gpu_available: false,
+ backend_variant: 'cpu',
+ ...overrides,
+ };
+}
diff --git a/app/src/test/msw/handlers/index.ts b/app/src/test/msw/handlers/index.ts
new file mode 100644
index 000000000..794833843
--- /dev/null
+++ b/app/src/test/msw/handlers/index.ts
@@ -0,0 +1,102 @@
+import type { HttpHandler } from 'msw';
+import { HttpResponse, http } from 'msw';
+import type {
+ CaptureResponse,
+ CaptureSettings,
+ EffectPresetResponse,
+ GenerationSettings,
+ HistoryResponse,
+ ModelStatus,
+ StoryDetailResponse,
+ StoryResponse,
+ VoiceProfileResponse,
+} from '@/lib/api/types';
+import { buildCaptureReadiness, buildCaptureSettings, buildGenerationSettings } from '../fixtures';
+
+/**
+ * Happy-path handlers for one domain each. Tests compose what they need:
+ * worker.use(...profileHandlers([buildProfile()]), ...historyHandlers([]))
+ * Anything not stubbed fails loudly via onUnhandledRequest: 'error'.
+ */
+
+export function profileHandlers(profiles: VoiceProfileResponse[]): HttpHandler[] {
+ return [
+ http.get('*/profiles', () => HttpResponse.json(profiles)),
+ http.get('*/profiles/presets/:engine', () => HttpResponse.json([])),
+ http.get('*/profiles/:id', ({ params }) => {
+ const profile = profiles.find((p) => p.id === params.id);
+ return profile ? HttpResponse.json(profile) : new HttpResponse(null, { status: 404 });
+ }),
+ http.get('*/profiles/:id/channels', () => HttpResponse.json([])),
+ http.get('*/profiles/:id/samples', () => HttpResponse.json([])),
+ http.get('*/channels', () => HttpResponse.json([])),
+ ];
+}
+
+export function historyHandlers(items: HistoryResponse[]): HttpHandler[] {
+ return [
+ http.get('*/history', () => HttpResponse.json({ items, total: items.length })),
+ http.get('*/history/:id', ({ params }) => {
+ const item = items.find((i) => i.id === params.id);
+ return item ? HttpResponse.json(item) : new HttpResponse(null, { status: 404 });
+ }),
+ ];
+}
+
+export function captureHandlers(
+ items: CaptureResponse[],
+ settings: CaptureSettings = buildCaptureSettings(),
+): HttpHandler[] {
+ return [
+ http.get('*/captures', () => HttpResponse.json({ items, total: items.length })),
+ http.get('*/capture/readiness', () => HttpResponse.json(buildCaptureReadiness())),
+ http.get('*/settings/captures', () => HttpResponse.json(settings)),
+ http.put('*/settings/captures', async ({ request }) => {
+ const update = (await request.json()) as Partial;
+ return HttpResponse.json({ ...settings, ...update });
+ }),
+ ];
+}
+
+export function settingsHandlers(
+ generation: GenerationSettings = buildGenerationSettings(),
+): HttpHandler[] {
+ return [
+ http.get('*/settings/generation', () => HttpResponse.json(generation)),
+ http.put('*/settings/generation', async ({ request }) => {
+ const update = (await request.json()) as Partial;
+ return HttpResponse.json({ ...generation, ...update });
+ }),
+ ];
+}
+
+export function modelHandlers(models: ModelStatus[]): HttpHandler[] {
+ return [
+ http.get('*/models/status', () => HttpResponse.json({ models })),
+ http.get('*/models/cache-dir', () => HttpResponse.json({ cache_dir: '/tmp/models' })),
+ ];
+}
+
+export function storyHandlers(
+ stories: StoryResponse[],
+ details: StoryDetailResponse[] = [],
+): HttpHandler[] {
+ return [
+ http.get('*/stories', () => HttpResponse.json(stories)),
+ http.get('*/stories/:id', ({ params }) => {
+ const detail = details.find((d) => d.id === params.id);
+ return detail ? HttpResponse.json(detail) : new HttpResponse(null, { status: 404 });
+ }),
+ ];
+}
+
+export function effectsHandlers(presets: EffectPresetResponse[]): HttpHandler[] {
+ return [
+ http.get('*/effects/available', () => HttpResponse.json({ effects: [] })),
+ http.get('*/effects/presets', () => HttpResponse.json(presets)),
+ ];
+}
+
+export function taskHandlers(): HttpHandler[] {
+ return [http.get('*/tasks/active', () => HttpResponse.json({ downloads: [], generations: [] }))];
+}
diff --git a/app/src/test/msw/handlers/server.ts b/app/src/test/msw/handlers/server.ts
new file mode 100644
index 000000000..0b35fd60e
--- /dev/null
+++ b/app/src/test/msw/handlers/server.ts
@@ -0,0 +1,17 @@
+import { type HttpHandler, HttpResponse, http } from 'msw';
+
+/**
+ * Baseline handlers for endpoints nearly every screen touches. The health
+ * payload mirrors backend/routes/health.py closely enough for the UI's
+ * checks (`status`, `model_loaded`, backend variant fields).
+ */
+export const serverHandlers: HttpHandler[] = [
+ http.get('*/health', () =>
+ HttpResponse.json({
+ status: 'ok',
+ model_loaded: false,
+ device: 'cpu',
+ backend_variant: 'cpu',
+ }),
+ ),
+];
diff --git a/app/src/test/msw/worker.ts b/app/src/test/msw/worker.ts
new file mode 100644
index 000000000..b99217003
--- /dev/null
+++ b/app/src/test/msw/worker.ts
@@ -0,0 +1,9 @@
+import { setupWorker } from 'msw/browser';
+import { serverHandlers } from './handlers/server';
+
+/**
+ * Browser-mode MSW worker. Individual tests layer route-specific handlers
+ * on top with `worker.use(...)`; `setup.browser.ts` resets them after each
+ * test. Only the health/baseline handlers are registered globally.
+ */
+export const worker = setupWorker(...serverHandlers);
diff --git a/app/src/test/public/mockServiceWorker.js b/app/src/test/public/mockServiceWorker.js
new file mode 100644
index 000000000..c646a2362
--- /dev/null
+++ b/app/src/test/public/mockServiceWorker.js
@@ -0,0 +1,346 @@
+/* eslint-disable */
+/* tslint:disable */
+
+/**
+ * Mock Service Worker.
+ * @see https://github.com/mswjs/msw
+ * - Please do NOT modify this file.
+ */
+
+const PACKAGE_VERSION = '2.15.0';
+const INTEGRITY_CHECKSUM = '03cb67ac84128e63d7cd722a6e5b7f1e';
+const IS_MOCKED_RESPONSE = Symbol('isMockedResponse');
+const activeClientIds = new Set();
+
+addEventListener('install', () => {
+ self.skipWaiting();
+});
+
+addEventListener('activate', (event) => {
+ event.waitUntil(self.clients.claim());
+});
+
+addEventListener('message', async (event) => {
+ const clientId = Reflect.get(event.source || {}, 'id');
+
+ if (!clientId || !self.clients) {
+ return;
+ }
+
+ const client = await self.clients.get(clientId);
+
+ if (!client) {
+ return;
+ }
+
+ const allClients = await self.clients.matchAll({
+ type: 'window',
+ });
+
+ switch (event.data) {
+ case 'KEEPALIVE_REQUEST': {
+ sendToClient(client, {
+ type: 'KEEPALIVE_RESPONSE',
+ });
+ break;
+ }
+
+ case 'INTEGRITY_CHECK_REQUEST': {
+ sendToClient(client, {
+ type: 'INTEGRITY_CHECK_RESPONSE',
+ payload: {
+ packageVersion: PACKAGE_VERSION,
+ checksum: INTEGRITY_CHECKSUM,
+ },
+ });
+ break;
+ }
+
+ case 'MOCK_ACTIVATE': {
+ activeClientIds.add(clientId);
+
+ sendToClient(client, {
+ type: 'MOCKING_ENABLED',
+ payload: {
+ client: {
+ id: client.id,
+ frameType: client.frameType,
+ },
+ },
+ });
+ break;
+ }
+
+ case 'CLIENT_CLOSED': {
+ activeClientIds.delete(clientId);
+
+ const remainingClients = allClients.filter((client) => {
+ return client.id !== clientId;
+ });
+
+ // Unregister itself when there are no more clients
+ if (remainingClients.length === 0) {
+ self.registration.unregister();
+ }
+
+ break;
+ }
+ }
+});
+
+addEventListener('fetch', (event) => {
+ const requestInterceptedAt = Date.now();
+
+ // Bypass navigation requests.
+ if (event.request.mode === 'navigate') {
+ return;
+ }
+
+ // Opening the DevTools triggers the "only-if-cached" request
+ // that cannot be handled by the worker. Bypass such requests.
+ if (event.request.cache === 'only-if-cached' && event.request.mode !== 'same-origin') {
+ return;
+ }
+
+ // Bypass all requests when there are no active clients.
+ // Prevents the self-unregistered worked from handling requests
+ // after it's been terminated (still remains active until the next reload).
+ if (activeClientIds.size === 0) {
+ return;
+ }
+
+ const requestId = crypto.randomUUID();
+ event.respondWith(handleRequest(event, requestId, requestInterceptedAt));
+});
+
+/**
+ * @param {FetchEvent} event
+ * @param {string} requestId
+ * @param {number} requestInterceptedAt
+ */
+async function handleRequest(event, requestId, requestInterceptedAt) {
+ const client = await resolveMainClient(event);
+ const requestCloneForEvents = event.request.clone();
+ const response = await getResponse(event, client, requestId, requestInterceptedAt);
+
+ // Send back the response clone for the "response:*" life-cycle events.
+ // Ensure MSW is active and ready to handle the message, otherwise
+ // this message will pend indefinitely.
+ if (client && activeClientIds.has(client.id)) {
+ const serializedRequest = await serializeRequest(requestCloneForEvents);
+
+ // Omit the body of server-sent event stream responses.
+ // Cloning such responses would prevent client-side stream cancelations
+ // from reaching the original stream (a teed stream only cancels its
+ // source once both of its branches cancel) and would buffer the
+ // entire stream into the unconsumed clone indefinitely.
+ const isEventStreamResponse = response.headers
+ .get('content-type')
+ ?.toLowerCase()
+ .startsWith('text/event-stream');
+
+ // Clone the response so both the client and the library could consume it.
+ const responseClone = isEventStreamResponse ? null : response.clone();
+
+ sendToClient(
+ client,
+ {
+ type: 'RESPONSE',
+ payload: {
+ isMockedResponse: IS_MOCKED_RESPONSE in response,
+ request: {
+ id: requestId,
+ ...serializedRequest,
+ },
+ response: {
+ type: response.type,
+ status: response.status,
+ statusText: response.statusText,
+ headers: Object.fromEntries(response.headers.entries()),
+ body: responseClone ? responseClone.body : null,
+ },
+ },
+ },
+ responseClone && responseClone.body ? [serializedRequest.body, responseClone.body] : [],
+ );
+ }
+
+ return response;
+}
+
+/**
+ * Resolve the main client for the given event.
+ * Client that issues a request doesn't necessarily equal the client
+ * that registered the worker. It's with the latter the worker should
+ * communicate with during the response resolving phase.
+ * @param {FetchEvent} event
+ * @returns {Promise}
+ */
+async function resolveMainClient(event) {
+ const client = await self.clients.get(event.clientId);
+
+ if (activeClientIds.has(event.clientId)) {
+ return client;
+ }
+
+ if (client?.frameType === 'top-level') {
+ return client;
+ }
+
+ const allClients = await self.clients.matchAll({
+ type: 'window',
+ });
+
+ return allClients
+ .filter((client) => {
+ // Get only those clients that are currently visible.
+ return client.visibilityState === 'visible';
+ })
+ .find((client) => {
+ // Find the client ID that's recorded in the
+ // set of clients that have registered the worker.
+ return activeClientIds.has(client.id);
+ });
+}
+
+/**
+ * @param {FetchEvent} event
+ * @param {Client | undefined} client
+ * @param {string} requestId
+ * @param {number} requestInterceptedAt
+ * @returns {Promise}
+ */
+async function getResponse(event, client, requestId, requestInterceptedAt) {
+ // Clone the request because it might've been already used
+ // (i.e. its body has been read and sent to the client).
+ const requestClone = event.request.clone();
+
+ function passthrough() {
+ // Cast the request headers to a new Headers instance
+ // so the headers can be manipulated with.
+ const headers = new Headers(requestClone.headers);
+
+ // Remove the "accept" header value that marked this request as passthrough.
+ // This prevents request alteration and also keeps it compliant with the
+ // user-defined CORS policies.
+ const acceptHeader = headers.get('accept');
+ if (acceptHeader) {
+ const values = acceptHeader.split(',').map((value) => value.trim());
+ const filteredValues = values.filter((value) => value !== 'msw/passthrough');
+
+ if (filteredValues.length > 0) {
+ headers.set('accept', filteredValues.join(', '));
+ } else {
+ headers.delete('accept');
+ }
+ }
+
+ return fetch(requestClone, { headers });
+ }
+
+ // Bypass mocking when the client is not active.
+ if (!client) {
+ return passthrough();
+ }
+
+ // Bypass initial page load requests (i.e. static assets).
+ // The absence of the immediate/parent client in the map of the active clients
+ // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
+ // and is not ready to handle requests.
+ if (!activeClientIds.has(client.id)) {
+ return passthrough();
+ }
+
+ // Notify the client that a request has been intercepted.
+ const serializedRequest = await serializeRequest(event.request);
+ const clientMessage = await sendToClient(
+ client,
+ {
+ type: 'REQUEST',
+ payload: {
+ id: requestId,
+ interceptedAt: requestInterceptedAt,
+ ...serializedRequest,
+ },
+ },
+ [serializedRequest.body],
+ );
+
+ switch (clientMessage.type) {
+ case 'MOCK_RESPONSE': {
+ return respondWithMock(clientMessage.data);
+ }
+
+ case 'PASSTHROUGH': {
+ return passthrough();
+ }
+ }
+
+ return passthrough();
+}
+
+/**
+ * @param {Client} client
+ * @param {any} message
+ * @param {Array} transferrables
+ * @returns {Promise}
+ */
+function sendToClient(client, message, transferrables = []) {
+ return new Promise((resolve, reject) => {
+ const channel = new MessageChannel();
+
+ channel.port1.onmessage = (event) => {
+ if (event.data && event.data.error) {
+ return reject(event.data.error);
+ }
+
+ resolve(event.data);
+ };
+
+ client.postMessage(message, [channel.port2, ...transferrables.filter(Boolean)]);
+ });
+}
+
+/**
+ * @param {Response} response
+ * @returns {Response}
+ */
+function respondWithMock(response) {
+ // Setting response status code to 0 is a no-op.
+ // However, when responding with a "Response.error()", the produced Response
+ // instance will have status code set to 0. Since it's not possible to create
+ // a Response instance with status code 0, handle that use-case separately.
+ if (response.status === 0) {
+ return Response.error();
+ }
+
+ const mockedResponse = new Response(response.body, response);
+
+ Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
+ value: true,
+ enumerable: true,
+ });
+
+ return mockedResponse;
+}
+
+/**
+ * @param {Request} request
+ */
+async function serializeRequest(request) {
+ return {
+ url: request.url,
+ mode: request.mode,
+ method: request.method,
+ headers: Object.fromEntries(request.headers.entries()),
+ cache: request.cache,
+ credentials: request.credentials,
+ destination: request.destination,
+ integrity: request.integrity,
+ redirect: request.redirect,
+ referrer: request.referrer,
+ referrerPolicy: request.referrerPolicy,
+ body: await request.arrayBuffer(),
+ keepalive: request.keepalive,
+ };
+}
diff --git a/app/src/test/render.tsx b/app/src/test/render.tsx
new file mode 100644
index 000000000..b7b41ad45
--- /dev/null
+++ b/app/src/test/render.tsx
@@ -0,0 +1,71 @@
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { createMemoryHistory, createRouter, RouterProvider } from '@tanstack/react-router';
+import type { ReactNode } from 'react';
+import { render } from 'vitest-browser-react';
+import { PlatformProvider } from '@/platform/PlatformContext';
+import { routeTree } from '@/router';
+import { createMockPlatform, type MockPlatform } from './mockPlatform';
+
+export function createTestQueryClient(): QueryClient {
+ return new QueryClient({
+ defaultOptions: {
+ // Retries and interval refetching are disabled so tests are
+ // deterministic — polling components get their data exactly once.
+ queries: {
+ retry: false,
+ refetchInterval: false,
+ refetchOnWindowFocus: false,
+ gcTime: Number.POSITIVE_INFINITY,
+ },
+ mutations: { retry: false },
+ },
+ });
+}
+
+export interface RenderWithProvidersOptions {
+ platform?: MockPlatform;
+ queryClient?: QueryClient;
+}
+
+// Every client handed to a render is drained on teardown so in-flight
+// queries can't fire after MSW handlers reset (noisy unhandled-request
+// errors between tests).
+const activeQueryClients: QueryClient[] = [];
+
+export async function drainQueryClients(): Promise {
+ for (const client of activeQueryClients) {
+ await client.cancelQueries();
+ client.clear();
+ }
+ activeQueryClients.length = 0;
+}
+
+export async function renderWithProviders(ui: ReactNode, options: RenderWithProvidersOptions = {}) {
+ const platform = options.platform ?? createMockPlatform();
+ const queryClient = options.queryClient ?? createTestQueryClient();
+ activeQueryClients.push(queryClient);
+
+ const result = await render(
+
+ {ui}
+ ,
+ );
+
+ // Object.assign keeps the render result's prototype methods (locators)
+ // intact — spreading would drop them.
+ return Object.assign(result, { platform, queryClient });
+}
+
+/**
+ * Mount the real route tree at `route` over memory history — full app chrome
+ * (sidebar, frame, toasts) included. A throwaway router per call keeps route
+ * state from leaking between tests.
+ */
+export async function renderRoute(route: string, options: RenderWithProvidersOptions = {}) {
+ const router = createRouter({
+ routeTree,
+ history: createMemoryHistory({ initialEntries: [route] }),
+ });
+ const result = await renderWithProviders(, options);
+ return Object.assign(result, { router });
+}
diff --git a/app/src/test/resetStores.ts b/app/src/test/resetStores.ts
new file mode 100644
index 000000000..adf90a36d
--- /dev/null
+++ b/app/src/test/resetStores.ts
@@ -0,0 +1,36 @@
+import { queryClient } from '@/lib/queryClient';
+import { useAudioChannelStore } from '@/stores/audioChannelStore';
+import { useEffectsStore } from '@/stores/effectsStore';
+import { useGenerationStore } from '@/stores/generationStore';
+import { useLogStore } from '@/stores/logStore';
+import { usePlayerStore } from '@/stores/playerStore';
+import { useServerStore } from '@/stores/serverStore';
+import { useStoryStore } from '@/stores/storyStore';
+import { useUIStore } from '@/stores/uiStore';
+
+const stores = [
+ useAudioChannelStore,
+ useEffectsStore,
+ useGenerationStore,
+ useLogStore,
+ usePlayerStore,
+ useServerStore,
+ useStoryStore,
+ useUIStore,
+] as const;
+
+// Snapshot pristine state at module load, before any test mutates anything.
+const snapshots = stores.map((store) => store.getState());
+
+/**
+ * Restore every zustand store to its initial state and clear persisted
+ * copies so tests can't leak state into each other. Persisted stores write
+ * through to localStorage on setState, so localStorage is cleared last.
+ */
+export function resetAllStores(): void {
+ stores.forEach((store, i) => {
+ store.setState(snapshots[i] as never, true);
+ });
+ queryClient.clear();
+ localStorage.clear();
+}
diff --git a/app/src/test/setup.browser.ts b/app/src/test/setup.browser.ts
new file mode 100644
index 000000000..e0b6d1d13
--- /dev/null
+++ b/app/src/test/setup.browser.ts
@@ -0,0 +1,18 @@
+import { afterEach, beforeAll } from 'vitest';
+import { cleanup } from 'vitest-browser-react';
+import { worker } from './msw/worker';
+import { drainQueryClients } from './render';
+
+beforeAll(async () => {
+ await worker.start({ onUnhandledRequest: 'error', quiet: true });
+ return () => worker.stop();
+});
+
+// Registered after setup.ts, so this runs first (afterEach is LIFO):
+// unmount → cancel in-flight queries → reset handlers, then setup.ts
+// restores stores and mocks.
+afterEach(async () => {
+ await cleanup();
+ await drainQueryClients();
+ worker.resetHandlers();
+});
diff --git a/app/src/test/setup.ts b/app/src/test/setup.ts
new file mode 100644
index 000000000..231b5c958
--- /dev/null
+++ b/app/src/test/setup.ts
@@ -0,0 +1,8 @@
+import '@/i18n';
+import { afterEach, vi } from 'vitest';
+import { resetAllStores } from './resetStores';
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ resetAllStores();
+});
diff --git a/app/src/test/sse.ts b/app/src/test/sse.ts
new file mode 100644
index 000000000..ec6c88219
--- /dev/null
+++ b/app/src/test/sse.ts
@@ -0,0 +1,81 @@
+import { HttpResponse } from 'msw';
+
+export interface SseEvent {
+ data: unknown;
+ event?: string;
+}
+
+const SSE_HEADERS = {
+ 'Content-Type': 'text/event-stream',
+ 'Cache-Control': 'no-cache',
+ Connection: 'keep-alive',
+} as const;
+
+const encoder = new TextEncoder();
+
+function frame({ data, event }: SseEvent): Uint8Array {
+ const payload = typeof data === 'string' ? data : JSON.stringify(data);
+ const lines = event ? `event: ${event}\ndata: ${payload}\n\n` : `data: ${payload}\n\n`;
+ return encoder.encode(lines);
+}
+
+/**
+ * An MSW response streaming the given events immediately, then staying open
+ * (EventSource reconnects on close, so a closed stream would loop the test).
+ */
+export function sseResponse(events: SseEvent[]): Response {
+ const stream = new ReadableStream({
+ start(controller) {
+ for (const event of events) controller.enqueue(frame(event));
+ },
+ });
+ return new HttpResponse(stream, { headers: SSE_HEADERS });
+}
+
+export interface SseController {
+ /** Hand this to an MSW resolver: `http.get(url, () => sse.response())`. */
+ response(): Response;
+ /** Push one event to every open stream. */
+ push(event: SseEvent): void;
+ /** End all open streams. */
+ close(): void;
+}
+
+/**
+ * Imperative SSE feed for tests that interleave user actions with server
+ * events (generation progress, download progress). Each call to `response()`
+ * opens a stream that receives subsequent `push`es — matching EventSource
+ * reconnect behavior.
+ */
+export function sseController(): SseController {
+ const controllers = new Set>();
+
+ return {
+ response() {
+ let own: ReadableStreamDefaultController;
+ const stream = new ReadableStream({
+ start(controller) {
+ own = controller;
+ controllers.add(controller);
+ },
+ cancel() {
+ controllers.delete(own);
+ },
+ });
+ return new HttpResponse(stream, { headers: SSE_HEADERS });
+ },
+ push(event: SseEvent) {
+ for (const controller of controllers) controller.enqueue(frame(event));
+ },
+ close() {
+ for (const controller of controllers) {
+ try {
+ controller.close();
+ } catch {
+ // already closed by cancel
+ }
+ }
+ controllers.clear();
+ },
+ };
+}
diff --git a/app/vite.config.ts b/app/vite.config.ts
deleted file mode 100644
index 36bc168bf..000000000
--- a/app/vite.config.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import path from 'node:path';
-import tailwindcss from '@tailwindcss/vite';
-import react from '@vitejs/plugin-react';
-import { defineConfig } from 'vite';
-import { changelogPlugin } from './plugins/changelog';
-
-export default defineConfig({
- plugins: [tailwindcss(), react(), changelogPlugin(path.resolve(__dirname, '..'))],
- resolve: {
- alias: {
- '@': path.resolve(__dirname, './src'),
- },
- },
-});
diff --git a/backend/backends/__init__.py b/backend/backends/__init__.py
index abaf70c17..c7870268d 100644
--- a/backend/backends/__init__.py
+++ b/backend/backends/__init__.py
@@ -12,6 +12,7 @@
# HF_HUB_OFFLINE=1 and on network failures.
from ..utils import hf_offline_patch # noqa: F401
+import os
import threading
from dataclasses import dataclass, field
from typing import Protocol, Optional, Tuple, List
@@ -678,6 +679,13 @@ def get_tts_backend_for_engine(engine: str) -> TTSBackend:
"""
global _tts_backends
+ # Test mode: every engine resolves to the fake backend so the full
+ # generation pipeline runs without model weights (see fake_backend.py).
+ if os.environ.get("VOICEBOX_FAKE_TTS") == "1":
+ from .fake_backend import get_fake_backend
+
+ return get_fake_backend()
+
# Fast path: check without lock
if engine in _tts_backends:
return _tts_backends[engine]
diff --git a/backend/backends/fake_backend.py b/backend/backends/fake_backend.py
new file mode 100644
index 000000000..cb6b3b2c6
--- /dev/null
+++ b/backend/backends/fake_backend.py
@@ -0,0 +1,92 @@
+"""Fake TTS backend for UI and E2E testing.
+
+Activated by ``VOICEBOX_FAKE_TTS=1``. Every engine resolves to this backend,
+which synthesizes a quiet sine tone sized to the input text — so the full
+generation pipeline (task queue, SSE progress, database rows, audio serving)
+runs exactly as in production, minus model weights and GPU time.
+"""
+
+import asyncio
+import logging
+from typing import ClassVar, Optional
+
+import numpy as np
+
+logger = logging.getLogger(__name__)
+
+SAMPLE_RATE = 24_000
+SECONDS_PER_CHAR = 0.02
+MIN_DURATION_S = 0.25
+TONE_HZ = 440.0
+AMPLITUDE = 0.1
+
+
+class FakeTTSBackend:
+ """Implements the TTSBackend protocol without any model."""
+
+ MODEL_CONFIGS: ClassVar[list] = []
+
+ def __init__(self) -> None:
+ self._loaded = False
+
+ async def load_model(self, model_size: str = "default") -> None:
+ if self._loaded:
+ return
+ # Brief pause so the UI's loading_model state is observable.
+ await asyncio.sleep(0.1)
+ self._loaded = True
+ logger.info("Fake TTS backend loaded (VOICEBOX_FAKE_TTS)")
+
+ async def load_model_async(self, model_size: str = "default") -> None:
+ # Qwen engines are loaded through this variant (see load_engine_model).
+ await self.load_model(model_size)
+
+ async def create_voice_prompt(
+ self,
+ audio_path: str,
+ reference_text: str,
+ use_cache: bool = True,
+ ) -> tuple[dict, bool]:
+ return ({"fake": True, "audio_path": audio_path, "reference_text": reference_text}, False)
+
+ async def combine_voice_prompts(
+ self,
+ audio_paths: list[str],
+ reference_texts: list[str],
+ ) -> tuple[np.ndarray, str]:
+ combined_text = " ".join(reference_texts)
+ return np.zeros(SAMPLE_RATE, dtype=np.float32), combined_text
+
+ async def generate(
+ self,
+ text: str,
+ voice_prompt: dict,
+ language: str = "en",
+ seed: Optional[int] = None,
+ instruct: Optional[str] = None,
+ ) -> tuple[np.ndarray, int]:
+ duration_s = max(MIN_DURATION_S, len(text) * SECONDS_PER_CHAR)
+ # Yield once so cancellation has a window, mirroring real inference.
+ await asyncio.sleep(0.05)
+ t = np.linspace(0.0, duration_s, int(SAMPLE_RATE * duration_s), endpoint=False)
+ audio = (AMPLITUDE * np.sin(2.0 * np.pi * TONE_HZ * t)).astype(np.float32)
+ return audio, SAMPLE_RATE
+
+ def unload_model(self) -> None:
+ self._loaded = False
+
+ def is_loaded(self) -> bool:
+ return self._loaded
+
+ def _get_model_path(self, model_size: str) -> str:
+ return "fake"
+
+
+_fake_backend: Optional[FakeTTSBackend] = None
+
+
+def get_fake_backend() -> FakeTTSBackend:
+ global _fake_backend
+ if _fake_backend is None:
+ _fake_backend = FakeTTSBackend()
+ return _fake_backend
diff --git a/backend/requirements-ci.txt b/backend/requirements-ci.txt
new file mode 100644
index 000000000..8e328d349
--- /dev/null
+++ b/backend/requirements-ci.txt
@@ -0,0 +1,25 @@
+# Minimal dependency set to boot the backend on a CPU-only CI runner.
+# No TTS/STT model libraries — inference is covered by the fake TTS
+# backend (VOICEBOX_FAKE_TTS=1). Install CPU torch first on Linux:
+# pip install torch --index-url https://download.pytorch.org/whl/cpu
+# then: pip install -r backend/requirements-ci.txt
+
+fastapi>=0.109.0
+uvicorn[standard]>=0.27.0
+pydantic>=2.5.0
+sqlalchemy>=2.0.0
+alembic>=1.13.0
+torch>=2.2.0
+huggingface_hub>=0.20.0
+numpy
+soundfile
+python-multipart
+sse-starlette
+psutil
+requests
+httpx
+fastmcp
+librosa
+pillow
+pydub
+pedalboard
diff --git a/bun.lock b/bun.lock
index e9491131c..c72f6b8ef 100644
--- a/bun.lock
+++ b/bun.lock
@@ -10,9 +10,18 @@
},
"devDependencies": {
"@biomejs/biome": "2.3.12",
+ "@playwright/test": "^1.62.1",
"@types/node": "^20.0.0",
+ "@vitejs/plugin-react": "^6.0.5",
+ "@vitest/browser": "^4.1.10",
+ "@vitest/browser-playwright": "^4.1.10",
+ "happy-dom": "^20.11.2",
+ "msw": "^2.15.0",
+ "playwright": "^1.62.1",
"tailwindcss": "^4.1.18",
"typescript": "^5.6.0",
+ "vitest": "^4.1.10",
+ "vitest-browser-react": "^2.2.0",
},
},
"app": {
@@ -180,6 +189,8 @@
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.12", "", { "os": "win32", "cpu": "x64" }, "sha512-qqGVWqNNek0KikwPZlOIoxtXgsNGsX+rgdEzgw82Re8nF02W+E2WokaQhpF5TdBh/D/RQ3TLppH+otp6ztN0lw=="],
+ "@blazediff/core": ["@blazediff/core@1.9.1", "", {}, "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA=="],
+
"@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="],
"@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="],
@@ -258,6 +269,16 @@
"@humanwhocodes/object-schema": ["@humanwhocodes/object-schema@2.0.3", "", {}, "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA=="],
+ "@inquirer/ansi": ["@inquirer/ansi@2.0.7", "", {}, "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q=="],
+
+ "@inquirer/confirm": ["@inquirer/confirm@6.1.1", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ=="],
+
+ "@inquirer/core": ["@inquirer/core@11.2.1", "", { "dependencies": { "@inquirer/ansi": "^2.0.7", "@inquirer/figures": "^2.0.7", "@inquirer/type": "^4.0.7", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA=="],
+
+ "@inquirer/figures": ["@inquirer/figures@2.0.7", "", {}, "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw=="],
+
+ "@inquirer/type": ["@inquirer/type@4.0.7", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g=="],
+
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
@@ -268,12 +289,26 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
+ "@mswjs/interceptors": ["@mswjs/interceptors@0.41.9", "", { "dependencies": { "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "strict-event-emitter": "^0.5.1" } }, "sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w=="],
+
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
+ "@open-draft/deferred-promise": ["@open-draft/deferred-promise@3.0.0", "", {}, "sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA=="],
+
+ "@open-draft/logger": ["@open-draft/logger@0.3.0", "", { "dependencies": { "is-node-process": "^1.2.0", "outvariant": "^1.4.0" } }, "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ=="],
+
+ "@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="],
+
+ "@oxc-project/types": ["@oxc-project/types@0.143.0", "", {}, "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA=="],
+
+ "@playwright/test": ["@playwright/test@1.62.1", "", { "dependencies": { "playwright": "1.62.1" }, "bin": { "playwright": "cli.js" } }, "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ=="],
+
+ "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
+
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
@@ -358,7 +393,35 @@
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
- "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
+ "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.3", "", { "os": "android", "cpu": "arm64" }, "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw=="],
+
+ "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA=="],
+
+ "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA=="],
+
+ "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg=="],
+
+ "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.3", "", { "os": "linux", "cpu": "arm" }, "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw=="],
+
+ "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw=="],
+
+ "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q=="],
+
+ "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w=="],
+
+ "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg=="],
+
+ "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w=="],
+
+ "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A=="],
+
+ "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.3", "", { "os": "none", "cpu": "arm64" }, "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug=="],
+
+ "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw=="],
+
+ "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.3", "", { "os": "win32", "cpu": "x64" }, "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg=="],
+
+ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.56.0", "", { "os": "android", "cpu": "arm" }, "sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw=="],
@@ -410,6 +473,8 @@
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.56.0", "", { "os": "win32", "cpu": "x64" }, "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g=="],
+ "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
+
"@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="],
@@ -502,6 +567,10 @@
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
+ "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
+
+ "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
+
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/node": ["@types/node@20.19.30", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g=="],
@@ -512,6 +581,14 @@
"@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="],
+ "@types/set-cookie-parser": ["@types/set-cookie-parser@2.4.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw=="],
+
+ "@types/statuses": ["@types/statuses@2.0.6", "", {}, "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="],
+
+ "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
+
+ "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
+
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@7.18.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/type-utils": "7.18.0", "@typescript-eslint/utils": "7.18.0", "@typescript-eslint/visitor-keys": "7.18.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", "ts-api-utils": "^1.3.0" }, "peerDependencies": { "@typescript-eslint/parser": "^7.0.0", "eslint": "^8.56.0" } }, "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@7.18.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/types": "7.18.0", "@typescript-eslint/typescript-estree": "7.18.0", "@typescript-eslint/visitor-keys": "7.18.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.56.0" } }, "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg=="],
@@ -530,7 +607,25 @@
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
- "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
+ "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.5", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA=="],
+
+ "@vitest/browser": ["@vitest/browser@4.1.10", "", { "dependencies": { "@blazediff/core": "1.9.1", "@vitest/mocker": "4.1.10", "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pngjs": "^7.0.0", "sirv": "^3.0.2", "tinyrainbow": "^3.1.0", "ws": "^8.19.0" }, "peerDependencies": { "vitest": "4.1.10" } }, "sha512-UDwuWGwXj646CBx/bQHOaJSX7np0I8JL/UKQYa1e4QrVHH8VdWtx8eaOuf8sy0ShwDgR6NjJAsp5eF6vjF6qng=="],
+
+ "@vitest/browser-playwright": ["@vitest/browser-playwright@4.1.10", "", { "dependencies": { "@vitest/browser": "4.1.10", "@vitest/mocker": "4.1.10", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "playwright": "*", "vitest": "4.1.10" } }, "sha512-nMoXGEiRpT7m3W7NsbvrM2aKNwiNHZf+zEpUCvMteGjZFvfT96Q9fh7QyB98dvDWXiKvrLxA7bJ1mCOOv+JQPw=="],
+
+ "@vitest/expect": ["@vitest/expect@4.1.10", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA=="],
+
+ "@vitest/mocker": ["@vitest/mocker@4.1.10", "", { "dependencies": { "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow=="],
+
+ "@vitest/pretty-format": ["@vitest/pretty-format@4.1.10", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q=="],
+
+ "@vitest/runner": ["@vitest/runner@4.1.10", "", { "dependencies": { "@vitest/utils": "4.1.10", "pathe": "^2.0.3" } }, "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg=="],
+
+ "@vitest/snapshot": ["@vitest/snapshot@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw=="],
+
+ "@vitest/spy": ["@vitest/spy@4.1.10", "", {}, "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw=="],
+
+ "@vitest/utils": ["@vitest/utils@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA=="],
"@voicebox/app": ["@voicebox/app@workspace:app"],
@@ -554,6 +649,8 @@
"array-union": ["array-union@2.1.0", "", {}, "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw=="],
+ "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
+
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.18", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA=="],
@@ -564,16 +661,24 @@
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
+ "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="],
+
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001766", "", {}, "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA=="],
+ "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
+
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
"classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="],
+ "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="],
+
+ "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
+
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
@@ -584,6 +689,8 @@
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
+ "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
+
"cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
@@ -606,8 +713,14 @@
"electron-to-chromium": ["electron-to-chromium@1.5.278", "", {}, "sha512-dQ0tM1svDRQOwxnXxm+twlGTjr9Upvt8UFWAgmLsxEzFQxhbti4VwxmMjsDxVC51Zo84swW7FVCXEV+VAkhuPw=="],
+ "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
+
"enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="],
+ "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
+
+ "es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="],
+
"esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
@@ -632,8 +745,12 @@
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
+ "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
+
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
+ "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="],
+
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
@@ -642,8 +759,16 @@
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
+ "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="],
+
+ "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="],
+
+ "fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="],
+
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
+ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
+
"file-entry-cache": ["file-entry-cache@6.0.1", "", { "dependencies": { "flat-cache": "^3.0.4" } }, "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
@@ -658,10 +783,12 @@
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
- "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+ "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
+ "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
+
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
"glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
@@ -676,8 +803,14 @@
"graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="],
+ "graphql": ["graphql@16.14.2", "", {}, "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA=="],
+
+ "happy-dom": ["happy-dom@20.11.2", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-7MB+bJLkxu3SowAfBJbjW+c55kNz5tkR45gu2qzrxznezhLeN5YIlJbwUgSzlGc+qWoZ8Ykg71H5ezz69xixrw=="],
+
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
+ "headers-polyfill": ["headers-polyfill@5.0.1", "", { "dependencies": { "@types/set-cookie-parser": "^2.4.10", "set-cookie-parser": "^3.0.1" } }, "sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA=="],
+
"html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="],
"i18next": ["i18next@26.0.6", "", { "dependencies": { "@babel/runtime": "^7.29.2" }, "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-A4U6eCXodIbrhf8EarRurB9/4ebyaurH4+fu4gig9bqxmpSt+fCAFm/GpRQDcN1Xzu/LdFCx4nYHsnM1edIIbg=="],
@@ -696,8 +829,12 @@
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
+ "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
+
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
+ "is-node-process": ["is-node-process@1.2.0", "", {}, "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw=="],
+
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"is-path-inside": ["is-path-inside@3.0.3", "", {}, "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ=="],
@@ -776,8 +913,14 @@
"motion-utils": ["motion-utils@12.36.0", "", {}, "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="],
+ "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
+
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+ "msw": ["msw@2.15.0", "", { "dependencies": { "@inquirer/confirm": "^6.0.11", "@mswjs/interceptors": "^0.41.3", "@open-draft/deferred-promise": "^3.0.0", "@types/statuses": "^2.0.6", "cookie": "^1.1.1", "graphql": "^16.13.2", "headers-polyfill": "^5.0.1", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.11.11", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.1", "type-fest": "^5.5.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-2wQAmKkQKxRuXvYJxVhPGG0wZNBQyD06oJvxqw90XqLvptdqxdlHrFUfEteKkpaNORX3Xzc+HtEl/q0nfmN2wQ=="],
+
+ "mute-stream": ["mute-stream@3.0.0", "", {}, "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw=="],
+
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
@@ -786,10 +929,14 @@
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
+ "obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="],
+
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
+ "outvariant": ["outvariant@1.4.3", "", {}, "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA=="],
+
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
@@ -802,11 +949,21 @@
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
+ "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
+
"path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="],
+ "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
+
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
- "picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
+ "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
+
+ "playwright": ["playwright@1.62.1", "", { "dependencies": { "playwright-core": "1.62.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg=="],
+
+ "playwright-core": ["playwright-core@1.62.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw=="],
+
+ "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
@@ -840,12 +997,18 @@
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
+ "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
+
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
+ "rettime": ["rettime@0.11.11", "", {}, "sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ=="],
+
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="],
+ "rolldown": ["rolldown@1.2.3", "", { "dependencies": { "@oxc-project/types": "=0.143.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.2.3", "@rolldown/binding-darwin-arm64": "1.2.3", "@rolldown/binding-darwin-x64": "1.2.3", "@rolldown/binding-freebsd-x64": "1.2.3", "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", "@rolldown/binding-linux-arm64-gnu": "1.2.3", "@rolldown/binding-linux-arm64-musl": "1.2.3", "@rolldown/binding-linux-ppc64-gnu": "1.2.3", "@rolldown/binding-linux-s390x-gnu": "1.2.3", "@rolldown/binding-linux-x64-gnu": "1.2.3", "@rolldown/binding-linux-x64-musl": "1.2.3", "@rolldown/binding-openharmony-arm64": "1.2.3", "@rolldown/binding-win32-arm64-msvc": "1.2.3", "@rolldown/binding-win32-x64-msvc": "1.2.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A=="],
+
"rollup": ["rollup@4.56.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.56.0", "@rollup/rollup-android-arm64": "4.56.0", "@rollup/rollup-darwin-arm64": "4.56.0", "@rollup/rollup-darwin-x64": "4.56.0", "@rollup/rollup-freebsd-arm64": "4.56.0", "@rollup/rollup-freebsd-x64": "4.56.0", "@rollup/rollup-linux-arm-gnueabihf": "4.56.0", "@rollup/rollup-linux-arm-musleabihf": "4.56.0", "@rollup/rollup-linux-arm64-gnu": "4.56.0", "@rollup/rollup-linux-arm64-musl": "4.56.0", "@rollup/rollup-linux-loong64-gnu": "4.56.0", "@rollup/rollup-linux-loong64-musl": "4.56.0", "@rollup/rollup-linux-ppc64-gnu": "4.56.0", "@rollup/rollup-linux-ppc64-musl": "4.56.0", "@rollup/rollup-linux-riscv64-gnu": "4.56.0", "@rollup/rollup-linux-riscv64-musl": "4.56.0", "@rollup/rollup-linux-s390x-gnu": "4.56.0", "@rollup/rollup-linux-x64-gnu": "4.56.0", "@rollup/rollup-linux-x64-musl": "4.56.0", "@rollup/rollup-openbsd-x64": "4.56.0", "@rollup/rollup-openharmony-arm64": "4.56.0", "@rollup/rollup-win32-arm64-msvc": "4.56.0", "@rollup/rollup-win32-ia32-msvc": "4.56.0", "@rollup/rollup-win32-x64-gnu": "4.56.0", "@rollup/rollup-win32-x64-msvc": "4.56.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg=="],
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
@@ -858,22 +1021,42 @@
"seroval-plugins": ["seroval-plugins@1.5.0", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA=="],
+ "set-cookie-parser": ["set-cookie-parser@3.1.2", "", {}, "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw=="],
+
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
+ "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
+
+ "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
+
+ "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
+
"slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
"sound-visualizer": ["sound-visualizer@1.4.0", "", {}, "sha512-2+Un0PrrBgXylnCjrVYUoRW7KEDH29h7O8/MGzeDOgFGBPb9oX/2n/RGBxJXvVv2U3KFwX5olUWeJKf0Rr5TLQ=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
+ "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
+
+ "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
+
+ "std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="],
+
+ "strict-event-emitter": ["strict-event-emitter@0.5.1", "", {}, "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ=="],
+
+ "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
+
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
+ "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
+
"tailwind-merge": ["tailwind-merge@2.6.0", "", {}, "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA=="],
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
@@ -888,20 +1071,38 @@
"tiny-warning": ["tiny-warning@1.0.3", "", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="],
+ "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
+
+ "tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="],
+
+ "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
+
+ "tinyrainbow": ["tinyrainbow@3.1.1", "", {}, "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw=="],
+
+ "tldts": ["tldts@7.4.10", "", { "dependencies": { "tldts-core": "^7.4.10" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog=="],
+
+ "tldts-core": ["tldts-core@7.4.10", "", {}, "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw=="],
+
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
+ "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
+
+ "tough-cookie": ["tough-cookie@6.0.2", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA=="],
+
"ts-api-utils": ["ts-api-utils@1.4.3", "", { "peerDependencies": { "typescript": ">=4.2.0" } }, "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
- "type-fest": ["type-fest@0.20.2", "", {}, "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ=="],
+ "type-fest": ["type-fest@5.8.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
+ "until-async": ["until-async@3.0.2", "", {}, "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw=="],
+
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
@@ -914,24 +1115,44 @@
"vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
+ "vitest": ["vitest@4.1.10", "", { "dependencies": { "@vitest/expect": "4.1.10", "@vitest/mocker": "4.1.10", "@vitest/pretty-format": "4.1.10", "@vitest/runner": "4.1.10", "@vitest/snapshot": "4.1.10", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.10", "@vitest/browser-preview": "4.1.10", "@vitest/browser-webdriverio": "4.1.10", "@vitest/coverage-istanbul": "4.1.10", "@vitest/coverage-v8": "4.1.10", "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw=="],
+
+ "vitest-browser-react": ["vitest-browser-react@2.2.0", "", { "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "vitest": "^4.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oY3KM6305kwJMa6nHo92vVtkOsih7mjEf12dLKuphaF+9ywWPEc+qanIBd394SZ6m5LadVEaG6dicvvizOzmjA=="],
+
"void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="],
"wavesurfer.js": ["wavesurfer.js@7.12.2", "", {}, "sha512-akVYISAHCw2gNw/7n8Pk/zH1Zz91WJyL/2MaNQCLD1XV3A226gKlWoDHWp9UdWqQ3zXnWttDf9ewZQQ3cxbOmQ=="],
+ "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
+
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
+ "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
+
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
+ "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
+
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
+ "ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="],
+
+ "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
+
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
+ "yargs": ["yargs@17.7.3", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g=="],
+
+ "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
+
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="],
+ "@mswjs/interceptors/@open-draft/deferred-promise": ["@open-draft/deferred-promise@2.2.0", "", {}, "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA=="],
+
"@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-avatar/@radix-ui/react-context": ["@radix-ui/react-context@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="],
@@ -974,16 +1195,100 @@
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
+ "@vitejs/plugin-react/vite": ["vite@8.2.1", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.25", "rolldown": "~1.2.1", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw=="],
+
+ "@voicebox/app/@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
+
+ "@voicebox/tauri/@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
+
+ "@voicebox/web/@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
+
"@voicebox/web/wavesurfer.js": ["wavesurfer.js@7.12.1", "", {}, "sha512-NswPjVHxk0Q1F/VMRemCPUzSojjuHHisQrBqQiRXg7MVbe3f5vQ6r0rTTXA/a/neC/4hnOEC4YpXca4LpH0SUg=="],
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
+ "globals/type-fest": ["type-fest@0.20.2", "", {}, "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ=="],
+
+ "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
+
"motion/framer-motion": ["framer-motion@12.29.0", "", { "dependencies": { "motion-dom": "^12.29.0", "motion-utils": "^12.27.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-1gEFGXHYV2BD42ZPTFmSU9buehppU+bCuOnHU0AD18DKh9j4DuTx47MvqY5ax+NNWRtK32qIcJf1UxKo1WwjWg=="],
+ "rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+
+ "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+
+ "vitest/vite": ["vite@8.2.1", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.25", "rolldown": "~1.2.1", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw=="],
+
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
+ "@vitejs/plugin-react/vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+
+ "@vitejs/plugin-react/vite/lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
+
+ "@vitejs/plugin-react/vite/postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="],
+
+ "@voicebox/app/@vitejs/plugin-react/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
+
+ "@voicebox/tauri/@vitejs/plugin-react/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
+
+ "@voicebox/web/@vitejs/plugin-react/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
+
"motion/framer-motion/motion-dom": ["motion-dom@12.29.0", "", { "dependencies": { "motion-utils": "^12.27.2" } }, "sha512-3eiz9bb32yvY8Q6XNM4AwkSOBPgU//EIKTZwsSWgA9uzbPBhZJeScCVcBuwwYVqhfamewpv7ZNmVKTGp5qnzkA=="],
"motion/framer-motion/motion-utils": ["motion-utils@12.27.2", "", {}, "sha512-B55gcoL85Mcdt2IEStY5EEAsrMSVE2sI14xQ/uAdPL+mfQxhKKFaEag9JmfxedJOR4vZpBGoPeC/Gm13I/4g5Q=="],
+
+ "vitest/vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+
+ "vitest/vite/lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
+
+ "vitest/vite/postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="],
+
+ "@vitejs/plugin-react/vite/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
+
+ "@vitejs/plugin-react/vite/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="],
+
+ "@vitejs/plugin-react/vite/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="],
+
+ "@vitejs/plugin-react/vite/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="],
+
+ "@vitejs/plugin-react/vite/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="],
+
+ "@vitejs/plugin-react/vite/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="],
+
+ "@vitejs/plugin-react/vite/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="],
+
+ "@vitejs/plugin-react/vite/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="],
+
+ "@vitejs/plugin-react/vite/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="],
+
+ "@vitejs/plugin-react/vite/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="],
+
+ "@vitejs/plugin-react/vite/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
+
+ "@vitejs/plugin-react/vite/postcss/nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="],
+
+ "vitest/vite/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
+
+ "vitest/vite/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="],
+
+ "vitest/vite/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="],
+
+ "vitest/vite/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="],
+
+ "vitest/vite/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="],
+
+ "vitest/vite/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="],
+
+ "vitest/vite/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="],
+
+ "vitest/vite/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="],
+
+ "vitest/vite/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="],
+
+ "vitest/vite/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="],
+
+ "vitest/vite/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
+
+ "vitest/vite/postcss/nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="],
}
}
diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts
new file mode 100644
index 000000000..2210a79e0
--- /dev/null
+++ b/e2e/fixtures.ts
@@ -0,0 +1,99 @@
+import { type ChildProcess, spawn } from 'node:child_process';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { test as base } from '@playwright/test';
+
+const REPO_ROOT = path.resolve(__dirname, '..');
+const BASE_PORT = 18100;
+
+export interface BackendFixture {
+ url: string;
+ dataDir: string;
+}
+
+async function waitForHealth(url: string, timeoutMs = 60_000): Promise {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ try {
+ const res = await fetch(`${url}/health`);
+ if (res.ok) return;
+ } catch {
+ // not up yet
+ }
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ }
+ throw new Error(`backend at ${url} did not become healthy within ${timeoutMs}ms`);
+}
+
+export const test = base.extend, { backend: BackendFixture }>({
+ backend: [
+ async ({}, use, workerInfo) => {
+ const port = BASE_PORT + workerInfo.workerIndex;
+ const url = `http://127.0.0.1:${port}`;
+ // uvicorn resolves the data dir from cwd, so a temp cwd isolates
+ // each worker's SQLite and audio files completely.
+ const dataDir = mkdtempSync(path.join(tmpdir(), `voicebox-e2e-${workerInfo.workerIndex}-`));
+ const python = process.env.VOICEBOX_PYTHON ?? path.join(REPO_ROOT, '.venv-ci/bin/python');
+
+ const proc: ChildProcess = spawn(
+ python,
+ ['-m', 'uvicorn', 'backend.main:app', '--port', String(port), '--log-level', 'warning'],
+ {
+ cwd: dataDir,
+ env: {
+ ...process.env,
+ PYTHONPATH: REPO_ROOT,
+ VOICEBOX_FAKE_TTS: '1',
+ VOICEBOX_CORS_ORIGINS:
+ 'http://localhost:4173,http://127.0.0.1:4173,http://localhost:5173',
+ },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ },
+ );
+ const logs: Buffer[] = [];
+ proc.stdout?.on('data', (chunk) => logs.push(chunk));
+ proc.stderr?.on('data', (chunk) => logs.push(chunk));
+
+ try {
+ await waitForHealth(url);
+ } catch (error) {
+ proc.kill('SIGKILL');
+ throw new Error(`${(error as Error).message}\nbackend log:\n${Buffer.concat(logs)}`);
+ }
+
+ await use({ url, dataDir });
+
+ proc.kill('SIGTERM');
+ await new Promise((resolve) => {
+ proc.once('exit', resolve);
+ setTimeout(resolve, 5000);
+ });
+ rmSync(dataDir, { recursive: true, force: true });
+ },
+ { scope: 'worker' },
+ ],
+
+ // Point the app's persisted server store at this worker's backend before
+ // any page script runs.
+ page: async ({ page, backend }, use) => {
+ await page.addInitScript((serverUrl) => {
+ window.localStorage.setItem(
+ 'voicebox-server',
+ JSON.stringify({
+ state: {
+ serverUrl,
+ isConnected: false,
+ mode: 'local',
+ keepServerRunningOnClose: false,
+ customModelsDir: null,
+ },
+ version: 0,
+ }),
+ );
+ }, backend.url);
+ await use(page);
+ },
+});
+
+export { expect } from '@playwright/test';
diff --git a/e2e/helpers/api.ts b/e2e/helpers/api.ts
new file mode 100644
index 000000000..a3e3d26d0
--- /dev/null
+++ b/e2e/helpers/api.ts
@@ -0,0 +1,58 @@
+/** Direct REST seeding against the per-worker backend — faster and less
+ * brittle than driving every prerequisite through the UI. */
+
+export interface SeededProfile {
+ id: string;
+ name: string;
+}
+
+/** A 3-second 220 Hz sine WAV (backend requires samples >= 2s). */
+export function buildSampleWav(): Blob {
+ const sampleRate = 24_000;
+ const seconds = 3;
+ const samples = sampleRate * seconds;
+ const dataSize = samples * 2;
+ const buffer = new ArrayBuffer(44 + dataSize);
+ const view = new DataView(buffer);
+ const writeString = (offset: number, s: string) => {
+ for (let i = 0; i < s.length; i++) view.setUint8(offset + i, s.charCodeAt(i));
+ };
+ writeString(0, 'RIFF');
+ view.setUint32(4, 36 + dataSize, true);
+ writeString(8, 'WAVE');
+ writeString(12, 'fmt ');
+ view.setUint32(16, 16, true);
+ view.setUint16(20, 1, true);
+ view.setUint16(22, 1, true);
+ view.setUint32(24, sampleRate, true);
+ view.setUint32(28, sampleRate * 2, true);
+ view.setUint16(32, 2, true);
+ view.setUint16(34, 16, true);
+ writeString(36, 'data');
+ view.setUint32(40, dataSize, true);
+ for (let i = 0; i < samples; i++) {
+ view.setInt16(44 + i * 2, Math.round(0.1 * 32767 * Math.sin((2 * Math.PI * 220 * i) / sampleRate)), true);
+ }
+ return new Blob([buffer], { type: 'audio/wav' });
+}
+
+export async function seedProfile(backendUrl: string, name: string): Promise {
+ const createRes = await fetch(`${backendUrl}/profiles`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name, language: 'en' }),
+ });
+ if (!createRes.ok) throw new Error(`create profile failed: ${createRes.status}`);
+ const profile = (await createRes.json()) as { id: string };
+
+ const form = new FormData();
+ form.append('file', buildSampleWav(), 'sample.wav');
+ form.append('reference_text', 'hello world this is a reference sample');
+ const sampleRes = await fetch(`${backendUrl}/profiles/${profile.id}/samples`, {
+ method: 'POST',
+ body: form,
+ });
+ if (!sampleRes.ok) throw new Error(`add sample failed: ${sampleRes.status} ${await sampleRes.text()}`);
+
+ return { id: profile.id, name };
+}
diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts
new file mode 100644
index 000000000..737f0a90c
--- /dev/null
+++ b/e2e/playwright.config.ts
@@ -0,0 +1,36 @@
+import { defineConfig, devices } from '@playwright/test';
+
+/**
+ * E2E suite driving the web build (same app as Tauri, browser platform)
+ * against a real CPU backend with the fake TTS engine. Each worker gets
+ * its own uvicorn on its own port with its own data dir — see fixtures.ts.
+ *
+ * PW_DEV=1 targets `bun run dev:web` (port 5173) instead of the preview
+ * build for faster local iteration.
+ */
+const DEV = !!process.env.PW_DEV;
+const PORT = DEV ? 5173 : 4173;
+
+export default defineConfig({
+ testDir: './specs',
+ fullyParallel: true,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 1 : 0,
+ reporter: process.env.CI ? [['html', { open: 'never' }], ['github']] : 'list',
+ use: {
+ baseURL: `http://localhost:${PORT}`,
+ trace: 'on-first-retry',
+ video: 'retain-on-failure',
+ screenshot: 'only-on-failure',
+ },
+ projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
+ webServer: {
+ command: DEV
+ ? 'bun run dev:web'
+ : 'bun run build:web && cd web && bunx vite preview --port 4173 --strictPort',
+ cwd: '..',
+ port: PORT,
+ reuseExistingServer: !process.env.CI,
+ timeout: 120_000,
+ },
+});
diff --git a/e2e/specs/generate.spec.ts b/e2e/specs/generate.spec.ts
new file mode 100644
index 000000000..e59dbd974
--- /dev/null
+++ b/e2e/specs/generate.spec.ts
@@ -0,0 +1,31 @@
+import { expect, test } from '../fixtures';
+import { seedProfile } from '../helpers/api';
+
+test('generate speech end to end through the fake TTS pipeline', async ({ page, backend }) => {
+ const profile = await seedProfile(backend.url, 'Narrator');
+
+ await page.goto('/');
+
+ // Select the seeded voice, type into the generate box, and submit.
+ await page.getByText(profile.name).first().click();
+ const input = page.getByRole('textbox').first();
+ await input.click();
+ await page.keyboard.type('The quick brown fox jumps over the lazy dog.');
+ await page.getByRole('button', { name: 'Generate speech' }).click();
+
+ // The row lands in history and completes via the real queue + SSE.
+ await expect(page.getByText('The quick brown fox', { exact: false }).first()).toBeVisible({
+ timeout: 15_000,
+ });
+
+ await expect
+ .poll(
+ async () => {
+ const res = await fetch(`${backend.url}/history?limit=10`);
+ const body = (await res.json()) as { items: { status: string }[] };
+ return body.items[0]?.status;
+ },
+ { timeout: 20_000 },
+ )
+ .toBe('completed');
+});
diff --git a/e2e/specs/startup.spec.ts b/e2e/specs/startup.spec.ts
new file mode 100644
index 000000000..fd5c9722a
--- /dev/null
+++ b/e2e/specs/startup.spec.ts
@@ -0,0 +1,16 @@
+import { expect, test } from '../fixtures';
+
+test('app boots against the backend and shows the main editor', async ({ page }) => {
+ await page.goto('/');
+
+ await expect(page.getByRole('heading', { name: 'Voicebox' })).toBeVisible();
+ await expect(page.getByRole('button', { name: 'Create Voice' }).first()).toBeVisible();
+});
+
+test('settings layout renders its pages', async ({ page }) => {
+ await page.goto('/settings/about');
+
+ await expect(page.getByText('General', { exact: true })).toBeVisible();
+ await expect(page.getByText('Changelog', { exact: true })).toBeVisible();
+ await expect(page.getByText('About', { exact: true })).toBeVisible();
+});
diff --git a/e2e/specs/voices.spec.ts b/e2e/specs/voices.spec.ts
new file mode 100644
index 000000000..92e7cf7c9
--- /dev/null
+++ b/e2e/specs/voices.spec.ts
@@ -0,0 +1,10 @@
+import { expect, test } from '../fixtures';
+import { seedProfile } from '../helpers/api';
+
+test('a seeded voice profile appears in the voices tab', async ({ page, backend }) => {
+ const profile = await seedProfile(backend.url, 'Marcus Aurelius');
+
+ await page.goto('/voices');
+
+ await expect(page.getByText(profile.name).first()).toBeVisible();
+});
diff --git a/package.json b/package.json
index b1e5682dd..5e1120b14 100644
--- a/package.json
+++ b/package.json
@@ -27,13 +27,24 @@
"format:check": "biome format .",
"check": "biome check .",
"check:fix": "biome check --write .",
+ "test": "vitest run",
+ "test:watch": "vitest",
"ci": "bun run typecheck && bun run build:web"
},
"devDependencies": {
"@biomejs/biome": "2.3.12",
+ "@playwright/test": "^1.62.1",
"@types/node": "^20.0.0",
+ "@vitejs/plugin-react": "^6.0.5",
+ "@vitest/browser": "^4.1.10",
+ "@vitest/browser-playwright": "^4.1.10",
+ "happy-dom": "^20.11.2",
+ "msw": "^2.15.0",
+ "playwright": "^1.62.1",
"tailwindcss": "^4.1.18",
- "typescript": "^5.6.0"
+ "typescript": "^5.6.0",
+ "vitest": "^4.1.10",
+ "vitest-browser-react": "^2.2.0"
},
"engines": {
"bun": ">=1.0.0"
diff --git a/tauri/src/main.tsx b/tauri/src/main.tsx
index 398fc57e8..5c1eadb9c 100644
--- a/tauri/src/main.tsx
+++ b/tauri/src/main.tsx
@@ -1,24 +1,15 @@
+import { QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import ReactDOM from 'react-dom/client';
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
// import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import App from '@/App';
// Import CSS from app directory using alias so Tailwind can scan the source files
import '@/index.css';
+import '@/i18n';
+import { queryClient } from '@/lib/queryClient';
import { PlatformProvider } from '@/platform/PlatformContext';
import { tauriPlatform } from './platform';
-const queryClient = new QueryClient({
- defaultOptions: {
- queries: {
- staleTime: 1000 * 60 * 5, // 5 minutes
- gcTime: 1000 * 60 * 10, // 10 minutes
- retry: 1,
- refetchOnWindowFocus: false,
- },
- },
-});
-
ReactDOM.createRoot(document.getElementById('root')!).render(
diff --git a/tauri/src/platform/filesystem.ts b/tauri/src/platform/filesystem.ts
index 1d8ded39d..4da11ab37 100644
--- a/tauri/src/platform/filesystem.ts
+++ b/tauri/src/platform/filesystem.ts
@@ -10,7 +10,7 @@ export const tauriFilesystem: PlatformFilesystem = {
filters: filters || [],
});
- if (!filePath) return; // User cancelled the dialog
+ if (!filePath) return null; // User cancelled the dialog
const resolvedPath =
typeof filePath === 'string' ? filePath : (filePath as { path: string }).path;
@@ -21,6 +21,7 @@ export const tauriFilesystem: PlatformFilesystem = {
const arrayBuffer = await blob.arrayBuffer();
await writeFile(resolvedPath, new Uint8Array(arrayBuffer));
+ return resolvedPath;
},
async openPath(path: string) {
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 000000000..bee089548
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,80 @@
+import path from 'node:path';
+import react from '@vitejs/plugin-react';
+import { playwright } from '@vitest/browser-playwright';
+import { defineConfig } from 'vitest/config';
+import { changelogPlugin } from './app/plugins/changelog';
+
+const appSrc = path.resolve(__dirname, 'app/src');
+
+const shared = {
+ plugins: [react(), changelogPlugin(__dirname)],
+ resolve: {
+ alias: { '@': appSrc },
+ },
+};
+
+export default defineConfig({
+ ...shared,
+ test: {
+ projects: [
+ {
+ ...shared,
+ test: {
+ name: 'unit',
+ environment: 'happy-dom',
+ include: ['app/src/**/*.test.{ts,tsx}'],
+ exclude: ['app/src/**/*.browser.test.{ts,tsx}'],
+ setupFiles: ['app/src/test/setup.ts'],
+ },
+ },
+ {
+ ...shared,
+ publicDir: path.resolve(__dirname, 'app/src/test/public'),
+ // Pre-bundle everything the app pulls in so the dep optimizer never
+ // reloads mid-run (a cold cache otherwise fails the first CI run).
+ optimizeDeps: {
+ include: [
+ 'react',
+ 'react-dom/client',
+ 'react/jsx-runtime',
+ 'react/jsx-dev-runtime',
+ '@tanstack/react-query',
+ '@tanstack/react-router',
+ 'zustand',
+ 'zustand/middleware',
+ 'i18next',
+ 'react-i18next',
+ 'i18next-browser-languagedetector',
+ 'framer-motion',
+ 'motion/react',
+ 'lucide-react',
+ 'wavesurfer.js',
+ '@dnd-kit/core',
+ '@dnd-kit/sortable',
+ '@dnd-kit/utilities',
+ 'react-hook-form',
+ '@hookform/resolvers/zod',
+ 'zod',
+ 'clsx',
+ 'tailwind-merge',
+ 'class-variance-authority',
+ 'date-fns',
+ 'msw',
+ 'vitest-browser-react',
+ ],
+ },
+ test: {
+ name: 'browser',
+ include: ['app/src/**/*.browser.test.{ts,tsx}'],
+ setupFiles: ['app/src/test/setup.ts', 'app/src/test/setup.browser.ts'],
+ browser: {
+ enabled: true,
+ headless: true,
+ provider: playwright(),
+ instances: [{ browser: 'chromium' }],
+ },
+ },
+ },
+ ],
+ },
+});
diff --git a/web/src/main.tsx b/web/src/main.tsx
index 771f07e84..5a344f194 100644
--- a/web/src/main.tsx
+++ b/web/src/main.tsx
@@ -1,22 +1,13 @@
+import { QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import ReactDOM from 'react-dom/client';
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from '../../app/src/App';
import '../../app/src/index.css';
+import '../../app/src/i18n';
+import { queryClient } from '../../app/src/lib/queryClient';
import { PlatformProvider } from '../../app/src/platform/PlatformContext';
import { webPlatform } from './platform';
-const queryClient = new QueryClient({
- defaultOptions: {
- queries: {
- staleTime: 1000 * 60 * 5, // 5 minutes
- gcTime: 1000 * 60 * 10, // 10 minutes
- retry: 1,
- refetchOnWindowFocus: false,
- },
- },
-});
-
ReactDOM.createRoot(document.getElementById('root')!).render(
diff --git a/web/src/platform/filesystem.ts b/web/src/platform/filesystem.ts
index 3a871ad20..a15441e82 100644
--- a/web/src/platform/filesystem.ts
+++ b/web/src/platform/filesystem.ts
@@ -11,6 +11,7 @@ export const webFilesystem: PlatformFilesystem = {
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
+ return filename;
},
async openPath(_path: string) {