feat(onboarding): add perceived key-creation loading state to backup step

- BackupStep.tsx: replace the static "Account created!" landing with a
  two-phase intro driven by a 1.4s timer (INTRO_HOLD_MS):
  - Loading phase: title "Creating your identity key" with the animated
    FuzzyLogo mark rendered in place of the body content; description,
    Keycase card, and footer CTAs are withheld
  - Finished phase: title swaps to "Your unique identity key has been
    created" with new copy ("Your identity key will be saved to your
    keychain. Back it up somewhere safe so you can restore your account.
    Never share your key."), and the Keycase flow + footer fade in via
    animate-in fade-in (700ms, disabled under prefers-reduced-motion)
- Unify the previously mode-dependent (encrypted vs raw) header copy into
  the single new description string
- Use plain string concat for the h1 classes: cn()'s tailwind-merge
  misreads the custom text-title size token as conflicting with
  text-foreground and drops it
- onboarding-backup.spec.ts: assert the loading -> finished title/logo
  sequence, and wait for the intro logo to unmount before screenshots so
  captures are deterministic

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-07-28 13:11:35 -07:00
parent 8840d6c63d
commit 88d0c59689
2 changed files with 176 additions and 108 deletions
+161 -107
View File
@@ -2,7 +2,9 @@ import { AlertTriangle, Info, RefreshCw } from "lucide-react";
import * as React from "react";
import { getNsec } from "@/shared/api/tauriIdentity";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { FuzzyLogo } from "@/shared/ui/buzz-logo/FuzzyLogo";
import { Card } from "@/shared/ui/card";
import { Spinner } from "@/shared/ui/spinner";
import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome";
@@ -16,6 +18,16 @@ import { NsecMaskedDisplay } from "./NsecMaskedDisplay";
export type BackupStepMode = "encrypted" | "raw";
/**
* How long the "Creating your identity key" loader holds the stage before the
* finished state fades in. Purely perceptual — the key already exists; the
* pause sells the creation moment.
*/
const INTRO_HOLD_MS = 1400;
const REVEAL_ANIMATION_CLASS =
"animate-in fade-in duration-700 motion-reduce:animate-none";
/** Saving a Keycase is recommended, never required to continue onboarding. */
export function backupNextDisabled(): boolean {
return false;
@@ -36,8 +48,14 @@ export function BackupStep({ direction, onBack, onNext }: BackupStepProps) {
const [nsec, setNsec] = React.useState<string | null>(null);
const [isLoading, setIsLoading] = React.useState(false);
const [loadError, setLoadError] = React.useState<string | null>(null);
const [created, setCreated] = React.useState(false);
const cancelledRef = React.useRef(false);
React.useEffect(() => {
const timer = window.setTimeout(() => setCreated(true), INTRO_HOLD_MS);
return () => window.clearTimeout(timer);
}, []);
const loadNsec = React.useCallback(async () => {
setIsLoading(true);
setLoadError(null);
@@ -79,131 +97,167 @@ export function BackupStep({ direction, onBack, onNext }: BackupStepProps) {
transitionKey={`backup-${direction}`}
>
<div className="flex w-full max-w-[500px] shrink-0 flex-col text-center">
<h1 className="text-title font-normal text-foreground">
Account created!
{/* Plain string concat: cn()'s tailwind-merge misreads the custom
text-title size token as conflicting with text-foreground. */}
<h1
className={`text-title font-normal text-foreground ${REVEAL_ANIMATION_CLASS}`}
key={created ? "created" : "creating"}
>
{created
? "Your unique identity key has been created"
: "Creating your identity key"}
</h1>
<p className="mt-5 text-sm leading-6 text-foreground/80">
{mode === "encrypted"
? "Buzz keeps your identity in the system keychain. Save a portable, password-protected Keycase in case you need to restore it elsewhere."
: "This key is stored in your system keychain, but save it some place safe in case you ever need to restore your account."}
</p>
{created ? (
<p
className={cn(
"mt-5 text-sm leading-6 text-foreground/80",
REVEAL_ANIMATION_CLASS,
)}
>
Your identity key will be saved to your keychain. Back it up
somewhere safe so you can restore your account. Never share your
key.
</p>
) : null}
</div>
<div className="flex w-full max-w-[1040px] flex-1 flex-col justify-center py-10">
{mode === "encrypted" ? (
<Card className="w-full px-8 py-6" variant="textured">
<div className="mx-auto w-full max-w-[832px]">
<div className="mb-5 space-y-2 text-center">
<h2 className="text-lg font-medium">Save a Keycase</h2>
<p className="text-xs leading-5 text-muted-foreground">
A Keycase is still private. Never publish or share it. You
need both the file and password to restore your identity.
</p>
{!created ? (
<div
className="flex w-full flex-1 items-center justify-center py-10"
data-testid="backup-intro-logo"
>
<FuzzyLogo
ariaLabel="Creating your identity key"
className="w-20! text-foreground"
fuzz
loop
loopRestSeconds={0}
/>
</div>
) : (
<div
className={cn(
"flex w-full max-w-[1040px] flex-1 flex-col justify-center py-10",
REVEAL_ANIMATION_CLASS,
)}
>
{mode === "encrypted" ? (
<Card className="w-full px-8 py-6" variant="textured">
<div className="mx-auto w-full max-w-[832px]">
<div className="mb-5 space-y-2 text-center">
<h2 className="text-lg font-medium">Save a Keycase</h2>
<p className="text-xs leading-5 text-muted-foreground">
A Keycase is still private. Never publish or share it. You
need both the file and password to restore your identity.
</p>
</div>
<EncryptedBackupCreator variant="spotlight" />
</div>
<EncryptedBackupCreator variant="spotlight" />
</Card>
) : isLoading ? (
<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>
</Card>
) : isLoading ? (
<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="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"
>
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>
Could not retrieve your private key: {loadError}. You can
continue and find it later in Settings &gt; Profile &gt;
Identity.
</span>
) : loadError ? (
<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"
>
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>
Could not retrieve your private key: {loadError}. You can
continue and find it later in Settings &gt; Profile &gt;
Identity.
</span>
</div>
<Button
className="h-8 gap-1.5 text-sm"
data-testid="backup-retry"
onClick={() => void loadNsec()}
size="sm"
type="button"
variant="outline"
>
<RefreshCw className="h-3.5 w-3.5" />
Try again
</Button>
</div>
<Button
className="h-8 gap-1.5 text-sm"
data-testid="backup-retry"
onClick={() => void loadNsec()}
size="sm"
type="button"
variant="outline"
>
<RefreshCw className="h-3.5 w-3.5" />
Try again
</Button>
</div>
) : nsec ? (
<Card className="w-full px-8 py-6" variant="textured">
<div className="mx-auto w-full max-w-[832px]">
<NsecMaskedDisplay nsec={nsec} variant="bare" />
</div>
</Card>
) : (
<p className="text-center text-sm text-foreground/70">
No key available to back up.
</p>
)}
) : nsec ? (
<Card className="w-full px-8 py-6" variant="textured">
<div className="mx-auto w-full max-w-[832px]">
<NsecMaskedDisplay nsec={nsec} variant="bare" />
</div>
</Card>
) : (
<p className="text-center text-sm text-foreground/70">
No key available to back up.
</p>
)}
{mode === "encrypted" ? (
<div className="mt-4 flex justify-center">
{mode === "encrypted" ? (
<div className="mt-4 flex justify-center">
<Button
className="h-8 text-sm text-muted-foreground hover:text-accent-foreground"
data-testid="backup-show-raw-key"
onClick={showRawKey}
size="sm"
type="button"
variant="ghost"
>
Advanced: show my private key instead
</Button>
</div>
) : null}
{mode === "raw" && nsec ? (
<p className="mx-auto mt-6 flex max-w-[440px] items-start justify-center gap-1.5 text-center text-xs leading-5 text-[var(--buzz-onboarding-backup-ink)]">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>
Never share your private key. Anyone with this key can
impersonate you and access everything in your account.
</span>
</p>
) : null}
</div>
)}
{created ? (
<OnboardingFooter className={REVEAL_ANIMATION_CLASS}>
<Button
className={ONBOARDING_PRIMARY_CTA_CLASS}
data-testid="onboarding-next"
disabled={backupNextDisabled()}
onClick={onNext}
type="button"
>
Next
</Button>
{mode === "raw" && loadError ? (
<Button
className="h-8 text-sm text-muted-foreground hover:text-accent-foreground"
data-testid="backup-show-raw-key"
onClick={showRawKey}
size="sm"
className="h-9 rounded-full px-5 text-muted-foreground hover:text-accent-foreground"
data-testid="backup-skip"
onClick={onNext}
type="button"
variant="ghost"
>
Advanced: show my private key instead
Skip for now
</Button>
</div>
) : null}
) : null}
{mode === "raw" && nsec ? (
<p className="mx-auto mt-6 flex max-w-[440px] items-start justify-center gap-1.5 text-center text-xs leading-5 text-[var(--buzz-onboarding-backup-ink)]">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>
Never share your private key. Anyone with this key can impersonate
you and access everything in your account.
</span>
</p>
) : null}
</div>
<OnboardingFooter>
<Button
className={ONBOARDING_PRIMARY_CTA_CLASS}
data-testid="onboarding-next"
disabled={backupNextDisabled()}
onClick={onNext}
type="button"
>
Next
</Button>
{mode === "raw" && loadError ? (
<Button
className="h-9 rounded-full px-5 text-muted-foreground hover:text-accent-foreground"
data-testid="backup-skip"
onClick={onNext}
className="h-9 rounded-full bg-foreground/10 px-6 hover:bg-foreground/15"
data-testid="onboarding-back"
onClick={onBack}
type="button"
variant="ghost"
>
Skip for now
Back
</Button>
) : null}
<Button
className="h-9 rounded-full bg-foreground/10 px-6 hover:bg-foreground/15"
data-testid="onboarding-back"
onClick={onBack}
type="button"
variant="ghost"
>
Back
</Button>
</OnboardingFooter>
</OnboardingFooter>
) : null}
</OnboardingSlideTransition>
);
}
+15 -1
View File
@@ -19,11 +19,20 @@ test("backup step appears on fresh-key path after profile submit", async ({
await enterMachineBackup(page);
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
// Perceived-loading intro: the animated logo and "Creating" title show
// first, then the finished state replaces them after the hold.
await expect(
page.getByRole("heading", { name: "Creating your identity key" }),
).toBeVisible();
await expect(page.getByTestId("backup-intro-logo")).toBeVisible();
await expect(
page.getByRole("heading", {
name: "Account created!",
name: "Your unique identity key has been created",
}),
).toBeVisible();
await expect(page.getByTestId("backup-intro-logo")).toHaveCount(0);
});
// ---------------------------------------------------------------------------
@@ -40,6 +49,9 @@ test("Keycase happy path: generated password, create, native save, Next", async
await expect(page.getByTestId("backup-passphrase-generated")).toBeVisible();
await expect(page.getByTestId("onboarding-next")).toBeEnabled();
// Let the perceived-loading intro (animated logo → content fade-in) finish
// so the screenshot captures fully opaque content.
await expect(page.getByTestId("backup-intro-logo")).toHaveCount(0);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/02-backup-step-passphrase.png` });
@@ -120,6 +132,8 @@ test("raw key path is one explicit click away and shows the masked nsec", async
await expect(nsecDisplay).not.toHaveCSS("filter", /blur/);
await expect(nsecDisplay).toContainText("nsec1mock");
// Intro crossfade must be finished before capturing.
await expect(page.getByTestId("backup-intro-logo")).toHaveCount(0);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/04-backup-step-raw-revealed.png` });