Skip to content

redesign first-run onboarding: single-column + decisive setup#2141

Open
braden-w wants to merge 30 commits into
mainfrom
polish/whispering-onboarding-two-column
Open

redesign first-run onboarding: single-column + decisive setup#2141
braden-w wants to merge 30 commits into
mainfrom
polish/whispering-onboarding-two-column

Conversation

@braden-w

@braden-w braden-w commented Jun 21, 2026

Copy link
Copy Markdown
Member

Reworks the Whispering first-run onboarding (the not-ready state on /) so it guides a normal user instead of describing options.

Problem

The old screen dropped the user into a "Service" dropdown of ~10 unfamiliar provider names with a model card buried beneath it, plus jargon like "Download Parakeet TDT 0.6B v3 (INT8)". A non-technical user couldn't tell what the one thing to do was, or why a 670 MB download was needed. An earlier two-column pass also left an unbalanced L-shaped void.

What changed

Single-column flow (top to bottom): hero -> setup -> trust.

  • Replaced the two-column hero/setup split with one centered max-w-2xl column; the columns could never balance (tall setup card, short trust cards).
  • Trust guarantees are a calm vertical list (one column, three rows) under the action.

Decisive setup (instead of a service menu):

  • Hide the service picker on the primary path (new hideServiceSelect on TranscriptionRuntimeConfig). The screen opens straight into the recommended service's setup: the local model download on desktop, the API-key field on web.
  • The full service picker is one "Use a different service" disclosure away for anyone who wants a cloud provider or a different model.
  • Dropped jargon from the recommended download button: "Download recommended model (670 MB)" (the card title already names the engine).

Why one screen, not a wizard

