feat(ux): dogfood pass — onboarding, tasks, LLM chip, float popout

Bundles a session of dogfood UX feedback plus the two Cursor Bugbot
findings on the auto-titles branch.

Onboarding (FirstRunPage):
- Welcome leads with "Set up automatically"; system breakdown and the
  full model list move behind a "Choose manually" disclosure
- Morning / evening / autostart modal copy trimmed to one short
  sentence each; CTAs shortened
- "Corbie" autostart string reverted to "Kon"
- Already-downloaded models are clickable in the picker so the
  Settings → About → Replay onboarding flow doesn't re-download
- Autostart "No thanks" now does isEnabled() → disable() to actually
  remove the OS login item when replaying after a previous "Yes"

Tasks page:
- Bucket nav (All / Inbox / Today / Soon / Later) now a horizontal
  pill row; was stacking because nav was block-level
- List sidebar sized to content via self-start max-h-full instead of
  stretching to viewport when sparse
- Energy chip surfaces at opacity-60 when unset (was opacity-0,
  hidden until hover) so the affordance is discoverable
- "Brain-Dead" energy label → "Zero" everywhere user-facing; enum
  stays brain_dead to avoid a destructive DB migration

LLM status chip (llmStatus.svelte.ts + Dictation/Settings):
- Chip no longer auto-warms when the engine isn't loaded; it's hidden
  unless ready / generating / loading / error
- refreshLlmStatus takes { force: true } so post-load reconcile clears
  stale "warming"; ambient refreshes still preserve in-flight state
- markError exported; failed loads surface "AI error" with detail
  rather than silently going to off
- check_llm_model is the source of truth (replaces the bool-only
  get_llm_status path in the store)

Float popout window:
- Native decorations off — was stacking two titlebars + two close X's
  on KWin, one of which silently failed
- ResizeHandles mounted outside the animate-float-enter wrapper so
  fixed-position handles anchor to the viewport, not the transformed
  root; secondary-windows capability gains
  core:window:allow-start-resize-dragging for tasks-float
- GTK Utility WindowTypeHint applied pre-map (mirroring the preview
  window) so KWin Wayland honours always-on-top reliably
- visible_on_all_workspaces(true) so the pinned tasks list follows
  workspace switches
- togglePin does hide()+show()+focus() on re-pin to nudge the
  compositor into re-evaluating window state
- Pop-out / Edit / Open viewer buttons hidden on Android via
  isAndroid() — the multi-window Tauri commands stub out there

Build / Bugbot:
- src-tauri Cargo.toml: whisper feature now chains whisper-vulkan, so
  the dev runner's --no-default-features --features whisper
  invocation actually pulls Vulkan acceleration instead of silently
  falling back to CPU-only
- jsconfig.json's inherited "types": ["node"] fixed by adding
  @types/node; corresponding @ts-expect-error in vite.config.js
  removed now that process is a known global

Verification: svelte-check + cargo check pass clean. Manual
device-side validation still pending for float resize and replay
autostart "No thanks" — those are the only remaining confidence items.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 23:43:00 +01:00
parent ce849a15ab
commit a15167c44e
18 changed files with 403 additions and 174 deletions

View File

