redesign first-run onboarding: single-column + decisive setup#2141
Open
braden-w wants to merge 30 commits into
Open
redesign first-run onboarding: single-column + decisive setup#2141braden-w wants to merge 30 commits into
braden-w wants to merge 30 commits into
Conversation
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.
Contributor
Preview Deployment
These previews update automatically with new commits to this PR. Commit a1110e6 |
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.
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.
…oarding-two-column
…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.
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
max-w-2xlcolumn; the columns could never balance (tall setup card, short trust cards).Decisive setup (instead of a service menu):
hideServiceSelectonTranscriptionRuntimeConfig). The screen opens straight into the recommended service's setup: the local model download on desktop, the API-key field on web.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
/setup4-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-existingvite-config/bun.lockduplicate-viteenv issue).