feat(desktop): onboarding step dots, identity/key page restyle, overflow scroll (#1993)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-07-16 14:28:05 -07:00
committed by GitHub
co-authored by Brain
parent db57bc8a2c
commit f2522995cb
9 changed files with 230 additions and 240 deletions
@@ -1,10 +1,9 @@
import { AlertTriangle, RefreshCw } from "lucide-react";
import { AlertTriangle, Info, RefreshCw } from "lucide-react";
import * as React from "react";
import { getNsec } from "@/shared/api/tauriIdentity";
import { Button } from "@/shared/ui/button";
import { Spinner } from "@/shared/ui/spinner";
import { StepProgress } from "@/shared/ui/step-progress";
import {
type OnboardingTransitionDirection,
OnboardingSlideTransition,
@@ -14,51 +13,33 @@ import { NsecMaskedDisplay } from "./NsecMaskedDisplay";
/**
* Pure helper so the disabled logic can be unit-tested without a DOM.
*
* Disabled when:
* - still loading (key not fetched yet)
* - load failed (only the explicit "Skip for now" ghost advances past an error)
* - key loaded AND checkbox not yet checked
*
* Enabled when key is null after a clean (non-error) load — backend returned
* nothing, so there is nothing to acknowledge and the user may proceed.
* Disabled while loading (key not fetched yet) or after a failed load (only
* the explicit "Skip for now" ghost advances past an error).
*/
export function backupNextDisabled({
isLoading,
loadError,
nsec,
hasAcknowledged,
}: {
isLoading: boolean;
loadError: string | null;
nsec: string | null;
hasAcknowledged: boolean;
}): boolean {
return isLoading || loadError !== null || (nsec !== null && !hasAcknowledged);
return isLoading || loadError !== null;
}
type BackupStepProps = {
currentStep: number;
direction: OnboardingTransitionDirection;
totalSteps: number;
onBack: () => void;
onNext: () => void;
};
/**
* Onboarding backup step — shows the user their nsec and requires
* acknowledgement before advancing. Only shown on the fresh-key path.
* Onboarding backup step — shows the user their freshly created key so they
* can save it somewhere safe. Only shown on the fresh-key path.
*/
export function BackupStep({
currentStep,
direction,
totalSteps,
onBack,
onNext,
}: BackupStepProps) {
export function BackupStep({ direction, onBack, onNext }: BackupStepProps) {
const [nsec, setNsec] = React.useState<string | null>(null);
const [isLoading, setIsLoading] = React.useState(true);
const [loadError, setLoadError] = React.useState<string | null>(null);
const [hasAcknowledged, setHasAcknowledged] = React.useState(false);
const cancelledRef = React.useRef(false);
const loadNsec = React.useCallback(async () => {
@@ -92,30 +73,29 @@ export function BackupStep({
return (
<OnboardingSlideTransition
className="flex w-full flex-col items-center pb-36 lg:pb-0"
className="flex w-full flex-col items-center"
data-testid="onboarding-page-backup"
direction={direction}
transitionKey={`backup-${direction}`}
>
<div className="w-full max-w-[500px] text-center">
<h1 className="text-3xl font-semibold tracking-tight text-foreground">
Save your private key
Your unique identity has been created
</h1>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
Buzz generated a Nostr private key for you. This key is stored in your
system keychain, but you should also save it somewhere safe in case
you ever need to restore your account on another device.
<p className="mt-3 text-sm leading-6 text-foreground/80">
This key is stored in your system keychain, but save it some place
safe in case you ever need to restore your account.
</p>
</div>
<div className="mt-8 w-full max-w-[500px] space-y-4 text-left">
<div className="mt-10 w-full max-w-[640px]">
{isLoading ? (
<div className="flex items-center gap-2 rounded-lg border border-border/70 bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
<div className="flex items-center justify-center gap-2 py-6 text-sm text-foreground/70">
<Spinner className="h-4 w-4 border-2" />
Loading your private key…
</div>
) : loadError ? (
<div className="space-y-3">
<div className="mx-auto max-w-[500px] space-y-3 text-left">
<div
className="flex items-start gap-3 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive"
data-testid="backup-load-error"
@@ -140,46 +120,31 @@ export function BackupStep({
</Button>
</div>
) : nsec ? (
<NsecMaskedDisplay nsec={nsec} />
<div className="rounded-3xl bg-white/85 px-6 py-5 shadow-[0_0_70px_45px_rgba(255,255,255,0.85)]">
<NsecMaskedDisplay nsec={nsec} variant="bare" />
</div>
) : (
<p className="text-sm text-muted-foreground">
<p className="text-center text-sm text-foreground/70">
No key available to back up.
</p>
)}
{nsec ? (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm leading-5 text-amber-700 dark:text-amber-400">
<strong>Never share your private key.</strong> Anyone with it can
impersonate you and access everything in your account.
</div>
) : null}
{!isLoading && !loadError && nsec ? (
<label className="flex cursor-pointer items-start gap-3">
<input
checked={hasAcknowledged}
className="mt-0.5 h-4 w-4 shrink-0 cursor-pointer accent-primary"
data-testid="backup-acknowledge"
onChange={(e) => setHasAcknowledged(e.target.checked)}
type="checkbox"
/>
<span className="text-sm leading-5">
I've saved my private key in a safe place
<p className="mx-auto mt-6 flex max-w-[440px] items-start justify-center gap-1.5 text-center text-xs leading-5 text-foreground/70">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>
Never share your private key. Anyone can impersonate you and
access everything in your account.
</span>
</label>
</p>
) : null}
</div>
<div className="mt-8 flex w-full max-w-[500px] flex-col gap-3 max-lg:pointer-events-none max-lg:fixed max-lg:inset-x-0 max-lg:bottom-0 max-lg:z-40 max-lg:mt-0 max-lg:max-w-none max-lg:border-t max-lg:border-border max-lg:bg-background max-lg:p-4 max-lg:pb-[max(1rem,env(safe-area-inset-bottom))]">
<div className="mt-12 flex flex-col items-center gap-3">
<Button
className="h-10 w-full max-lg:pointer-events-auto"
className="h-10 rounded-full px-8"
data-testid="onboarding-next"
disabled={backupNextDisabled({
isLoading,
loadError,
nsec,
hasAcknowledged,
})}
disabled={backupNextDisabled({ isLoading, loadError })}
onClick={onNext}
type="button"
>
@@ -188,7 +153,7 @@ export function BackupStep({
{loadError ? (
<Button
className="h-10 w-full text-muted-foreground hover:text-accent-foreground max-lg:pointer-events-auto"
className="h-9 rounded-full px-5 text-muted-foreground hover:text-accent-foreground"
data-testid="backup-skip"
onClick={onNext}
type="button"
@@ -199,7 +164,7 @@ export function BackupStep({
) : null}
<Button
className="h-10 w-full text-muted-foreground hover:text-accent-foreground max-lg:pointer-events-auto"
className="h-9 rounded-full bg-foreground/10 px-6 hover:bg-foreground/15"
data-testid="onboarding-back"
onClick={onBack}
type="button"
@@ -207,15 +172,6 @@ export function BackupStep({
>
Back
</Button>
<StepProgress
activeSegmentClassName="bg-primary"
className="mt-1 max-lg:pointer-events-auto lg:hidden"
completeSegmentClassName="bg-primary/35"
currentStep={currentStep}
inactiveSegmentClassName="bg-muted-foreground/25"
totalSteps={totalSteps}
/>
</div>
</OnboardingSlideTransition>
);
@@ -12,6 +12,7 @@ import { BackupStep } from "./BackupStep";
import { LandingBees } from "./LandingBees";
import { NostrKeyImportForm } from "./NostrKeyImportForm";
import { OnboardingSlideTransition } from "./OnboardingSlideTransition";
import { OnboardingStepDots } from "./OnboardingStepDots";
import { SetupStep } from "./SetupStep";
type MachinePage = "identity" | "key-import" | "backup" | "setup";
@@ -87,14 +88,17 @@ export function MachineOnboardingFlow({
return (
<div
className={`buzz-onboarding-neutral-theme buzz-startup-shell flex items-center justify-center px-4 py-8 text-foreground ${
page === "identity" ? "buzz-onboarding-welcome" : ""
}`}
className={`buzz-onboarding-neutral-theme buzz-startup-shell flex max-h-dvh items-start justify-center overflow-y-auto px-4 text-foreground ${
page === "setup" ? "py-24" : "py-8"
} ${page === "identity" ? "buzz-onboarding-welcome" : ""}`}
data-testid="machine-onboarding-gate"
>
<StartupWindowDragRegion />
{page === "identity" ? <LandingBees /> : null}
<div className="relative flex w-full max-w-[920px] flex-col items-center text-center">
{page !== "identity" ? (
<OnboardingStepDots current={page === "setup" ? 3 : 2} />
) : null}
<div className="relative my-auto flex w-full max-w-[920px] flex-col items-center text-center">
{page === "identity" ? (
<OnboardingSlideTransition
className="flex w-full max-w-[720px] flex-col items-center text-center"
@@ -135,17 +139,17 @@ export function MachineOnboardingFlow({
</OnboardingSlideTransition>
) : page === "key-import" ? (
<OnboardingSlideTransition
className="flex w-full max-w-[500px] flex-col items-center text-center"
className="flex w-full max-w-[640px] flex-col items-center text-center"
direction="forward"
transitionKey="machine-key-import"
>
<h1 className="text-3xl font-semibold tracking-tight">
{identityLost ? "Re-import your key" : "Use your existing key"}
{identityLost ? "Re-import your key" : "Enter your private key"}
</h1>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
<p className="mt-3 max-w-[440px] text-sm leading-6 text-foreground/80">
{identityLost
? "Your identity is no longer in the system keyring. Re-import your nsec to restore it."
: "Import your Nostr private key to use that identity with Buzz."}
: "If you already have a Nostr account, enter your private key below to get started."}
</p>
<NostrKeyImportForm
backLabel={identityLost ? "Start new identity" : "Back"}
@@ -155,15 +159,14 @@ export function MachineOnboardingFlow({
: () => setPage("identity")
}
onImport={importExistingIdentity}
variant="spotlight"
/>
</OnboardingSlideTransition>
) : page === "backup" ? (
<BackupStep
currentStep={2}
direction="forward"
onBack={() => setPage("identity")}
onNext={() => setPage("setup")}
totalSteps={3}
/>
) : (
<SetupStep
@@ -15,6 +15,8 @@ type NostrKeyImportFormProps = {
errorMessage?: string | null;
onBack: () => void;
onImport: (nsec: string) => Promise<void>;
/** "spotlight" is the first-launch treatment: glowy centered input, no drop zone, pill buttons. */
variant?: "default" | "spotlight";
};
/**
@@ -30,6 +32,7 @@ export function NostrKeyImportForm({
errorMessage: externalErrorMessage = null,
onBack,
onImport,
variant = "default",
}: NostrKeyImportFormProps) {
const [nsecInput, setNsecInput] = React.useState("");
const [isImporting, setIsImporting] = React.useState(false);
@@ -114,23 +117,31 @@ export function NostrKeyImportForm({
}}
>
<div className="space-y-1.5 text-left">
<label
className="text-sm font-medium text-foreground"
htmlFor="nostr-private-key"
>
Private key
</label>
{variant === "spotlight" ? null : (
<label
className="text-sm font-medium text-foreground"
htmlFor="nostr-private-key"
>
Private key
</label>
)}
<Input
autoComplete="off"
autoCorrect="off"
className="h-10 bg-background"
className={
variant === "spotlight"
? "h-16 rounded-2xl border-0 bg-white/85 text-center font-mono !text-xl shadow-[0_0_70px_45px_rgba(255,255,255,0.85)] placeholder:text-foreground/30 focus-visible:ring-0"
: "h-10 bg-background"
}
data-testid="nostr-import-nsec-input"
id="nostr-private-key"
onChange={(event) => {
setNsecInput(event.target.value);
setImportError(null);
}}
placeholder="nsec1..."
placeholder={
variant === "spotlight" ? "Enter your key here" : "nsec1..."
}
ref={inputRef}
spellCheck={false}
type="password"
@@ -138,86 +149,90 @@ export function NostrKeyImportForm({
/>
</div>
<input
accept=".key,text/plain"
className="sr-only"
disabled={isInteractionDisabled}
onChange={(event) => {
void handleFiles(event.currentTarget.files);
event.currentTarget.value = "";
}}
ref={fileInputRef}
tabIndex={-1}
type="file"
/>
{variant === "spotlight" ? null : (
<>
<input
accept=".key,text/plain"
className="sr-only"
disabled={isInteractionDisabled}
onChange={(event) => {
void handleFiles(event.currentTarget.files);
event.currentTarget.value = "";
}}
ref={fileInputRef}
tabIndex={-1}
type="file"
/>
<button
className={cn(
"relative flex h-[120px] flex-col items-center justify-center gap-3 overflow-hidden rounded-xl border border-transparent bg-muted text-foreground transition-[background-color,border-color,box-shadow,color] duration-[250ms] ease-out hover:bg-muted/80 disabled:opacity-60",
isDragging &&
"border-primary bg-primary/10 text-primary ring-1 ring-primary/35 hover:bg-primary/10",
)}
data-dragging={isDragging ? "true" : undefined}
data-testid="nostr-import-drop"
disabled={isInteractionDisabled}
onClick={openFilePicker}
onDragEnter={(event) => {
event.preventDefault();
event.stopPropagation();
if (!isInteractionDisabled) {
setIsDragging(true);
}
}}
onDragLeave={(event) => {
event.preventDefault();
event.stopPropagation();
if (
event.currentTarget.contains(event.relatedTarget as Node | null)
) {
return;
}
setIsDragging(false);
}}
onDragOver={(event) => {
event.preventDefault();
event.stopPropagation();
if (!isInteractionDisabled) {
event.dataTransfer.dropEffect = "copy";
}
}}
onDrop={(event) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(false);
if (isInteractionDisabled) {
return;
}
void handleFiles(event.dataTransfer.files);
}}
type="button"
>
<span
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-0 rounded-[inherit] bg-primary/10 opacity-0 transition-opacity duration-[250ms] ease-out",
isDragging && "opacity-100",
)}
/>
<KeyRound
className={cn(
"relative h-8 w-8 text-muted-foreground transition-colors duration-[250ms] ease-out",
isDragging && "text-primary",
)}
/>
<span
className={cn(
"relative text-sm font-medium text-muted-foreground transition-colors duration-[250ms] ease-out",
isDragging && "text-primary",
)}
>
Drop a key here
</span>
</button>
<button
className={cn(
"relative flex h-[120px] flex-col items-center justify-center gap-3 overflow-hidden rounded-xl border border-transparent bg-muted text-foreground transition-[background-color,border-color,box-shadow,color] duration-[250ms] ease-out hover:bg-muted/80 disabled:opacity-60",
isDragging &&
"border-primary bg-primary/10 text-primary ring-1 ring-primary/35 hover:bg-primary/10",
)}
data-dragging={isDragging ? "true" : undefined}
data-testid="nostr-import-drop"
disabled={isInteractionDisabled}
onClick={openFilePicker}
onDragEnter={(event) => {
event.preventDefault();
event.stopPropagation();
if (!isInteractionDisabled) {
setIsDragging(true);
}
}}
onDragLeave={(event) => {
event.preventDefault();
event.stopPropagation();
if (
event.currentTarget.contains(event.relatedTarget as Node | null)
) {
return;
}
setIsDragging(false);
}}
onDragOver={(event) => {
event.preventDefault();
event.stopPropagation();
if (!isInteractionDisabled) {
event.dataTransfer.dropEffect = "copy";
}
}}
onDrop={(event) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(false);
if (isInteractionDisabled) {
return;
}
void handleFiles(event.dataTransfer.files);
}}
type="button"
>
<span
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-0 rounded-[inherit] bg-primary/10 opacity-0 transition-opacity duration-[250ms] ease-out",
isDragging && "opacity-100",
)}
/>
<KeyRound
className={cn(
"relative h-8 w-8 text-muted-foreground transition-colors duration-[250ms] ease-out",
isDragging && "text-primary",
)}
/>
<span
className={cn(
"relative text-sm font-medium text-muted-foreground transition-colors duration-[250ms] ease-out",
isDragging && "text-primary",
)}
>
Drop a key here
</span>
</button>
</>
)}
{previewNpub ? (
<div
@@ -246,22 +261,36 @@ export function NostrKeyImportForm({
<p className="text-center text-sm text-destructive">{errorMessage}</p>
) : null}
<div className="flex w-full flex-col gap-3 pt-1">
<div
className={
variant === "spotlight"
? "mt-4 flex w-full flex-col items-center gap-3"
: "flex w-full flex-col gap-3 pt-1"
}
>
<Button
className="h-10 w-full"
className={
variant === "spotlight" ? "h-10 rounded-full px-8" : "h-10 w-full"
}
data-testid="nostr-import-submit"
disabled={!isValid || isInteractionDisabled}
type="submit"
>
{isImporting ? (
<Spinner aria-label="Importing key" className="h-4 w-4 border-2" />
) : variant === "spotlight" ? (
"Next"
) : (
"Continue with this key"
)}
</Button>
<Button
className="h-10 w-full text-muted-foreground hover:text-accent-foreground"
className={
variant === "spotlight"
? "h-9 rounded-full bg-foreground/10 px-6 hover:bg-foreground/15"
: "h-10 w-full text-muted-foreground hover:text-accent-foreground"
}
disabled={isImporting}
onClick={onBack}
type="button"
@@ -4,6 +4,8 @@ import { Button } from "@/shared/ui/button";
type NsecMaskedDisplayProps = {
nsec: string;
/** "bare" drops the boxed chrome for the onboarding spotlight treatment. */
variant?: "boxed" | "bare";
};
/**
@@ -14,7 +16,10 @@ type NsecMaskedDisplayProps = {
* - select is disabled while masked (user-select: none)
* - state is cleared when the component unmounts
*/
export function NsecMaskedDisplay({ nsec }: NsecMaskedDisplayProps) {
export function NsecMaskedDisplay({
nsec,
variant = "boxed",
}: NsecMaskedDisplayProps) {
const [isRevealed, setIsRevealed] = React.useState(false);
const [isCopied, setIsCopied] = React.useState(false);
const copyTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -40,10 +45,18 @@ export function NsecMaskedDisplay({ nsec }: NsecMaskedDisplayProps) {
}
return (
<div className="overflow-hidden rounded-lg border border-border/70 bg-muted/30">
<div
className={
variant === "boxed"
? "overflow-hidden rounded-lg border border-border/70 bg-muted/30"
: ""
}
>
<div className="flex items-center gap-2 px-3 py-2">
<p
className={`min-w-0 flex-1 break-all font-mono text-xs leading-5 ${
className={`min-w-0 flex-1 break-all font-mono leading-5 ${
variant === "bare" ? "text-base" : "text-xs"
} ${
isRevealed
? "select-text text-foreground"
: "select-none text-muted-foreground blur-[4px]"
@@ -0,0 +1,40 @@
import { BuzzMark } from "@/shared/ui/buzz-logo/BuzzMark";
/**
* Positions in the first-launch flow: landing, identity/key, provider setup,
* community choice, community profile, meet the team.
*/
export const TOTAL_ONBOARDING_PAGES = 6;
/**
* Top-left paging indicator for the first-launch flow. The bee marks the
* current page on a dot track; shown on every page after the landing screen.
*/
export function OnboardingStepDots({
current,
total = TOTAL_ONBOARDING_PAGES,
}: {
current: number;
total?: number;
}) {
return (
<div
aria-hidden
className="pointer-events-none fixed left-6 top-12 z-10 flex items-center gap-2.5 text-foreground"
data-testid="onboarding-step-dots"
>
{Array.from({ length: total }, (_, i) => i + 1).map((position) =>
position === current ? (
<span className="block w-9" key={position}>
<BuzzMark className="h-auto w-full" />
</span>
) : (
<span
className="block h-[7px] w-[7px] rounded-full bg-foreground"
key={position}
/>
),
)}
</div>
);
}
@@ -59,66 +59,21 @@ test("currentStep_falls_back_to_1_for_pages_outside_the_step_list", () => {
test("backup_next_disabled_while_loading", () => {
// During a slow keychain read, Next must be blocked — user cannot race past
// the key display before it is shown.
assert.equal(
backupNextDisabled({
isLoading: true,
loadError: null,
nsec: null,
hasAcknowledged: false,
}),
true,
);
assert.equal(backupNextDisabled({ isLoading: true, loadError: null }), true);
});
test("backup_next_disabled_on_load_error", () => {
// Error state: only the explicit "Skip for now" ghost advances; Next blocked.
assert.equal(
backupNextDisabled({
isLoading: false,
loadError: "IPC error",
nsec: null,
hasAcknowledged: false,
}),
backupNextDisabled({ isLoading: false, loadError: "IPC error" }),
true,
);
});
test("backup_next_disabled_when_nsec_loaded_and_not_acknowledged", () => {
// Key shown but checkbox unchecked.
test("backup_next_enabled_after_clean_load", () => {
// Key shown (or backend cleanly returned none) — user may proceed.
assert.equal(
backupNextDisabled({
isLoading: false,
loadError: null,
nsec: "nsec1test",
hasAcknowledged: false,
}),
true,
);
});
test("backup_next_enabled_when_nsec_loaded_and_acknowledged", () => {
// Key shown and checkbox checked — the normal happy path.
assert.equal(
backupNextDisabled({
isLoading: false,
loadError: null,
nsec: "nsec1test",
hasAcknowledged: true,
}),
false,
);
});
test("backup_next_enabled_when_backend_returned_null_key_cleanly", () => {
// Backend returned no key without an error (edge case): nothing to acknowledge,
// allow forward progress so onboarding is never bricked.
assert.equal(
backupNextDisabled({
isLoading: false,
loadError: null,
nsec: null,
hasAcknowledged: false,
}),
backupNextDisabled({ isLoading: false, loadError: null }),
false,
);
});