From 88d0c59689c93bcd2161513dd69f2c42c1015889 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 28 Jul 2026 13:11:35 -0700 Subject: [PATCH] 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 Signed-off-by: Taylor Ho --- .../src/features/onboarding/ui/BackupStep.tsx | 268 +++++++++++------- desktop/tests/e2e/onboarding-backup.spec.ts | 16 +- 2 files changed, 176 insertions(+), 108 deletions(-) diff --git a/desktop/src/features/onboarding/ui/BackupStep.tsx b/desktop/src/features/onboarding/ui/BackupStep.tsx index ddd4b1cb7..aecc128dd 100644 --- a/desktop/src/features/onboarding/ui/BackupStep.tsx +++ b/desktop/src/features/onboarding/ui/BackupStep.tsx @@ -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(null); const [isLoading, setIsLoading] = React.useState(false); const [loadError, setLoadError] = React.useState(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}`} >
-

- Account created! + {/* Plain string concat: cn()'s tailwind-merge misreads the custom + text-title size token as conflicting with text-foreground. */} +

+ {created + ? "Your unique identity key has been created" + : "Creating your identity key"}

-

- {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."} -

+ {created ? ( +

+ Your identity key will be saved to your keychain. Back it up + somewhere safe so you can restore your account. Never share your + key. +

+ ) : null}
-
- {mode === "encrypted" ? ( - -
-
-

Save a Keycase

-

- A Keycase is still private. Never publish or share it. You - need both the file and password to restore your identity. -

+ {!created ? ( +
+ +
+ ) : ( +
+ {mode === "encrypted" ? ( + +
+
+

Save a Keycase

+

+ A Keycase is still private. Never publish or share it. You + need both the file and password to restore your identity. +

+
+
- +
+ ) : isLoading ? ( +
+ + Loading your private key…
- - ) : isLoading ? ( -
- - Loading your private key… -
- ) : loadError ? ( -
-
- - - Could not retrieve your private key: {loadError}. You can - continue and find it later in Settings > Profile > - Identity. - + ) : loadError ? ( +
+
+ + + Could not retrieve your private key: {loadError}. You can + continue and find it later in Settings > Profile > + Identity. + +
+
- -
- ) : nsec ? ( - -
- -
-
- ) : ( -

- No key available to back up. -

- )} + ) : nsec ? ( + +
+ +
+
+ ) : ( +

+ No key available to back up. +

+ )} - {mode === "encrypted" ? ( -
+ {mode === "encrypted" ? ( +
+ +
+ ) : null} + + {mode === "raw" && nsec ? ( +

+ + + Never share your private key. Anyone with this key can + impersonate you and access everything in your account. + +

+ ) : null} +
+ )} + + {created ? ( + + + + {mode === "raw" && loadError ? ( -
- ) : null} + ) : null} - {mode === "raw" && nsec ? ( -

- - - Never share your private key. Anyone with this key can impersonate - you and access everything in your account. - -

- ) : null} -
- - - - - {mode === "raw" && loadError ? ( - ) : null} - - - + + ) : null} ); } diff --git a/desktop/tests/e2e/onboarding-backup.spec.ts b/desktop/tests/e2e/onboarding-backup.spec.ts index e123e0079..99fa40192 100644 --- a/desktop/tests/e2e/onboarding-backup.spec.ts +++ b/desktop/tests/e2e/onboarding-backup.spec.ts @@ -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` });