Setup is genuinely one decision (get a transcriber). Mic access and the activation shortcut already ship working by default. The /setup 4-step wizard was built (PR #1976) and deliberately removed (PR #2067) for exactly this reason, so this stays one decisive screen rather than re-adding it.

Verification

  • svelte-check: clean on all changed files (the lone error is a pre-existing vite-config/bun.lock duplicate-vite env issue).
  • Screenshotted the web build collapsed and expanded: opens into the OpenAI key setup, no service-dropdown wall, disclosure reveals the picker, trust list intact.
  • Local model download card is desktop-only (Tauri); verified the structure there by reasoning. Worth one glance in the desktop build before merge.

braden-w added 2 commits June 20, 2026 17:55
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.
…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.
@github-actions

github-actions Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Preview Deployment

App Preview URL
Whispering https://e2ad515d-whispering.epicenter.workers.dev
Landing https://705d7deb-landing.epicenter.workers.dev

These previews update automatically with new commits to this PR.
No cleanup needed — previews are version aliases on the existing workers.


Commit a1110e6

braden-w added 2 commits June 20, 2026 23:07
Replace the two-column hero/setup split with a single centered column:
hero, then the setup card as the primary action, then the three trust
guarantees as a reassurance strip below it. The columns could never
balance (the setup card is intrinsically tall, the trust cards short),
which left an L-shaped void and forced a diagonal eye path from title to
download. One column reads top to bottom: what this is, what to do, why
to trust it.

The trust cards keep their copy and styling; they lay out as a 3-up grid
on sm+ (icon on top) and collapse to stacked icon-left rows below sm.
Setup form and trust strip share one max-w-2xl width so their edges line
up. Simplify the hero snippet now that both branches center it.
@braden-w braden-w changed the title feat(whispering): redesign first-run onboarding as a two-column layout redesign first-run onboarding as a single-column flow Jun 21, 2026
braden-w added 2 commits June 20, 2026 23:29
The three-guarantee trust strip sat in the narrower column of the two-column onboarding layout, where the 3-up grid wrapped each description to three lines. Drop the grid and the sm:flex-col override so each guarantee is a plain full-width Item row (icon, title, description): compact under the setup CTA, uncramped, and the default Item shape.
…menu

A normal user opening the not-ready home screen saw a "Service" dropdown
of ~10 unfamiliar provider names and a model card buried under it, with
no sign of which one thing to do. It described options instead of
guiding. This leads with the recommended setup and hides the choice:

- Hide the service picker on the primary path (new `hideServiceSelect`
  on TranscriptionRuntimeConfig). The screen now opens straight into the
  recommended service's setup: the local model download on desktop, the
  API-key field on web.
- Tuck the full service picker behind a "Use a different service"
  disclosure for anyone who wants a cloud provider or a different model.
- Drop the jargon from the recommended download button: "Download
  recommended model (670 MB)" instead of "Download Parakeet TDT 0.6B v3
  (INT8) (~670 MB)". The card title already names the engine.

Keeps the single-column layout and the vertical trust list. Setup is
genuinely one decision, so this stays one screen rather than re-adding
the /setup wizard that PR #1976 shipped and PR #2067 deliberately
removed.
@braden-w braden-w changed the title redesign first-run onboarding as a single-column flow redesign first-run onboarding: single-column + decisive setup Jun 21, 2026
braden-w added 5 commits June 20, 2026 23:46
…core

Pull the recommended-setup-plus-disclosure block out of the first-run home
branch into a reusable TranscriptionSetup component. Behavior is unchanged
(the not-ready home screen renders identically); this just gives the
upcoming first-run wizard and the minimal not-ready screen one shared
place for the transcriber setup mechanics, so neither duplicates it.
Replace the inline minimal not-ready screen with a guided, value-first
setup flow: voice engine -> first dictation (the aha; the mic prompt
happens in-context) -> dictate-anywhere upsell (macOS only; reuses the
existing Accessibility guide and reads the live capability) -> done.

It's one universal surface, shown whenever transcription isn't ready,
first launch or a later regression alike: if you're here, something needs
setting up, so the full guided flow is the helpful thing. Steps derive
from live state (the Accessibility step is absent on web/Wayland and
adapts to the grant), the engine step composes the shared
TranscriptionSetup, and the practice step drives the real recorder.

No persisted "seen onboarding" flag and no $effect latch: a single
state, initialized synchronously from readiness (untracked, so no
first-paint flash) and flipped false only when the user finishes.
A regression re-activates it for free via SvelteKit's remount on
navigation back to home.
Three simplifications surfaced reviewing the first-run flow:

- FirstRunSetup: derive the practice transcript instead of latching it in
  an $effect. stopManualRecording awaits the whole pipeline, so the row is
  saved by the time isSuccess flips; computing it synchronously also drops
  a one-frame flash of the Record button before the post-effect write.
- Inline TranscriptionSetup into the wizard's engine step and delete it. It
  was extracted to be shared by the wizard and a minimal home not-ready
  screen, but the next commit replaced that screen with FirstRunSetup, so
  it had a single consumer, a dead class prop, and a stale shared-core
  comment.
- home: seed setupActive with a direct getTranscriptionReadiness() call
  instead of a standing $derived read once under untrack.
…ields

getTranscriptionReadiness() returned a five-field object, but every consumer
reads only isReady and primaryIssue; service, isServiceAvailable, and
isRuntimeConfigured were computed and returned for nobody. Drop them from the
type and inline the two intermediate bools into the guard cascade so it reads
as plain early returns.

Also drop the inert id FirstRunSetup passed to TranscriptionRuntimeConfig: it
only feeds the service picker, which that call hides.
braden-w added 15 commits June 21, 2026 22:23
TranscriptionRuntimeConfig rendered the service picker itself and took a
hideServiceSelect flag plus id/label/description props that fed only that
picker; the first-run wizard set the flag and supplied its own picker, so the
built-in picker and those props went inert there.

Drop the picker, the flag, and the three picker-only props. The component now
configures only the selected service, and each caller owns picker placement:
the settings page renders one above it, the first-run wizard tucks one behind
its disclosure. Same strings and provider-config UI; the settings picker moves
from Field.Group (gap-7) to Field.Set (gap-6) spacing.
On a true first run (no recordings yet) the wizard now opens with a welcome: the
Whispering mark, the tagline, and the three guarantees (private by default, runs
on your device, free and open source) as a single column of muted Item rows,
then a Get started CTA into the engine setup. It restores the trust strip the
decisive-setup pass had dropped, but as a pre-step a returning user (who already
has recordings) skips.

Gated on a mount-time snapshot of the recording count, not a persisted seen-
onboarding flag, so it stays flag-free; the snapshot is a $state read (not a
$derived) so the welcome can't flash away as the Yjs table hydrates.
…eConfig

No caller passed it once the service picker moved out; the component takes only
showAdvanced now.
…ght props

ManualRecordingAction and VadRecordingAction each derived the same eight
RecordingActionCard props (active/pending/icon/label/description/tooltip/
shortcutLabel/onclick) from their recorder state, then spelled the whole mapping
out at the call site. Extract that into createManualRecordingController and
createVadRecordingController, both satisfying a RecordingActionController
contract, and have the card take a single controller prop. The card also owns
the hide-footer-while-active rule, so callers stop repeating
active ? undefined : pipeline. Same behavior; the mapping lives in one place.
The wizard's first-dictation step rendered a bespoke mic circle, a fake EQ-bar
meter (CSS animation that ignored the actual audio), and its own start/stop
mutations. Replace all of it with the same createManualRecordingController and
RecordingActionCard the home screen uses, so the states are honest (real start
spinner, real recording treatment) and the user rehearses the exact control.

Also: Back from the first step now returns to the welcome instead of dead-ending
(it stays disabled only for a returning user who never saw a welcome).
…urce of truth

+page.svelte hand-assembled the capture pipeline three times inline (manual /
vad / import), differing only in the device selector and whether capture
behavior shows, then drilled each down as a pipeline snippet. The pipeline is
fully determined by the surface, so give CapturePipeline a `surface` prop and
move the selector composition plus the view-transition names into it. The action
components render their own <CapturePipeline surface=...> footer; +page drops the
inline snippets, the prop-drilling, the transformation view-transition
derivation, and five now-unused imports.
…ace string

CapturePipeline took surface=manual|vad|import and switched back to the matching
device selector, so each surface was encoded twice: structurally (you are in
VadRecordingAction) and again as a string the pipeline mapped back to a component.

Invert it. CapturePipelineCore owns the universal model + transformation row with
optional leading/trailing slots; CapturePipeline is a surface-agnostic live wrapper
that takes the host's device selector as children and adds the capture-behavior
popover; import uses the bare core. Each action component hands in its own device
selector, so the surface name never travels downstream.
The prior commit split CapturePipeline into a CapturePipelineCore plus a thin
leading/trailing-slot wrapper, to keep device-presence and behavior-presence
independent. No surface needs them independent (live = device + behavior, import =
neither), so that was unearned indirection. Fold it back into a single
CapturePipeline that takes the device selector as children and gates the
capture-behavior popover on the device's presence. The action call sites are
unchanged.
The first-run wizard tucked the whole model library into its engine step: an
"All models" disclosure (showing one Parakeet model behind it), a
bring-your-own box, and a second download button for the model the hero
already offers. That surface belongs in settings, not first run.

Give LocalModelSelector a `compact` mode that renders the hero only (download
/ active summary / missing warning) and drops the All-models list, the
bring-your-own box, the footer, and the summary's "Change" button.
TranscriptionRuntimeConfig derives it from its existing first-run signal, so
the settings page keeps the full library and the wizard gets one action.
The "Get started" button collapsed to `w-auto` past the `sm` breakpoint, so
on desktop it hugged its label under three full-width trust cards and read as
orphaned. Make it full-width so the call to action anchors the column.
…oud chooser

The engine step buried the one decision that matters at first run, where
transcription runs, under a service dropdown a disclosure away, while a model
card with a redundant title carried the screen. The word "Parakeet" appeared
four times across the step.

Lead instead with a two-option chooser: On device (recommended) vs Cloud.
Picking one snaps transcription.service to that location's recommended default
and reflects the current service's location; the chosen setup renders right
below. The full provider list stays behind "Use a different service" for the
long tail (other engines, other providers, self-hosted). Desktop only, since
the web build has no local engine.
First dictation rehearsed the real recorder button but ended in a bespoke "It
works" box that didn't resemble what every later recording shows. Since the
practice recording is real (stop runs the full pipeline: transcribe and copy to
the clipboard), end it in the real result UI instead: the home screen's
TranscriptDialog plus the same audio playback, so the step previews the actual
workflow rather than a mock. The blob store caches the playback URL per id and
the wizard never coexists with the home recorder, so creating it here and
revoking on teardown can't race home's copy.

Also relabel the step's "Skip" to "Skip for now" to match the Accessibility
step.
…e hero

Settings showed the active model twice: a summary row, then again inside an
"All models (N)" disclosure that for a single-model engine said "All models (1)"
and revealed the same model behind a chevron. Drop both. Settings now renders
the models as one flat list (catalog rows + custom entries + the bring-your-own
footer), active row highlighted, missing-selection warning on top; the empty
state is just the list with download buttons. One model or four, same clean
shape, no disclosure.

The first-run wizard keeps its single-action compact hero, and a new `bare`
flag drops the card chrome (header title/description) so a host can present that
hero as content attached to its own panel rather than a card-in-a-card. Removes
the now-dead all-models disclosure state.
The engine step hid service selection behind a clunky "Use a different service"
collapsible: a chevron that revealed a single dropdown. Replace it. The On
device / Cloud chooser stays, and the chosen location's setup renders in a panel
tied to the selected card by a caret that tracks the choice left or right. The
cards stay put, so the panel's own height changes (download progress, cloud
fields) never jostle the choice itself: the cohesion of an accordion without the
moving layout.

