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` });