From 279456f45c40b069ae9b6bcc418d07bbae18fc62 Mon Sep 17 00:00:00 2001 From: Braden Wong <13159333+braden-w@users.noreply.github.com> Date: Sat, 20 Jun 2026 17:55:37 -0700 Subject: [PATCH 1/3] feat(whispering): redesign first-run onboarding as a two-column layout The setup screen wore the narrow max-w-lg centered shape meant for the capture column, so a tall setup card sat stranded beside ~560px of dead horizontal space and, with sm:py-0, hugged the top and bottom window edges. Split the first-run branch into a two-column grid: a left value-prop column (hero plus Private/On-device/Open-source trust items) beside the existing setup card on the right, stacking to a single column below lg. The hero is now a shared snippet so the capture view keeps its centered single-column layout. Columns are top-aligned so the headings share a line, and the container always keeps vertical padding so content never touches the window edges. --- apps/whispering/src/routes/(app)/+page.svelte | 492 ++++++++++-------- 1 file changed, 278 insertions(+), 214 deletions(-) diff --git a/apps/whispering/src/routes/(app)/+page.svelte b/apps/whispering/src/routes/(app)/+page.svelte index 45d18a73cb..50b0161f28 100644 --- a/apps/whispering/src/routes/(app)/+page.svelte +++ b/apps/whispering/src/routes/(app)/+page.svelte @@ -2,10 +2,14 @@ import { Button } from '@epicenter/ui/button'; import { confirmationDialog } from '@epicenter/ui/confirmation-dialog'; import { FileDropZone } from '@epicenter/ui/file-drop-zone'; + import * as Item from '@epicenter/ui/item'; import * as Kbd from '@epicenter/ui/kbd'; import { Link } from '@epicenter/ui/link'; import * as SectionHeader from '@epicenter/ui/section-header'; import * as ToggleGroup from '@epicenter/ui/toggle-group'; + import Cpu from '@lucide/svelte/icons/cpu'; + import Heart from '@lucide/svelte/icons/heart'; + import ShieldCheck from '@lucide/svelte/icons/shield-check'; import XIcon from '@lucide/svelte/icons/x'; import { createQuery } from '@tanstack/svelte-query'; import type { UnlistenFn } from '@tauri-apps/api/event'; @@ -179,10 +183,15 @@ Whispering -
- +{#snippet hero(layout: 'center' | 'split')} +
- + Press shortcut → speak → get text. Free and open source ❤️
+{/snippet} - - +
{#if !transcriptionReadiness.isReady} -
-
-

Set up transcription

-

- {transcriptionReadiness.primaryIssue ?? - 'Choose how Whispering turns your speech into text.'} -

-
- -
- {:else} - captureSurface.current, - (surface) => { - if (!surface) return; - void selectCaptureSurface(surface as CaptureSurface); - }} - class="w-full" - > - {#each CAPTURE_SURFACE_OPTIONS as option} - {@const SurfaceIcon = CAPTURE_SURFACE_META[option.value].Icon} - + +
+
- - - - {/each} - + {@render hero('split')} + + + + + + + Private and offline + + Audio is transcribed on your device and never uploaded. + + + + + + + + + Runs on this device + + No servers, no API keys, no monthly bill. + + + + + + + + + Free and open source + + Yours to keep, audit, and extend. + + + + +
- - {#if captureSurface.current === 'manual'} -
- - {#snippet pipeline()} - - - - - - - {/snippet} - - {#if manualRecorder.state === 'RECORDING'} - - {/if} -
- {:else if captureSurface.current === 'vad'} -
- - {#snippet pipeline()} - - - - - - - {/snippet} - -
- {:else if captureSurface.current === 'import'} -
- { - if (files.length > 0) { - await importFiles({ files }); - } - }} - onFileRejected={({ file, reason }) => { - report.error({ - cause: PageError.FileRejected({ - fileName: file.name, - reason, - }).error, - title: 'File rejected', - }); - }} - class="h-32 sm:h-36 w-full" - /> - - - +
+

Set up transcription

+

+ {transcriptionReadiness.primaryIssue ?? + 'Choose how Whispering turns your speech into text.'} +

+
+ -
+
- {/if} +
+ {:else} +
+ {@render hero('center')} - {#if latestRecording} -
- { - confirmationDialog.open({ - title: 'Delete recording', - description: 'Are you sure you want to delete this recording?', - confirm: { text: 'Delete', variant: 'destructive' }, - onConfirm: () => { - services.blobs.audio.revokeUrl(latestRecording.id); - recordings.delete(latestRecording.id); - report.success({ - title: 'Deleted recording!', - description: 'Your recording has been deleted.', - }); - }, - }); + + captureSurface.current, + (surface) => { + if (!surface) return; + void selectCaptureSurface(surface as CaptureSurface); }} - /> - - {#if audioPlaybackUrlQuery.data} - - {/if} -
- {/if} + class="w-full" + > + {#each CAPTURE_SURFACE_OPTIONS as option} + {@const SurfaceIcon = CAPTURE_SURFACE_META[option.value].Icon} + + + + + {/each} + -
+ {#if captureSurface.current === 'manual'} -

- {#if manualShortcutLabel} - Click the microphone to record{tauri ? ' here' : ''}, or press - + + {#snippet pipeline()} + + + + + + + {/snippet} + + {#if manualRecorder.state === 'RECORDING'} + {/if} -

+
{:else if captureSurface.current === 'vad'} -

- {#if vadShortcutLabel} - Click the microphone to listen{tauri ? ' here' : ''}, or press +

+ + {#snippet pipeline()} + + + + + + + {/snippet} + +
+ {:else if captureSurface.current === 'import'} +
+ { + if (files.length > 0) { + await importFiles({ files }); + } + }} + onFileRejected={({ file, reason }) => { + report.error({ + cause: PageError.FileRejected({ + fileName: file.name, + reason, + }).error, + title: 'File rejected', + }); + }} + class="h-32 sm:h-36 w-full" + /> + + + + +
+ {/if} + + {#if latestRecording} +
+ { + confirmationDialog.open({ + title: 'Delete recording', + description: 'Are you sure you want to delete this recording?', + confirm: { text: 'Delete', variant: 'destructive' }, + onConfirm: () => { + services.blobs.audio.revokeUrl(latestRecording.id); + recordings.delete(latestRecording.id); + report.success({ + title: 'Deleted recording!', + description: 'Your recording has been deleted.', + }); + }, + }); + }} + /> + + {#if audioPlaybackUrlQuery.data} + + {/if} +
+ {/if} + +
+ {#if captureSurface.current === 'manual'} +

+ {#if manualShortcutLabel} + Click the microphone to record{tauri ? ' here' : ''}, or press + + {manualShortcutLabel} + + {tauri ? 'to record from anywhere.' : 'to record.'} + {:else if tauri} + Click the microphone to record, or + set a global shortcut + to record from anywhere. + {:else} + Click the microphone to start recording. + {/if} +

+ {:else if captureSurface.current === 'vad'} +

+ {#if vadShortcutLabel} + Click the microphone to listen{tauri ? ' here' : ''}, or press + + {vadShortcutLabel} + + {tauri ? 'to listen from anywhere.' : 'to listen.'} + {:else if tauri} + Click the microphone to start a voice activated session, or + set a global shortcut + to listen from anywhere. + {:else} + Click the microphone to start a voice activated session. + {/if} +

+ {/if} +

+ {#if !tauri} + Tired of switching tabs? - {vadShortcutLabel} + Get the native desktop app - {tauri ? 'to listen from anywhere.' : 'to listen.'} - {:else if tauri} - Click the microphone to start a voice activated session, or - set a global shortcut - to listen from anywhere. - {:else} - Click the microphone to start a voice activated session. {/if}

- {/if} -

- {#if !tauri} - Tired of switching tabs? - - Get the native desktop app - - {/if} -

+
{/if}
From ddb64ac124c87cb0ab6cea010fe43dbb3fb89034 Mon Sep 17 00:00:00 2001 From: Braden Wong <13159333+braden-w@users.noreply.github.com> Date: Sat, 20 Jun 2026 17:55:46 -0700 Subject: [PATCH 2/3] refactor(whispering): drop dead padding class from local model empty state The empty state passed class="py-8", but .cn-empty is scoped as .style-vega .cn-empty (specificity 0,2,0, unlayered) and already applies p-12, so the utility never took effect. Remove the no-op class. --- .../src/lib/components/settings/LocalModelSelector.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/whispering/src/lib/components/settings/LocalModelSelector.svelte b/apps/whispering/src/lib/components/settings/LocalModelSelector.svelte index 1e9b5aec9c..d50e2d351a 100644 --- a/apps/whispering/src/lib/components/settings/LocalModelSelector.svelte +++ b/apps/whispering/src/lib/components/settings/LocalModelSelector.svelte @@ -266,7 +266,7 @@ {:else if !value && customEntries.length === 0} - + From 44df7ef96721865554a7bfce4eabf05434f6a403 Mon Sep 17 00:00:00 2001 From: Braden Wong <13159333+braden-w@users.noreply.github.com> Date: Sat, 20 Jun 2026 17:57:05 -0700 Subject: [PATCH 3/3] chore: pin bun install linker to isolated bun's default linker varies by bun version, workspace setup, and lockfile configVersion, so the node_modules layout differed across machines and could flip silently on a bun upgrade. Pin it explicitly so every machine and CI produce the same isolated (pnpm-style) tree, where a package can only import the dependencies it declares. --- bunfig.toml | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 bunfig.toml diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000000..e22998f0d7 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,7 @@ +[install] +# Pin the dependency linker so every machine and CI produce the same +# node_modules layout, instead of leaving it to bun's default (which varies by +# bun version, workspace setup, and lockfile configVersion). "isolated" is the +# strict pnpm-style layout: a package can only import what it declares, so +# phantom dependencies fail loudly instead of riding on accidental hoisting. +linker = "isolated"