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 CreateAccount from "./routes/CreateAccount.svelte";
|
||||||
import Restore from "./routes/Restore.svelte";
|
import Restore from "./routes/Restore.svelte";
|
||||||
import Unlock from "./routes/Unlock.svelte";
|
import Unlock from "./routes/Unlock.svelte";
|
||||||
|
import Welcome from "./routes/Welcome.svelte";
|
||||||
import Identity from "./routes/Identity.svelte";
|
import Identity from "./routes/Identity.svelte";
|
||||||
import Claims from "./routes/Claims.svelte";
|
import Claims from "./routes/Claims.svelte";
|
||||||
import AddClaim from "./routes/AddClaim.svelte";
|
import AddClaim from "./routes/AddClaim.svelte";
|
||||||
@ -21,6 +22,7 @@
|
|||||||
"/create": CreateAccount,
|
"/create": CreateAccount,
|
||||||
"/restore": Restore,
|
"/restore": Restore,
|
||||||
"/unlock": Unlock,
|
"/unlock": Unlock,
|
||||||
|
"/welcome": Welcome,
|
||||||
"/chats": Messages,
|
"/chats": Messages,
|
||||||
"/identity": Identity,
|
"/identity": Identity,
|
||||||
"/claims": Claims,
|
"/claims": Claims,
|
||||||
@ -29,7 +31,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
// App routes show the nav chrome; everything else (auth flow) is full-bleed.
|
// 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));
|
const showNav = $derived(!!session.unlocked && APP_ROUTES.includes($location));
|
||||||
|
|
||||||
onMount(async () => {
|
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 { lookup, lookupByPrimary, ApiError } from "../lib/api.js";
|
||||||
import { inboxService } from "../lib/inbox-service.svelte.js";
|
import { inboxService } from "../lib/inbox-service.svelte.js";
|
||||||
import { verifySubject } from "../lib/verify.js";
|
import { verifySubject } from "../lib/verify.js";
|
||||||
|
import { onboarding } from "../lib/onboarding.svelte.js";
|
||||||
import EmojiButton from "../lib/EmojiButton.svelte";
|
import EmojiButton from "../lib/EmojiButton.svelte";
|
||||||
import Avatar from "../lib/Avatar.svelte";
|
import Avatar from "../lib/Avatar.svelte";
|
||||||
import VerifiedBadge from "../lib/VerifiedBadge.svelte";
|
import VerifiedBadge from "../lib/VerifiedBadge.svelte";
|
||||||
@ -318,6 +319,13 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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 -->
|
<!-- Start a chat -->
|
||||||
<div class="p-3 border-b border-border">
|
<div class="p-3 border-b border-border">
|
||||||
<form class="flex gap-2" onsubmit={(e) => { e.preventDefault(); startConversation(); }}>
|
<form class="flex gap-2" onsubmit={(e) => { e.preventDefault(); startConversation(); }}>
|
||||||
|
|||||||
@ -17,6 +17,12 @@
|
|||||||
fireTestNotification,
|
fireTestNotification,
|
||||||
} from "../lib/inbox-service.svelte.js";
|
} from "../lib/inbox-service.svelte.js";
|
||||||
import { theme, type ThemeChoice } from "../lib/theme.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 }[] = [
|
const themeOptions: { value: ThemeChoice; label: string }[] = [
|
||||||
{ value: "light", label: "Light" },
|
{ value: "light", label: "Light" },
|
||||||
@ -199,6 +205,9 @@
|
|||||||
<!-- Account / About -->
|
<!-- Account / About -->
|
||||||
<section class="bg-surface border border-border rounded-xl p-6 space-y-4">
|
<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>
|
<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}>
|
<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
|
Lock
|
||||||
</button>
|
</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