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:
parent
a2538b2886
commit
dac98486c5
@ -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 () => {
|
||||
|
||||
48
kez-chat/web/src/lib/onboarding.svelte.ts
Normal file
48
kez-chat/web/src/lib/onboarding.svelte.ts
Normal 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();
|
||||
@ -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(); }}>
|
||||
|
||||
@ -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>
|
||||
|
||||
201
kez-chat/web/src/routes/Welcome.svelte
Normal file
201
kez-chat/web/src/routes/Welcome.svelte
Normal 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}
|
||||
Loading…
x
Reference in New Issue
Block a user