diff --git a/apps/mana/apps/web/src/hooks.server.ts b/apps/mana/apps/web/src/hooks.server.ts index 165d61c25..c5f9c01d5 100644 --- a/apps/mana/apps/web/src/hooks.server.ts +++ b/apps/mana/apps/web/src/hooks.server.ts @@ -134,6 +134,8 @@ window.__PUBLIC_GLITCHTIP_DSN__ = ${JSON.stringify(PUBLIC_GLITCHTIP_DSN)}; const isDev = process.env.NODE_ENV !== 'production'; setSecurityHeaders(response, { + // Allow mana-media images (localhost in dev, https in prod) + imgSrc: isDev ? ['http://localhost:*'] : [], // @huggingface/transformers (used by @mana/local-llm) lazy-loads the // onnxruntime-web WASM loader from jsDelivr at backend selection // time via a dynamic import(). Browsers route dynamic imports diff --git a/apps/mana/apps/web/src/lib/components/wallpaper/WallpaperLayer.svelte b/apps/mana/apps/web/src/lib/components/wallpaper/WallpaperLayer.svelte new file mode 100644 index 000000000..3b84b4771 --- /dev/null +++ b/apps/mana/apps/web/src/lib/components/wallpaper/WallpaperLayer.svelte @@ -0,0 +1,108 @@ + + +{#if isActive} + + {#key bgStyle} +
+ {#if overlayStyle} +
+ {/if} +
+ {/key} +{/if} + + diff --git a/apps/mana/apps/web/src/lib/components/wallpaper/WallpaperPicker.svelte b/apps/mana/apps/web/src/lib/components/wallpaper/WallpaperPicker.svelte new file mode 100644 index 000000000..3602ad1de --- /dev/null +++ b/apps/mana/apps/web/src/lib/components/wallpaper/WallpaperPicker.svelte @@ -0,0 +1,746 @@ + + +
+ +
+ {#if hasMultipleScenes} +
+ + +
+ {:else} +
+ {/if} + + {#if currentSource.type !== 'none'} + + {/if} +
+ + +
+ {#each tabs as tab} + + {/each} +
+ + + {#if activeTab === 'gradients'} + +

+ Empfohlen + ({currentVariant}) +

+
+ {#each gradientPresets as gradient} + + {/each} +
+ + + {#each Object.entries(GRADIENT_PRESETS).filter(([v]) => v !== currentVariant) as [variant, presets]} +

+ {variant} +

+
+ {#each presets as gradient} + + {/each} +
+ {/each} + {:else if activeTab === 'images'} + {#if PREDEFINED_WALLPAPERS.length === 0} +
+ +

Hintergrundbilder kommen bald

+
+ {:else} + {#if variantWallpapers.length > 0} +

+ Empfohlen + ({currentVariant}) +

+
+ {#each variantWallpapers as wp} + + {/each} +
+ {/if} + + {#if otherWallpapers.length > 0} +

+ Weitere +

+
+ {#each otherWallpapers as wp} + + {/each} +
+ {/if} + {/if} + {:else if activeTab === 'upload'} + +
!uploading && fileInput?.click()} + onkeydown={(e) => { + if ((e.key === 'Enter' || e.key === ' ') && !uploading) { + e.preventDefault(); + fileInput?.click(); + } + }} + ondragover={handleDragOver} + ondragleave={handleDragLeave} + ondrop={handleDrop} + > + {#if uploading} + +

Wird hochgeladen...

+ {:else} + +

+ {isDragging ? 'Hier ablegen' : 'Bild hochladen'} +

+

JPG, PNG, WebP — Drag & Drop oder Klick

+ {/if} +
+ + + + {#if uploadError} +
+ {uploadError} + +
+ {/if} + + + {#if loadingGallery} +
+ + Lade Bilder... +
+ {:else if uploadedWallpapers.length > 0} +

+ Eigene Bilder +

+
+ {#each uploadedWallpapers as media (media.id)} +
+ + +
+ {/each} +
+ {/if} + {/if} + + +
+

Overlay

+ +
+
+ + {blur}px +
+ +
+ +
+
+ + {Math.round(overlayOpacity * 100)}% +
+ +
+
+
+ + diff --git a/apps/mana/apps/web/src/lib/config/wallpapers.ts b/apps/mana/apps/web/src/lib/config/wallpapers.ts new file mode 100644 index 000000000..f867e5833 --- /dev/null +++ b/apps/mana/apps/web/src/lib/config/wallpapers.ts @@ -0,0 +1,85 @@ +/** + * Predefined Wallpaper Registry + * + * Bundled wallpaper images shipped with the app. Each theme variant + * gets 2–3 curated images. Images live in /static/wallpapers/ as WebP, + * with thumbnails in /static/wallpapers/thumbs/. + * + * Phase 2 will populate actual images — for now this is the registry structure. + */ + +import type { ThemeVariant, WallpaperGradient } from '@mana/shared-theme'; + +export interface PredefinedWallpaper { + id: string; + /** Theme variant this wallpaper was designed for (shown first in that theme). */ + variant: ThemeVariant; + /** Full-size image URL (1920×1080 WebP). */ + url: string; + /** Thumbnail URL (320×180 WebP) for the picker grid. */ + thumbUrl: string; + /** Display label in the picker. */ + label: string; +} + +/** + * All predefined wallpapers. Will be populated in Phase 2 with actual images. + * For now, the array is empty so the system works end-to-end without images. + */ +export const PREDEFINED_WALLPAPERS: PredefinedWallpaper[] = [ + // Phase 2: add entries like: + // { id: 'ocean-1', variant: 'ocean', url: '/wallpapers/ocean-1.webp', thumbUrl: '/wallpapers/thumbs/ocean-1.webp', label: 'Ocean Waves' }, +]; + +/** + * Look up a predefined wallpaper by ID. + */ +export function getPredefinedWallpaper(id: string): PredefinedWallpaper | undefined { + return PREDEFINED_WALLPAPERS.find((w) => w.id === id); +} + +/** + * Get predefined wallpapers for a specific theme variant. + */ +export function getWallpapersForVariant(variant: ThemeVariant): PredefinedWallpaper[] { + return PREDEFINED_WALLPAPERS.filter((w) => w.variant === variant); +} + +/** + * Theme-aware gradient presets. Each variant gets a few curated gradients + * that complement its color palette. + */ +export const GRADIENT_PRESETS: Record = { + ocean: [ + { type: 'gradient', colors: ['#0f0c29', '#302b63', '#24243e'], angle: 180 }, + { type: 'gradient', colors: ['#005c97', '#363795'], angle: 135 }, + ], + lume: [ + { type: 'gradient', colors: ['#ffecd2', '#fcb69f'], angle: 135 }, + { type: 'gradient', colors: ['#f5f7fa', '#c3cfe2'], angle: 180 }, + ], + nature: [ + { type: 'gradient', colors: ['#134e5e', '#71b280'], angle: 180 }, + { type: 'gradient', colors: ['#0b8793', '#360033'], angle: 135 }, + ], + stone: [ + { type: 'gradient', colors: ['#3e3e3e', '#1a1a1a'], angle: 180 }, + { type: 'gradient', colors: ['#bdc3c7', '#2c3e50'], angle: 135 }, + ], + sunset: [ + { type: 'gradient', colors: ['#ff6b6b', '#feca57', '#48dbfb'], angle: 135 }, + { type: 'gradient', colors: ['#f12711', '#f5af19'], angle: 180 }, + ], + midnight: [ + { type: 'gradient', colors: ['#0f2027', '#203a43', '#2c5364'], angle: 180 }, + { type: 'gradient', colors: ['#141e30', '#243b55'], angle: 135 }, + ], + rose: [ + { type: 'gradient', colors: ['#fbc2eb', '#a6c1ee'], angle: 135 }, + { type: 'gradient', colors: ['#ee9ca7', '#ffdde1'], angle: 180 }, + ], + lavender: [ + { type: 'gradient', colors: ['#667eea', '#764ba2'], angle: 135 }, + { type: 'gradient', colors: ['#a18cd1', '#fbc2eb'], angle: 180 }, + ], +}; diff --git a/apps/mana/apps/web/src/lib/stores/wallpaper.svelte.ts b/apps/mana/apps/web/src/lib/stores/wallpaper.svelte.ts new file mode 100644 index 000000000..2938a1a6c --- /dev/null +++ b/apps/mana/apps/web/src/lib/stores/wallpaper.svelte.ts @@ -0,0 +1,165 @@ +/** + * Wallpaper Store — reactive wallpaper resolution and mutations. + * + * Uses a local $state for immediate UI feedback. Persists to userSettings + * (global) and Dexie (per-scene) in the background. + * + * Resolution order: + * 1. Preview (transient hover state, not persisted) + * 2. Active scene's wallpaper (per-scene override) + * 3. Local global state (synced to userSettings) + * 4. Default: { source: { type: 'none' } } + */ + +import { browser } from '$app/environment'; +import type { WallpaperConfig } from '@mana/shared-theme'; +import { userSettings } from './user-settings.svelte'; +import { workbenchScenesStore } from './workbench-scenes.svelte'; +import { DEFAULT_WALLPAPER_CONFIG } from '$lib/types/wallpaper'; + +const LS_KEY = 'mana:wallpaper:global'; + +// ─── Local state (immediate, survives page nav) ────────────── + +function loadFromStorage(): WallpaperConfig { + if (!browser) return DEFAULT_WALLPAPER_CONFIG; + try { + const raw = localStorage.getItem(LS_KEY); + if (raw) return JSON.parse(raw) as WallpaperConfig; + } catch { + /* ignore */ + } + return DEFAULT_WALLPAPER_CONFIG; +} + +function saveToStorage(config: WallpaperConfig) { + if (!browser) return; + try { + if (config.source.type === 'none') { + localStorage.removeItem(LS_KEY); + } else { + localStorage.setItem(LS_KEY, JSON.stringify(config)); + } + } catch { + /* ignore */ + } +} + +let localGlobal = $state(loadFromStorage()); + +// ─── Preview state (transient, not persisted) ──────────────── + +let previewConfig = $state(null); + +// ─── Reactive derivation ───────────────────────────────────── + +/** The persisted effective wallpaper (without preview). */ +let persistedEffective = $derived.by((): WallpaperConfig => { + const scene = workbenchScenesStore.activeScene; + if (scene?.wallpaper && scene.wallpaper.source.type !== 'none') { + return scene.wallpaper; + } + if (localGlobal.source.type !== 'none') { + return localGlobal; + } + return DEFAULT_WALLPAPER_CONFIG; +}); + +/** What the WallpaperLayer actually renders: preview > persisted. */ +let displayState = $derived.by((): WallpaperConfig => { + if (previewConfig && previewConfig.source.type !== 'none') { + return previewConfig; + } + return persistedEffective; +}); + +// ─── Public store ──────────────────────────────────────────── + +export const wallpaperStore = { + /** What should be rendered (preview if active, otherwise persisted). */ + get effective(): WallpaperConfig { + return displayState; + }, + + /** The persisted wallpaper (ignoring any hover preview). */ + get persisted(): WallpaperConfig { + return persistedEffective; + }, + + /** Whether a wallpaper (not 'none') is currently displayed. */ + get hasWallpaper(): boolean { + return displayState.source.type !== 'none'; + }, + + /** Whether a hover preview is currently active. */ + get isPreviewing(): boolean { + return previewConfig !== null; + }, + + /** The global wallpaper config. */ + get global(): WallpaperConfig { + return localGlobal; + }, + + /** The active scene's wallpaper override (may be undefined). */ + get sceneOverride(): WallpaperConfig | undefined { + return workbenchScenesStore.activeScene?.wallpaper; + }, + + // ── Preview (hover) ─────────────────────────────────────── + + /** Show a transient preview (e.g. on hover). Not persisted. */ + preview(config: WallpaperConfig) { + previewConfig = config; + }, + + /** Clear the transient preview (e.g. on mouse leave). */ + clearPreview() { + previewConfig = null; + }, + + // ── Mutations (persisted) ───────────────────────────────── + + /** Set the global wallpaper (applies to all scenes without an override). */ + async setGlobal(config: WallpaperConfig) { + previewConfig = null; + localGlobal = config; + saveToStorage(config); + userSettings.updateGlobal({ wallpaper: config }).catch(() => {}); + }, + + /** Clear the global wallpaper (revert to theme default). */ + async clearGlobal() { + previewConfig = null; + localGlobal = DEFAULT_WALLPAPER_CONFIG; + saveToStorage(DEFAULT_WALLPAPER_CONFIG); + userSettings.updateGlobal({ wallpaper: DEFAULT_WALLPAPER_CONFIG }).catch(() => {}); + }, + + /** Set a wallpaper override for the currently active scene. */ + async setSceneWallpaper(config: WallpaperConfig) { + previewConfig = null; + const sceneId = workbenchScenesStore.activeSceneId; + if (!sceneId) return; + await patchSceneWallpaper(sceneId, config); + }, + + /** Clear the active scene's wallpaper override (fall back to global). */ + async clearSceneWallpaper() { + previewConfig = null; + const sceneId = workbenchScenesStore.activeSceneId; + if (!sceneId) return; + await patchSceneWallpaper(sceneId, undefined); + }, +}; + +// ─── Internal: patch scene wallpaper via Dexie ─────────────── + +async function patchSceneWallpaper(sceneId: string, wallpaper: WallpaperConfig | undefined) { + const { db } = await import('$lib/data/database'); + const clean = wallpaper ? structuredClone(wallpaper) : undefined; + await db.table('workbenchScenes').update(sceneId, { + wallpaper: clean, + updatedAt: new Date().toISOString(), + }); +} diff --git a/apps/mana/apps/web/src/lib/stores/workbench-scenes.svelte.ts b/apps/mana/apps/web/src/lib/stores/workbench-scenes.svelte.ts index de26a99dd..0b4c00b3d 100644 --- a/apps/mana/apps/web/src/lib/stores/workbench-scenes.svelte.ts +++ b/apps/mana/apps/web/src/lib/stores/workbench-scenes.svelte.ts @@ -65,6 +65,7 @@ function toScene(local: LocalWorkbenchScene): WorkbenchScene { icon: local.icon, openApps: local.openApps ?? [], order: local.order, + wallpaper: local.wallpaper, }; } @@ -97,7 +98,7 @@ function pickActiveId(scenes: WorkbenchScene[], current: string | null): string async function patchScene( id: string, - patch: Partial> + patch: Partial> ) { // Strip Svelte 5 $state proxies — IndexedDB's structured clone can't serialize them. const clean = $state.snapshot({ ...patch, updatedAt: nowIso() }); diff --git a/apps/mana/apps/web/src/lib/types/wallpaper.ts b/apps/mana/apps/web/src/lib/types/wallpaper.ts new file mode 100644 index 000000000..3628f25ab --- /dev/null +++ b/apps/mana/apps/web/src/lib/types/wallpaper.ts @@ -0,0 +1,32 @@ +/** + * Wallpaper / Background Configuration Types + * + * Re-exports from @mana/shared-theme (canonical source) plus app-local constants. + * + * The wallpaper system supports four sources: + * - none: theme default (solid color via CSS variables) + * - predefined: bundled images per theme variant + * - generated: CSS gradients/solids stored as parameters + * - upload: user-uploaded images via mana-media + * + * Resolution: activeScene.wallpaper > globalSettings.wallpaper > DEFAULT_WALLPAPER_CONFIG + */ + +export type { + WallpaperSource, + WallpaperSourceNone, + WallpaperSourcePredefined, + WallpaperSourceGenerated, + WallpaperSourceUpload, + WallpaperSolid, + WallpaperGradient, + WallpaperOverlay, + WallpaperConfig, +} from '@mana/shared-theme'; + +import type { WallpaperConfig } from '@mana/shared-theme'; + +/** Default (empty) wallpaper config — theme background color, no image. */ +export const DEFAULT_WALLPAPER_CONFIG: WallpaperConfig = { + source: { type: 'none' }, +}; diff --git a/apps/mana/apps/web/src/lib/types/workbench-scenes.ts b/apps/mana/apps/web/src/lib/types/workbench-scenes.ts index 8212fc84f..d73d9a0da 100644 --- a/apps/mana/apps/web/src/lib/types/workbench-scenes.ts +++ b/apps/mana/apps/web/src/lib/types/workbench-scenes.ts @@ -12,6 +12,7 @@ */ import type { BaseRecord } from '@mana/local-store'; +import type { WallpaperConfig } from '@mana/shared-theme'; export interface WorkbenchSceneApp { appId: string; @@ -29,6 +30,8 @@ export interface WorkbenchScene { openApps: WorkbenchSceneApp[]; /** Sort order in the scene tab bar. */ order: number; + /** Per-scene wallpaper override. When set, takes priority over globalSettings.wallpaper. */ + wallpaper?: WallpaperConfig; } /** Dexie row shape (adds the BaseRecord audit fields stamped by hooks). */ diff --git a/apps/mana/apps/web/src/routes/(app)/+layout.svelte b/apps/mana/apps/web/src/routes/(app)/+layout.svelte index 0d30726e5..74eec1e5a 100644 --- a/apps/mana/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/mana/apps/web/src/routes/(app)/+layout.svelte @@ -44,6 +44,16 @@ import { startLlmQueue, stopLlmQueue } from '$lib/llm-queue'; import { llmSettingsState, updateLlmSettings, tierLabel, type LlmTier } from '@mana/shared-llm'; import { isLocalLlmSupported, getLocalLlmStatus, loadLocalLlm } from '@mana/local-llm'; + import { + getLocalSttStatus, + loadLocalStt, + isLocalSttSupported, + MODELS as STT_MODELS, + DEFAULT_MODEL as STT_DEFAULT_MODEL, + type ModelKey as SttModelKey, + } from '@mana/local-stt'; + import { useLocalStt } from '$lib/components/voice/use-local-stt.svelte'; + import { Microphone, Stop } from '@mana/shared-icons'; import { startMemoroLlmWatcher, stopMemoroLlmWatcher, @@ -76,6 +86,8 @@ import { registerAllProviders } from '$lib/search/providers'; import { initSharedUload } from '@mana/shared-uload'; import type { DragPayload } from '@mana/shared-ui/dnd'; + import WallpaperLayer from '$lib/components/wallpaper/WallpaperLayer.svelte'; + import { wallpaperStore } from '$lib/stores/wallpaper.svelte'; let { children }: { children: Snippet } = $props(); @@ -176,6 +188,9 @@ // ── AI Tier Selector (PillNav dropdown) ───────────────── const webgpuSupported = isLocalLlmSupported(); const localLlmStatus = getLocalLlmStatus(); + const sttSupported = isLocalSttSupported(); + const localSttStatus = getLocalSttStatus(); + let selectedSttModel = $state(STT_DEFAULT_MODEL); const llmSettings = $derived(llmSettingsState.current); function toggleAiTier(tier: LlmTier) { @@ -187,36 +202,148 @@ } const TIER_TOGGLE_LIST: Array<{ tier: LlmTier; shortLabel: string; icon: string }> = [ - { tier: 'browser', shortLabel: 'Browser (Gemma 4)', icon: 'cpu' }, - { tier: 'mana-server', shortLabel: 'Server (Gemma 4)', icon: 'server' }, + { tier: 'browser', shortLabel: 'Lokal (Gemma 4)', icon: 'robot' }, + { tier: 'mana-server', shortLabel: 'Server (Gemma 4)', icon: 'globe' }, { tier: 'cloud', shortLabel: 'Cloud (Gemini)', icon: 'cloud' }, ]; let aiTierItems = $derived([ - // Tier toggles + // Tier toggles — browser tier item and its model-status buddy share a + // group so PillDropdownBar renders them as a paired pill. ...TIER_TOGGLE_LIST.filter((t) => t.tier !== 'browser' || webgpuSupported).map((t) => ({ id: `ai-tier-${t.tier}`, label: t.shortLabel, icon: t.icon, active: llmSettings.allowedTiers.includes(t.tier), onClick: () => toggleAiTier(t.tier), + ...(t.tier === 'browser' ? { group: 'local-llm' } : {}), })), - // Browser model status / load button + // Browser model status / load button (grouped with the "Lokal" toggle). + // Handles all LoadingStatus states so the user sees feedback during + // download, initialization, and on error (e.g. worker crash). ...(llmSettings.allowedTiers.includes('browser') && webgpuSupported ? [ - { - id: 'ai-browser-status', - label: - localLlmStatus.current.state === 'ready' - ? '✓ Modell geladen' - : localLlmStatus.current.state === 'downloading' - ? `Lade… ${((localLlmStatus.current as { progress: number }).progress * 100).toFixed(0)}%` - : 'Modell laden (~500 MB)', - icon: localLlmStatus.current.state === 'ready' ? 'check' : 'download', - disabled: localLlmStatus.current.state === 'ready', - onClick: - localLlmStatus.current.state !== 'ready' ? () => void loadLocalLlm() : undefined, - }, + (() => { + const s = localLlmStatus.current; + const state = s.state; + let label: string; + let icon: string; + let danger = false; + let disabled = false; + + switch (state) { + case 'ready': + label = 'Geladen'; + icon = 'check'; + disabled = true; + break; + case 'downloading': + label = `Lade… ${((s as { progress: number }).progress * 100).toFixed(0)}%`; + icon = 'clock'; + disabled = true; + break; + case 'loading': + label = 'Initialisiere…'; + icon = 'clock'; + disabled = true; + break; + case 'checking': + label = 'Prüfe…'; + icon = 'clock'; + disabled = true; + break; + case 'error': + label = 'Fehler — erneut versuchen'; + icon = 'bell'; + danger = true; + break; + default: + label = 'Modell laden'; + icon = 'cloud'; + } + + return { + id: 'ai-browser-status', + label, + icon, + group: 'local-llm', + danger, + disabled, + progress: state === 'downloading' ? (s as { progress: number }).progress : undefined, + onClick: !disabled ? () => void loadLocalLlm() : undefined, + }; + })(), + ] + : []), + // ── STT section ────────────────────────────────── + { id: 'stt-divider', label: '', divider: true }, + // STT model selector — each model is a pill, active = currently selected + ...(sttSupported + ? (Object.entries(STT_MODELS) as [SttModelKey, (typeof STT_MODELS)[SttModelKey]][]).map( + ([key, model]) => ({ + id: `stt-model-${key}`, + label: model.displayName, + icon: 'mic' as const, + active: selectedSttModel === key, + onClick: () => { + selectedSttModel = key; + void loadLocalStt(key); + }, + }) + ) + : []), + // STT model status (grouped with selected model) + ...(sttSupported + ? [ + (() => { + const s = localSttStatus.current; + const state = s.state; + let label: string; + let icon: string; + let danger = false; + let disabled = false; + + switch (state) { + case 'ready': + label = 'STT bereit'; + icon = 'check'; + disabled = true; + break; + case 'downloading': + label = `STT Lade… ${((s as { progress: number }).progress * 100).toFixed(0)}%`; + icon = 'clock'; + disabled = true; + break; + case 'loading': + label = 'STT lädt…'; + icon = 'clock'; + disabled = true; + break; + case 'checking': + label = 'STT prüft…'; + icon = 'clock'; + disabled = true; + break; + case 'error': + label = 'STT Fehler'; + icon = 'bell'; + danger = true; + break; + default: + label = 'STT Modell laden'; + icon = 'mic'; + } + + return { + id: 'stt-status', + label, + icon, + danger, + disabled, + progress: state === 'downloading' ? (s as { progress: number }).progress : undefined, + onClick: !disabled ? () => void loadLocalStt(selectedSttModel) : undefined, + }; + })(), ] : []), // Divider + settings link @@ -262,7 +389,7 @@ items.push({ id: 'sync-active', label: 'Cloud Sync aktiv', - icon: 'cloudCheck', + icon: 'cloud', active: true, disabled: true, }); @@ -275,6 +402,7 @@ items.push({ id: 'sync-next', label: `Nächste Abbuchung: ${date}`, + icon: 'calendar', disabled: true, }); } @@ -282,19 +410,20 @@ items.push({ id: 'sync-paused', label: 'Sync pausiert — Credits aufladen', - icon: 'warning', + icon: 'bell', onClick: () => goto('/credits?tab=packages'), }); } else { items.push({ id: 'sync-inactive', label: 'Sync aktivieren', - icon: 'cloudArrowUp', + icon: 'cloud', onClick: () => goto('/settings/sync'), }); items.push({ id: 'sync-info', label: 'Nur lokal — ab 30 Credits/Monat', + icon: 'creditCard', disabled: true, }); } @@ -303,7 +432,7 @@ items.push({ id: 'sync-settings', label: 'Sync-Einstellungen', - icon: 'gear', + icon: 'settings', onClick: () => goto('/settings/sync'), }); @@ -669,6 +798,28 @@ return searchRegistry.search(query, { signal }); }; + // ── Local STT (speech-to-text via Whisper in browser) ─── + const localStt = useLocalStt({ language: ($locale || 'de') === 'de' ? 'de' : 'en' }); + + // When STT finishes transcription, feed the text into the current + // module's QuickInputBar adapter (create action). This makes voice + // input context-aware: on /todo it creates a task, on /calendar an + // event, on / it searches, etc. + // Transcribed text is injected into the QuickInputBar so the user + // can see, edit, and confirm it before creating anything. + let sttInjectedText = $state(''); + $effect(() => { + const t = localStt.text; + const e = localStt.error; + if (e) { + console.warn('[layout-stt] Error:', e); + } + if (t) { + console.log('[layout-stt] Transcribed text:', t); + sttInjectedText = t; + } + }); + // ── QuickInputBar — context-aware adapter per module ───── let inputBarAdapter = $state(createFallbackAdapter(searchRegistry)); let activeModulePrefix = $state(null); @@ -740,7 +891,9 @@ {/if} -
+
+ + {#if !isFullscreen} @@ -830,7 +983,30 @@ onDefaultChange={inputBarAdapter.onDefaultChange} highlightPatterns={inputBarAdapter.highlightPatterns} positioning="static" + injectedText={sttInjectedText} > + {#snippet leftAction()} + + {/snippet} {#snippet rightAction()}