@@ -52,11 +52,19 @@
});
try {
// If the model is already on disk, skip the download step so the user
// can replay onboarding without re-downloading. download_model is a
// no-op for present files in current builds, but we belt-and-brace by
// checking is_downloaded first.
const alreadyDownloaded = (models ?? []).find((m) => m.id === modelId)?.is_downloaded ?? false;
if (modelId.startsWith("whisper-")) {
// backend's whisper_model_id accepts the full model id via its
// `other => ModelId::new(other)` fallback, so pass the id through
// unchanged rather than maintaining a fragile lowercased alias map.
await invoke("download_model", { size: modelId });
if (!alreadyDownloaded) {
await invoke("download_model", { size: modelId });
}
await invoke("load_model", { size: modelId });
const idToLabel = {
@@ -70,7 +78,9 @@
settings.engine = "whisper";
settings.modelSize = idToLabel[modelId] ?? "Base";
} else if (modelId.startsWith("parakeet-")) {
await invoke("download_parakeet_model", { name: "ctc-int8" });
if (!alreadyDownloaded) {
await invoke("download_parakeet_model", { name: "ctc-int8" });
}
await invoke("load_parakeet_model", { name: "ctc-int8" });
settings.engine = "parakeet";
}
@@ -95,6 +105,12 @@
}
}
// Auto-detect is the default surface; the system breakdown and the
// manual model list live behind a "Choose manually" disclosure so the
// first-run screen is not a wall of jargon for users who just want to
// hit one button and start dictating.
let showManual = $state(false);
// Phase 5: forced-choice rituals + autostart prompts. Research on
// libertarian-paternalism nudges (Thaler/Sunstein) says defaults
// drive uptake, but the ADHD target audience is sensitive to
@@ -104,6 +120,11 @@
let ritualsStep = $state<RitualsStep>("idle");
let autostartApplying = $state(false);
function setupAutomatically() {
if (!models?.length) return;
downloadAndGo(models[0].id);
}
async function answerMorning(yes: boolean) {
settings.ritualsMorning = yes;
saveSettings();
@@ -124,13 +145,20 @@
await plugin.enable();
settings.launchAtLogin = true;
} else {
// Don't call disable() on a fresh install — there's nothing to
// disable, and some platforms treat "disable when unset" as an
// error. Just record the choice.
// On a true first run this is already off, but replaying
// onboarding after previously choosing "Yes" must remove the
// OS-level login item too.
if (await plugin.isEnabled()) {
await plugin.disable();
}
settings.launchAtLogin = false;
}
} catch (err) {
toasts.warn("Could not update autostart", String(err));
try {
const plugin = await import("@tauri-apps/plugin-autostart");
settings.launchAtLogin = await plugin.isEnabled();
} catch {}
} finally {
autostartApplying = false;
settings.ritualsPromptSeen = true;
@@ -179,9 +207,9 @@
<Sunrise size={32} strokeWidth={1.5} class="text-accent mx-auto mb-3" />
<h2 class="text-xl font-medium text-text">Morning triage?</h2>
<p class="text-sm text-text-secondary mt-3 leading-relaxed">
On the first launch of the day, a gentle modal shows yesterday's open items and asks you to pick up to three for today. The rest can wait.
Each morning, pick three things to focus on. Everything else can wait.
</p>
<p class="text-[11px] text-text-tertiary mt-3">Off by default. You can change your mind any time in Settings.</p>
<p class="text-[11px] text-text-tertiary mt-3">Off by default. Change anytime in Settings.</p>
<div class="flex items-center justify-center gap-3 mt-6">
<button
class="px-4 py-2 rounded-lg text-sm border border-border text-text-secondary hover:bg-hover"
@@ -190,12 +218,12 @@
<button
class="px-4 py-2 rounded-lg text-sm bg-accent text-white hover:bg-accent-hover"
onclick={() => answerMorning(true)}
>Yes, turn it on</button>
>Turn on</button>
</div>
<button
class="mt-5 text-xs text-text-tertiary hover:text-text-secondary underline"
onclick={skipRituals}
>Skip all these questions</button>
>Skip these</button>
</div>
{:else if ritualsStep === "evening"}
@@ -203,9 +231,9 @@
<Moon size={32} strokeWidth={1.5} class="text-accent mx-auto mb-3" />
<h2 class="text-xl font-medium text-text">Evening wind-down?</h2>
<p class="text-sm text-text-secondary mt-3 leading-relaxed">
A reflective page you can open when you want to close the day. Shows what you finished, names the open loops, then gets out of the way. Never scheduled, never nagging.
A page to reflect on what you finished and what's still open — only when you choose to open it.
</p>
<p class="text-[11px] text-text-tertiary mt-3">Off by default. Always opt-in.</p>
<p class="text-[11px] text-text-tertiary mt-3">Off by default. Never scheduled.</p>
<div class="flex items-center justify-center gap-3 mt-6">
<button
class="px-4 py-2 rounded-lg text-sm border border-border text-text-secondary hover:bg-hover"
@@ -214,22 +242,22 @@
<button
class="px-4 py-2 rounded-lg text-sm bg-accent text-white hover:bg-accent-hover"
onclick={() => answerEvening(true)}
>Yes, turn it on</button>
>Turn on</button>
</div>
<button
class="mt-5 text-xs text-text-tertiary hover:text-text-secondary underline"
onclick={skipRituals}
>Skip the rest</button>
>Skip these</button>
</div>
{:else if ritualsStep === "autostart"}
<div class="w-full max-w-md mx-auto text-center">
<Play size={32} strokeWidth={1.5} class="text-accent mx-auto mb-3" />
<h2 class="text-xl font-medium text-text">Launch Corbie at login?</h2>
<h2 class="text-xl font-medium text-text">Launch Kon at login?</h2>
<p class="text-sm text-text-secondary mt-3 leading-relaxed">
So Corbie is already there when you need it — especially useful if you said yes to morning triage. Uses your OS's standard autostart. No background tricks, no telemetry.
Kon will be ready as soon as you sign in. Uses your OS's standard autostart.
</p>
<p class="text-[11px] text-text-tertiary mt-3">You can change this any time in Settings.</p>
<p class="text-[11px] text-text-tertiary mt-3">Change anytime in Settings.</p>
<div class="flex items-center justify-center gap-3 mt-6">
<button
class="px-4 py-2 rounded-lg text-sm border border-border text-text-secondary hover:bg-hover"
@@ -240,7 +268,7 @@
class="px-4 py-2 rounded-lg text-sm bg-accent text-white hover:bg-accent-hover disabled:opacity-60"
onclick={() => answerAutostart(true)}
disabled={autostartApplying}
>{autostartApplying ? 'Saving…' : 'Yes, launch at login'}</button>
>{autostartApplying ? 'Saving…' : 'Yes'}</button>
</div>
</div>
@@ -277,56 +305,76 @@
<div class="mt-4 p-3 rounded-lg bg-danger/10 text-danger text-sm">{error}</div>
{/if}
{#if systemInfo}
<div class="mt-8 p-4 rounded-lg bg-bg-input border border-border">
<h3 class="text-xs font-medium text-text-tertiary uppercase tracking-wider mb-3">Your system</h3>
<div class="grid grid-cols-2 gap-2 text-sm">
<span class="text-text-secondary">RAM</span>
<span class="text-text">{Math.round(systemInfo.ram_mb / 1024)} GB</span>
<span class="text-text-secondary">CPU</span>
<span class="text-text truncate" title={systemInfo.cpu_brand}>{systemInfo.cpu_brand}</span>
<span class="text-text-secondary">Cores</span>
<span class="text-text">{systemInfo.cpu_cores}</span>
<span class="text-text-secondary">OS</span>
<span class="text-text">{systemInfo.os}</span>
</div>
</div>
{/if}
{#if models.length > 0}
<div class="mt-6">
<h3 class="text-xs font-medium text-text-tertiary uppercase tracking-wider mb-3">Pick a model</h3>
<p class="text-xs text-text-tertiary mb-3">One tap — we handle the rest.</p>
<div class="space-y-2">
{#each models as model, i}
<button
class="w-full text-left p-3 rounded-lg border
{i === 0 ? 'border-accent bg-accent/5 hover:bg-accent/10' : 'border-border bg-bg-input hover:bg-hover'}"
style="transition-duration: var(--duration-ui)"
onclick={() => downloadAndGo(model.id)}
disabled={model.is_downloaded}
>
<div class="flex items-center justify-between">
<div>
<span class="text-sm font-medium text-text">{model.display_name}</span>
{#if i === 0}
<span class="ml-2 text-[10px] px-1.5 py-0.5 rounded-full bg-accent/15 text-accent font-medium">Recommended</span>
{/if}
{#if model.is_downloaded}
<span class="ml-2 text-[10px] px-1.5 py-0.5 rounded-full bg-success/15 text-success font-medium">Downloaded</span>
{/if}
</div>
<span class="text-xs text-text-tertiary">{model.disk_size_mb} MB</span>
</div>
<p class="text-xs text-text-secondary mt-1">{model.description}</p>
</button>
{/each}
</div>
<div class="mt-8 flex flex-col items-center">
<button
class="px-6 py-3 rounded-xl text-base font-medium bg-accent text-white hover:bg-accent-hover
shadow-[0_4px_16px_rgba(232,168,124,0.3)] active:scale-[0.97] transition-all duration-150"
onclick={setupAutomatically}
>
Set up automatically
</button>
<p class="mt-2 text-xs text-text-tertiary">
Picks the best model for your machine — about {models[0]?.disk_size_mb ?? "?"} MB.
</p>
</div>
<button
class="mt-6 mx-auto block text-xs text-text-secondary hover:text-text underline"
onclick={() => (showManual = !showManual)}
>
{showManual ? 'Hide manual setup' : 'Choose manually'}
</button>
{#if showManual}
{#if systemInfo}
<div class="mt-4 p-4 rounded-lg bg-bg-input border border-border">
<h3 class="text-xs font-medium text-text-tertiary uppercase tracking-wider mb-3">Your system</h3>
<div class="grid grid-cols-2 gap-2 text-sm">
<span class="text-text-secondary">RAM</span>
<span class="text-text">{Math.round(systemInfo.ram_mb / 1024)} GB</span>
<span class="text-text-secondary">CPU</span>
<span class="text-text truncate" title={systemInfo.cpu_brand}>{systemInfo.cpu_brand}</span>
<span class="text-text-secondary">Cores</span>
<span class="text-text">{systemInfo.cpu_cores}</span>
<span class="text-text-secondary">OS</span>
<span class="text-text">{systemInfo.os}</span>
</div>
</div>
{/if}
<div class="mt-6">
<h3 class="text-xs font-medium text-text-tertiary uppercase tracking-wider mb-3">Pick a model</h3>
<div class="space-y-2">
{#each models as model, i}
<button
class="w-full text-left p-3 rounded-lg border
{i === 0 ? 'border-accent bg-accent/5 hover:bg-accent/10' : 'border-border bg-bg-input hover:bg-hover'}"
style="transition-duration: var(--duration-ui)"
onclick={() => downloadAndGo(model.id)}
>
<div class="flex items-center justify-between">
<div>
<span class="text-sm font-medium text-text">{model.display_name}</span>
{#if i === 0}
<span class="ml-2 text-[10px] px-1.5 py-0.5 rounded-full bg-accent/15 text-accent font-medium">Recommended</span>
{/if}
{#if model.is_downloaded}
<span class="ml-2 text-[10px] px-1.5 py-0.5 rounded-full bg-success/15 text-success font-medium">Downloaded</span>
{/if}
</div>
<span class="text-xs text-text-tertiary">{model.disk_size_mb} MB</span>
</div>
<p class="text-xs text-text-secondary mt-1">{model.description}</p>
</button>
{/each}
</div>
</div>
{/if}
{/if}
<button
class="mt-6 text-xs text-text-tertiary hover:text-text-secondary underline"
class="mt-6 mx-auto block text-xs text-text-tertiary hover:text-text-secondary underline"
style="transition-duration: var(--duration-ui)"
onclick={skipSetup}
>