feat(kez-chat/web): first-run onboarding — Getting Started checklist

New accounts land on /welcome instead of an empty Chats. A resumable,
skippable checklist (not a forced wizard) orients them and drives the
key first-run tasks. Essentials-only scope.

  • Welcome.svelte: progress checklist —
      1. Account created (auto ✓)
      2. Back up recovery seed — highlighted as critical (warning border),
         inline reveal + copy + "I've saved it safely". SKIPPABLE per
         decision; no hard gate.
      3. Add your first proof → /claims/add (✓ once a proof verifies)
      4. Enable app lock (optional, only if a platform authenticator exists)
      5. Turn on notifications (optional)
     Steps derive from real state (claims, biometric, notif permission),
     so checkmarks are truthful. "Skip for now" + "Enter kez-chat →"
     both finish onboarding.
  • lib/onboarding.svelte.ts: $state-backed flags (onboarded, seedAcked)
    persisted to localStorage; reopen() for Settings.
  • CreateAccount → /welcome after registration (was → /chats).
  • Chats: dismissible "Finish setting up your account" nudge shown
    until onboarding is completed/skipped.
  • Settings → Account → "Getting started" reopens the checklist.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Jason Tudisco 2026-05-28 11:33:47 -06:00
parent a2538b2886
commit dac98486c5
5 changed files with 269 additions and 1 deletions

View File

