From 69d768e8038707d9c76959c63493685e7e3617e7 Mon Sep 17 00:00:00 2001 From: Jake Date: Fri, 17 Apr 2026 13:06:36 +0100 Subject: [PATCH] =?UTF-8?q?ui:=20Day=203=20=E2=80=94=20global=20toast=20sy?= =?UTF-8?q?stem=20+=20first=20error-toast=20wiring=20on=20DictationPage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit THE PROBLEM (Codex review + architecture-review.md §1, §14): Multiple critical paths discard errors silently — `let _ = insert_transcript(...)`, `.catch(() => {})`, inline `error = ...` state that not every page surfaces. ND users in particular need explicit feedback when something fails; silent failure is worse than no feature. SHIPPED: src/lib/stores/toasts.svelte.js (new): - Minimal in-house toast store (~80 lines, no svelte-french-toast dep) - Severity → palette: info/success → moss, warn → signal, error → ember - Auto-dismiss durations per severity (success 3s, info 4s, warn 6s, error sticky) - `invokeWithToast(invoke, command, args, errorTitle?)` helper wraps a Tauri invoke and toasts on failure while still throwing for callers that need to handle the error themselves src/lib/components/ToastViewport.svelte (new): - Reads from the toasts store - Bottom-right stack, max-width clamp on small screens - aria-live=polite + role=alert on error toasts (screen-reader friendly) - Slide-in animation honours `html.reduce-motion` for the existing preferences toggle - Severity left-border in brand colours via existing CSS variables src/routes/+layout.svelte: - Mounts once at the app root so toasts work from any page or window src/lib/pages/DictationPage.svelte: - First error-toast wiring: failures starting recording now show a sticky toast in addition to the inline `error` panel - Replaces a previously easy-to-miss failure-mode NEXT (Day 4 batch): - Wire toasts into FilesPage, HistoryPage, SettingsPage, ModelDownloader (all currently swallow errors) - Add `add_transcript`, `list_transcripts`, `update_transcript` (closes the long-standing TODO Codex flagged), `delete_transcript` Tauri commands wrapping the existing storage layer - Switch addToHistory to dual-write (localStorage + SQLite) so the canonical store catches up with the actual transcript flow - FTS5 transcripts_fts virtual table + search command - Wrap multi-row writes (decompose_and_store) in DB transactions --- src/lib/components/ToastViewport.svelte | 129 ++++++++++++++++++++++++ src/lib/pages/DictationPage.svelte | 5 + src/lib/stores/toasts.svelte.js | 83 +++++++++++++++ src/routes/+layout.svelte | 22 ++++ 4 files changed, 239 insertions(+) create mode 100644 src/lib/components/ToastViewport.svelte create mode 100644 src/lib/stores/toasts.svelte.js diff --git a/src/lib/components/ToastViewport.svelte b/src/lib/components/ToastViewport.svelte new file mode 100644 index 0000000..d5aea97 --- /dev/null +++ b/src/lib/components/ToastViewport.svelte @@ -0,0 +1,129 @@ + + +
+ {#each toasts.items as toast (toast.id)} +
+
+
{toast.title}
+ {#if toast.body} +
{toast.body}
+ {/if} +
+ +
+ {/each} +
+ + diff --git a/src/lib/pages/DictationPage.svelte b/src/lib/pages/DictationPage.svelte index dfee3eb..50c3a64 100644 --- a/src/lib/pages/DictationPage.svelte +++ b/src/lib/pages/DictationPage.svelte @@ -2,6 +2,7 @@ import { onMount, onDestroy } from "svelte"; import { Channel, invoke } from "@tauri-apps/api/core"; import { page, settings, templates, profiles, addToHistory, addTask, tasks } from "$lib/stores/page.svelte.js"; + import { toasts } from "$lib/stores/toasts.svelte.js"; import Card from "$lib/components/Card.svelte"; import ModelDownloader from "$lib/components/ModelDownloader.svelte"; import { exportTranscript } from "$lib/utils/export.js"; @@ -342,6 +343,10 @@ timerInterval = setInterval(updateTimer, 1000); } catch (err) { error = typeof err === "string" ? err : err?.message || "Microphone error"; + // Surface the failure as a sticky error toast so it does not get + // swallowed if the inline `error` panel is not visible. + // (Day 3 of the upgrade plan) + toasts.error("Could not start recording", error); page.status = "Error"; page.statusColor = "#e87171"; cleanup(); diff --git a/src/lib/stores/toasts.svelte.js b/src/lib/stores/toasts.svelte.js new file mode 100644 index 0000000..260750a --- /dev/null +++ b/src/lib/stores/toasts.svelte.js @@ -0,0 +1,83 @@ +// Minimal toast store. Roll-our-own (no svelte-french-toast etc) so Kon +// stays lean. Toasts auto-dismiss after `duration` ms unless duration is 0 +// (sticky) or unless the user clicks the close button. +// +// Severity → colour: +// info → moss (informational, default 4s) +// success → moss (operation succeeded, default 3s) +// warn → signal (degraded but not failed, default 6s) +// error → ember (operation failed, sticky until dismissed) +// +// Usage from a component: +// import { toasts } from '$lib/stores/toasts.svelte.js'; +// toasts.error('Could not start recording', 'Selected microphone disappeared.'); +// toasts.success('Saved'); + +let nextId = 1; + +function defaultDuration(severity) { + switch (severity) { + case 'success': return 3000; + case 'info': return 4000; + case 'warn': return 6000; + case 'error': return 0; // sticky + default: return 4000; + } +} + +function createToastsStore() { + // $state requires a class field or top-level let in Svelte 5; we expose + // `items` via a getter on the singleton. + let items = $state([]); + + function show(severity, title, body) { + const id = nextId++; + const duration = defaultDuration(severity); + const toast = { id, severity, title, body: body ?? '', duration }; + items.push(toast); + if (duration > 0) { + setTimeout(() => dismiss(id), duration); + } + return id; + } + + function dismiss(id) { + const ix = items.findIndex(t => t.id === id); + if (ix >= 0) items.splice(ix, 1); + } + + function dismissAll() { + items.length = 0; + } + + return { + get items() { return items; }, + info: (title, body) => show('info', title, body), + success: (title, body) => show('success', title, body), + warn: (title, body) => show('warn', title, body), + error: (title, body) => show('error', title, body), + dismiss, + dismissAll, + }; +} + +export const toasts = createToastsStore(); + +// Helper: wrap a Tauri invoke and toast on failure. Returns the result on +// success, throws on failure (so callers that need to handle the error +// can still try/catch). +// +// import { invoke } from '@tauri-apps/api/core'; +// import { invokeWithToast } from '$lib/stores/toasts.svelte.js'; +// const result = await invokeWithToast('start_native_capture', { deviceName }); +// +// Optional `errorTitle` overrides the default ("Action failed"). +export async function invokeWithToast(invokeFn, command, args, errorTitle) { + try { + return await invokeFn(command, args); + } catch (err) { + const msg = typeof err === 'string' ? err : (err?.message ?? String(err)); + toasts.error(errorTitle ?? 'Action failed', msg); + throw err; + } +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 41995ec..5157cb8 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -5,14 +5,17 @@ import Sidebar from "$lib/Sidebar.svelte"; import TaskSidebar from "$lib/components/TaskSidebar.svelte"; import Titlebar from "$lib/components/Titlebar.svelte"; + import ToastViewport from "$lib/components/ToastViewport.svelte"; import { page, settings, saveSettings } from "$lib/stores/page.svelte.js"; import { getPreferences, updatePreferences } from "$lib/stores/preferences.svelte.js"; + import { hasTauriRuntime } from "$lib/utils/runtime.js"; import { page as sveltePage } from "$app/stores"; let { children } = $props(); const prefs = getPreferences(); + const tauriRuntimeAvailable = hasTauriRuntime(); // Detect secondary windows (float, viewer) — they use +layout@.svelte @@ -49,6 +52,10 @@ let hotkeyBackend = $state("unknown"); // "evdev" | "tauri" | "unavailable" async function initHotkeyBackend() { + if (!tauriRuntimeAvailable) { + hotkeyBackend = "unavailable"; + return; + } try { const isWayland = await invoke("is_wayland_session"); if (isWayland) { @@ -73,6 +80,7 @@ } async function registerGlobalHotkey(hotkey) { + if (!tauriRuntimeAvailable) return; if (hotkeyBackend === "unknown") return; // not yet initialised try { @@ -106,6 +114,7 @@ // Listen for evdev hotkey events from the Rust backend let unlistenEvdev = null; async function setupEvdevListener() { + if (!tauriRuntimeAvailable) return; const { listen } = await import("@tauri-apps/api/event"); unlistenEvdev = await listen("kon:hotkey-pressed", () => { if (page.current !== "dictation") page.current = "dictation"; @@ -161,6 +170,11 @@ handleResize(); window.addEventListener("resize", handleResize); + if (!tauriRuntimeAvailable) { + hotkeyBackend = "unavailable"; + return; + } + // Detect and initialise the correct hotkey backend await initHotkeyBackend(); @@ -177,6 +191,9 @@ onDestroy(() => { window.removeEventListener("resize", handleResize); + if (!tauriRuntimeAvailable) { + return; + } if (hotkeyBackend === "evdev") { invoke("stop_evdev_hotkey").catch(() => {}); } else if (hotkeyBackend === "tauri" && registeredHotkey) { @@ -220,3 +237,8 @@ {/if} + + +