Picking Cloud surfaces an inline cloud-only provider select, since the provider
decides which API key you fetch; other local engines and self-hosted live in
settings. The Cloud card now reads "Works on any device. Needs an API key." (it
is not the fastest to set up), and the first-run model hero renders bare so it
is attached content, not a card-in-a-card.

Supporting props: TranscriptionServiceSelect gains a `locations` filter (scoped
to cloud here); TranscriptionRuntimeConfig forwards `bare` to the local model
selector.
"Selected model is missing" was driven by a component-local folder scan that
only re-ran on mount, window-focus, and a download this component started. The
download handle, though, is a global singleton that survives unmount and flips
the instant a download promotes its files. So downloading a model and then
switching to Cloud (which unmounts the Parakeet selector mid-download) left the
fresh selector's scan stale: the model was on disk and the handle knew it, but
the scan didn't, so it falsely read "missing" until a window-focus rescan. That
same staleness is why the active state took a while to appear and sometimes
needed a refresh.

Drive the missing-check off the global handle for catalog models, acquiring the
handle in its own derived so the state read tracks it. Custom (bring-your-own)
entries have no handle and still use the folder scan.
braden-w added 4 commits June 22, 2026 15:45
The attachment-cue pointer was a rotate-45 square showing only two borders,
which reads as a square corner rather than an arrow, with a near-invisible fill.
Use a real CSS triangle (zero-size box, transparent side borders, one colored
bottom border) so the pointer under the selected card is sharp.
…st dictation