@ -10,6 +10,7 @@
import CreateAccount from "./routes/CreateAccount.svelte";
import Restore from "./routes/Restore.svelte";
import Unlock from "./routes/Unlock.svelte";
import Welcome from "./routes/Welcome.svelte";
import Identity from "./routes/Identity.svelte";
import Claims from "./routes/Claims.svelte";
import AddClaim from "./routes/AddClaim.svelte";
@ -21,6 +22,7 @@
"/create": CreateAccount,
"/restore": Restore,
"/unlock": Unlock,
"/welcome": Welcome,
"/chats": Messages,
"/identity": Identity,
"/claims": Claims,
@ -29,7 +31,7 @@
};
// App routes show the nav chrome; everything else (auth flow) is full-bleed.
const APP_ROUTES = ["/chats", "/identity", "/claims", "/claims/add", "/settings"];
const APP_ROUTES = ["/welcome", "/chats", "/identity", "/claims", "/claims/add", "/settings"];
const showNav = $derived(!!session.unlocked && APP_ROUTES.includes($location));
onMount(async () => {

View File

@ -0,0 +1,48 @@
// First-run onboarding state. Two persisted flags:
// • onboarded — user finished/skipped the Getting Started checklist
// • seedAcked — user confirmed they backed up their recovery seed
// Both in localStorage; exposed as Svelte 5 $state so the "finish setup"
// nudge reactively disappears. Everything else in the checklist derives
// from real app state (claims, biometric, notification permission).
const ONBOARDED = "kez-chat:onboarded";
const SEED_ACKED = "kez-chat:seed_acked";
function read(key: string): boolean {
try {
return localStorage.getItem(key) === "1";
} catch {
return false;
}
}
function write(key: string, val: boolean) {
try {
if (val) localStorage.setItem(key, "1");
else localStorage.removeItem(key);
} catch {
/* private mode — fine */
}
}
class Onboarding {
onboarded = $state(read(ONBOARDED));
seedAcked = $state(read(SEED_ACKED));
finish() {
this.onboarded = true;
write(ONBOARDED, true);
}
ackSeed() {
this.seedAcked = true;
write(SEED_ACKED, true);
}
/** Re-open the checklist (e.g. from Settings → Getting started). */
reopen() {
this.onboarded = false;
write(ONBOARDED, false);
}
}
export const onboarding = new Onboarding();

View File

@ -6,6 +6,7 @@
import { lookup, lookupByPrimary, ApiError } from "../lib/api.js";
import { inboxService } from "../lib/inbox-service.svelte.js";
import { verifySubject } from "../lib/verify.js";
import { onboarding } from "../lib/onboarding.svelte.js";
import EmojiButton from "../lib/EmojiButton.svelte";
import Avatar from "../lib/Avatar.svelte";
import VerifiedBadge from "../lib/VerifiedBadge.svelte";
@ -318,6 +319,13 @@
{/if}
</div>
<!-- Finish-setup nudge (until onboarding is completed/skipped) -->
{#if !onboarding.onboarded}
<a href="#/welcome" class="flex items-center gap-2 px-3 py-2 border-b border-border bg-accent/10 text-accent hover:bg-accent/20 no-underline text-sm">
<span></span><span class="flex-1">Finish setting up your account</span><span></span>
</a>
{/if}
<!-- Start a chat -->
<div class="p-3 border-b border-border">
<form class="flex gap-2" onsubmit={(e) => { e.preventDefault(); startConversation(); }}>

View File

@ -17,6 +17,12 @@
fireTestNotification,
} from "../lib/inbox-service.svelte.js";
import { theme, type ThemeChoice } from "../lib/theme.svelte.js";
import { onboarding } from "../lib/onboarding.svelte.js";
function openGettingStarted() {
onboarding.reopen();
push("/welcome");
}
const themeOptions: { value: ThemeChoice; label: string }[] = [
{ value: "light", label: "Light" },
@ -199,6 +205,9 @@
<!-- Account / About -->
<section class="bg-surface border border-border rounded-xl p-6 space-y-4">
<h2 class="text-sm font-semibold text-text uppercase tracking-wider">Account</h2>
<button class="px-3 py-2 text-sm border border-border rounded-md text-text-secondary hover:bg-elevated hover:text-text" onclick={openGettingStarted}>
Getting started
</button>
<button class="px-3 py-2 text-sm border border-border rounded-md text-text-secondary hover:text-danger hover:border-danger" onclick={lock}>
Lock
</button>

View File

@ -0,0 +1,201 @@
<script lang="ts">
import { onMount } from "svelte";
import { push } from "svelte-spa-router";
import { bytesToHex } from "@noble/hashes/utils";
import { session } from "../lib/store.svelte.js";
import { onboarding } from "../lib/onboarding.svelte.js";
import { listClaims } from "../lib/claims-store.js";
import {
hasStoredBiometric,
setupBiometricUnlock,
isPlatformAuthenticatorAvailable,
} from "../lib/webauthn.js";
import {
notificationsSupported,
notificationsPermission,
requestNotificationsPermission,
} from "../lib/inbox-service.svelte.js";
import Avatar from "../lib/Avatar.svelte";
import Wordmark from "../lib/Wordmark.svelte";
let hasVerifiedProof = $state(false);
let biometricEnrolled = $state(false);
let biometricAvailable = $state(false);
let notifPerm = $state<NotificationPermission | "unsupported">("default");
let seedRevealed = $state(false);
let seedHex = $state("");
let seedCopied = $state(false);
let busy = $state(false);
onMount(async () => {
if (!session.unlocked) {
push("/unlock");
return;
}
const claims = await listClaims();
hasVerifiedProof = claims.some((c) => c.last_verify?.status === "ok");
biometricEnrolled = await hasStoredBiometric();
biometricAvailable = await isPlatformAuthenticatorAvailable();
notifPerm = notificationsSupported() ? notificationsPermission() : "unsupported";
});
function revealSeed() {
if (!session.unlocked) return;
seedHex = bytesToHex(session.unlocked.seed);
seedRevealed = true;
}
async function copySeed() {
await navigator.clipboard.writeText(seedHex);
seedCopied = true;
setTimeout(() => (seedCopied = false), 1500);
}
async function enableBiometric() {
if (!session.unlocked) return;
busy = true;
try {
await setupBiometricUnlock({
handle: session.unlocked.handle,
primary: session.unlocked.primary,
seed: session.unlocked.seed,
});
biometricEnrolled = true;
} catch (e) {
alert(`Couldn't enable app lock: ${(e as Error).message}`);
} finally {
busy = false;
}
}
async function enableNotifications() {
notifPerm = await requestNotificationsPermission();
}
function enterApp() {
onboarding.finish();
push("/chats");
}
// Progress = required steps done (seed + proof). Optional ones don't gate.
const done = $derived(
[onboarding.seedAcked, hasVerifiedProof].filter(Boolean).length,
);
const total = 2;
</script>
{#if session.unlocked}
<div class="max-w-xl mx-auto py-6 space-y-6">
<div class="text-center space-y-2">
<div class="flex justify-center"><Wordmark size={28} /></div>
<h1 class="text-xl font-semibold text-text">Welcome — let's get you set up</h1>
<p class="text-sm text-text-secondary">
A couple of quick steps. You can skip and come back anytime from Settings.
</p>
<div class="flex items-center justify-center gap-3 pt-1">
<Avatar seed={session.unlocked.primary} size={44} ring />
<span class="font-mono text-sm text-accent">{session.unlocked.handle}@{session.unlocked.server}</span>
</div>
<p class="text-xs text-text-muted">{done} of {total} essentials done</p>
</div>
<ol class="space-y-3">
<!-- 1. Account created (auto) -->
<li class="flex items-start gap-3 bg-surface border border-border rounded-xl p-4">
<span class="shrink-0 mt-0.5" style="color:var(--color-verified)"></span>
<div class="min-w-0">
<p class="text-sm font-semibold text-text">Account created</p>
<p class="text-xs text-text-secondary">Your identity is an Ed25519 keypair — no email, no password reset.</p>
</div>
</li>
<!-- 2. Back up seed (critical, skippable) -->
<li class={`bg-surface border rounded-xl p-4 ${onboarding.seedAcked ? "border-border" : "border-warning/50"}`}>
<div class="flex items-start gap-3">
<span class="shrink-0 mt-0.5">{onboarding.seedAcked ? "✓" : "🔑"}</span>
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-text">Back up your recovery seed</p>
<p class="text-xs text-text-secondary">
This 32-byte seed is the <strong>only</strong> way to recover your
account. Lose it and it's gone forever — there's no reset. Write it
down offline.
</p>
{#if !seedRevealed && !onboarding.seedAcked}
<button class="mt-2 px-3 py-1.5 text-sm border border-border rounded-md text-text-secondary hover:bg-elevated hover:text-text" onclick={revealSeed}>Reveal seed</button>
{/if}
{#if seedRevealed}
<div class="mt-2 p-3 bg-elevated border border-border rounded-md">
<p class="font-mono text-xs text-text break-all select-all">{seedHex}</p>
<div class="mt-2 flex gap-2">
<button class="px-2.5 py-1 text-xs border border-border rounded text-text-secondary hover:bg-surface" onclick={copySeed}>{seedCopied ? "✓ copied" : "Copy"}</button>
{#if !onboarding.seedAcked}
<button class="px-2.5 py-1 text-xs bg-accent text-accent-contrast font-semibold rounded" onclick={() => onboarding.ackSeed()}>I've saved it safely</button>
{/if}
</div>
</div>
{/if}
{#if onboarding.seedAcked}
<p class="mt-1 text-xs" style="color:var(--color-verified)">✓ Backed up</p>
{/if}
</div>
</div>
</li>
<!-- 3. Add first proof -->
<li class="flex items-start gap-3 bg-surface border border-border rounded-xl p-4">
<span class="shrink-0 mt-0.5">{hasVerifiedProof ? "✓" : "✦"}</span>
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-text">Add your first proof</p>
<p class="text-xs text-text-secondary">
Link a GitHub, a domain, or your nostr key to earn your verified
badge — so people know it's really you.
</p>
{#if hasVerifiedProof}
<p class="mt-1 text-xs" style="color:var(--color-verified)">✓ You have a verified proof</p>
{:else}
<a href="#/claims/add" class="mt-2 inline-block px-3 py-1.5 text-sm bg-accent text-accent-contrast font-semibold rounded-md hover:bg-accent-dim no-underline">Add a proof</a>
{/if}
</div>
</li>
<!-- 4. App lock (optional) -->
{#if biometricAvailable || biometricEnrolled}
<li class="flex items-start gap-3 bg-surface border border-border rounded-xl p-4">
<span class="shrink-0 mt-0.5">{biometricEnrolled ? "✓" : "🔓"}</span>
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-text">Enable app lock <span class="text-text-muted font-normal">· optional</span></p>
<p class="text-xs text-text-secondary">Unlock with Touch ID / Face ID / Windows Hello instead of your passphrase.</p>
{#if biometricEnrolled}
<p class="mt-1 text-xs" style="color:var(--color-verified)">✓ Enabled</p>
{:else}
<button class="mt-2 px-3 py-1.5 text-sm border border-border rounded-md text-text-secondary hover:bg-elevated hover:text-text disabled:opacity-50" onclick={enableBiometric} disabled={busy}>{busy ? "…" : "Enable"}</button>
{/if}
</div>
</li>
{/if}
<!-- 5. Notifications (optional) -->
{#if notifPerm !== "unsupported"}
<li class="flex items-start gap-3 bg-surface border border-border rounded-xl p-4">
<span class="shrink-0 mt-0.5">{notifPerm === "granted" ? "✓" : "🔔"}</span>
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-text">Turn on notifications <span class="text-text-muted font-normal">· optional</span></p>
<p class="text-xs text-text-secondary">Get pinged when a message arrives while you're away.</p>
{#if notifPerm === "granted"}
<p class="mt-1 text-xs" style="color:var(--color-verified)">✓ Enabled</p>
{:else if notifPerm === "denied"}
<p class="mt-1 text-xs text-warning">Blocked — re-enable in site settings.</p>
{:else}
<button class="mt-2 px-3 py-1.5 text-sm border border-border rounded-md text-text-secondary hover:bg-elevated hover:text-text" onclick={enableNotifications}>Enable</button>
{/if}
</div>
</li>
{/if}
</ol>
<div class="flex items-center justify-between pt-2">
<button class="text-sm text-text-muted hover:text-text underline" onclick={enterApp}>Skip for now</button>
<button class="px-5 py-2.5 text-sm bg-accent text-accent-contrast font-semibold rounded-md hover:bg-accent-dim" onclick={enterApp}>Enter kez-chat →</button>
</div>
</div>
{/if}