mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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 > Profile >
|
||||
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 > Profile >
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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` });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user