First dictation reimplemented a subset of the home screen's after-recording UI,
and its audio player was nested inside the transcript guard, so a recording with
no transcript (silence, or a failed transcribe) showed neither text nor audio.

Extract a RecordingResult component (transcript preview + audio player, owning
its own playback URL) that home and the first-run "try it" step both render, so
they cannot drift. Audio now renders whenever the clip exists, independent of the
transcript; the "it works" framing and the clipboard note stay gated on a
transcript. First dictation gates on the recording, not the transcript, so you
can always hear what you captured.
The model selector had two truth sources for "is a model on disk," refreshed on
different schedules and able to drift: a component-local folder scan (mount /
focus / own-mutation) and per-model download handles holding a separate stat.
That split was the whole bug class behind false "missing," slow-to-active, and
needs-a-refresh; the previous fix only taught the missing-check to read the
less-stale of the two.

Collapse both into one global per-engine `modelFolder` store. It unifies the two
genuinely different kinds of truth a selector needs, and nothing more: disk state
at rest (the scan plus each catalog model's completeness, refreshed in one cycle)
and in-flight transfers in motion (progress, cancel; a SvelteMap whose re-set
repaints the bar). Because it is a global singleton, a download started anywhere
updates the one store and every mounted view reacts; there is no private scan to
go stale. Selection stays in deviceConfig, a separate concern the views join.

The selector and the catalog row become pure views over the store, and the
missing-check collapses back to the obvious one-liner — the catalog special-case
deletes itself, because the staleness it worked around no longer exists. Deletes
local-model-downloads.svelte.ts.
The store built its disk state from two Rust calls per refresh: enumerate the
folder, then stat each catalog model's completeness. Fold the completeness into
the enumeration: `list_model_entries` now takes the catalog and returns each
entry already judged `complete` (against the 90% floor, the same rule the
download integrity check uses). So the store's `refresh()` is one call and one
projection, and `stateOf` reads `entry.complete` straight off the scan.

`resolve_model_files` stays: the transcribe pre-flight needs a single model's
actual byte size to message a truncated download ("got 200MB, expected 488MB"),
which the list's per-entry boolean cannot carry. Both read-path verdicts share
`is_size_complete`, so the threshold still has one owner. The store's
`createModelStorage.isInstalled` and its completeness map are gone.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant