feat(desktop): integrate backup options into identity onboarding

- Replace the standalone password-backup step with copy and encrypted-file options inside the identity-key stage.
- Add a midnight security theme, return-to-onboarding control, and downward transition back into the core yellow flow.
- Restore seven-page onboarding pagination and keep backup progress available when users revisit the security view.
- Update onboarding E2E coverage and screenshots for the inline options, direct setup path, and password-backup return behavior.

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-29 23:52:20 -07:00
parent 6a24905a2e
commit e8c7f15aee
11 changed files with 400 additions and 165 deletions
@@ -103,7 +103,7 @@ export function WelcomeSetup({
data-system-color-scheme={systemColorScheme}
>
<StartupWindowDragRegion />
<OnboardingChrome current={6} />
<OnboardingChrome current={5} />
<OnboardingFooterProvider>
<div className="relative flex min-h-0 w-full max-w-[920px] flex-1 flex-col items-center text-center">
{page === "welcome" ? (
+165 -62
View File
@@ -1,4 +1,12 @@
import { Check, Copy, Eye, EyeOff, Info } from "lucide-react";
import {
Check,
ChevronDown,
Copy,
Eye,
EyeOff,
Info,
ShieldCheck,
} from "lucide-react";
import * as React from "react";
import { getNsec } from "@/shared/api/tauriIdentity";
@@ -8,7 +16,6 @@ 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 { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome";
import { OnboardingFooter } from "./OnboardingFooter";
import {
@@ -42,18 +49,28 @@ export function backupNextDisabled(): boolean {
type BackupStepProps = {
direction: OnboardingTransitionDirection;
onBack: () => void;
/** Advances to the dedicated "Download your key" onboarding step. */
onDownload: () => void;
onNext: () => void;
onOpenPasswordBackup: () => void;
onShowOptions: () => void;
optionsExpanded: boolean;
returningFromSecurity: boolean;
};
/**
* Onboarding backup step — shows the freshly created key and offers a direct
* clipboard copy destined for a password manager. Next leads into the
* encrypted download step (its own onboarding page, via `onDownload`), which
* is skippable there. The raw key is fetched only when the user explicitly
* clicks Copy or Reveal, and is never held in state before that.
* Onboarding identity-key step — shows the freshly created key, then opens a
* dark backup-options state. Copy fetches the raw key only after an explicit
* click; password backup opens the separate security flow. Neither method
* blocks Next.
*/
export function BackupStep({ direction, onBack, onDownload }: BackupStepProps) {
export function BackupStep({
direction,
onBack,
onNext,
onOpenPasswordBackup,
onShowOptions,
optionsExpanded,
returningFromSecurity,
}: BackupStepProps) {
const [created, setCreated] = React.useState(introPlayed);
const [copyState, setCopyState] = React.useState<
"idle" | "copying" | "copied"
@@ -136,12 +153,116 @@ export function BackupStep({ direction, onBack, onDownload }: BackupStepProps) {
[nsec],
);
if (optionsExpanded) {
return (
<OnboardingSlideTransition
className="flex min-h-0 w-full flex-col items-center"
data-testid="onboarding-page-backup-options"
direction={direction}
effect={direction === "forward" ? "mask-reveal-up" : "line-slide"}
transitionKey={`backup-options-${direction}`}
>
<div className="flex w-full max-w-140 shrink-0 flex-col text-center">
<h1 className="text-title font-normal text-foreground">
Backup options
</h1>
<p className="mt-5 text-sm leading-6 text-foreground/75">
Choose how you want to keep a restorable copy of your identity key.
</p>
</div>
<div className="flex w-full max-w-240 flex-1 flex-col justify-center py-10">
<div
className="grid w-full grid-cols-1 gap-8 md:grid-cols-2"
data-testid="backup-options"
>
<div className="relative min-h-56 w-full">
<Card
aria-hidden="true"
className="pointer-events-none absolute inset-0 brightness-[0.005]"
variant="textured"
/>
<div className="relative z-10 flex min-h-56 w-full flex-col justify-center p-20 text-left text-foreground">
<span className="text-lg font-medium">
Save your key safely
</span>
<span className="mt-3 block text-sm leading-6 text-foreground/65">
A password manager such as 1Password is a good place to keep
your private key.
</span>
<Button
className="mt-5 h-9 w-fit gap-2 rounded-full border border-white/20 bg-white/10 px-5 text-foreground hover:bg-white/15 hover:text-foreground"
data-testid="backup-copy-key"
disabled={copyState === "copying"}
onClick={() => void copyKeyToClipboard()}
type="button"
variant="ghost"
>
{copyState === "copying" ? (
<Spinner className="h-4 w-4 border-2" />
) : copyState === "copied" ? (
<Check className="h-4 w-4" aria-hidden="true" />
) : (
<Copy className="h-4 w-4" aria-hidden="true" />
)}
{copyState === "copying"
? "Copying…"
: copyState === "copied"
? "Copied to clipboard"
: "Copy to clipboard"}
</Button>
</div>
</div>
<div className="relative min-h-56 w-full">
<Card
aria-hidden="true"
className="pointer-events-none absolute inset-0 brightness-[0.005]"
variant="textured"
/>
<div className="relative z-10 flex min-h-56 w-full flex-col justify-center p-20 text-left text-foreground">
<span className="text-lg font-medium">
Create a portable backup
</span>
<span className="mt-3 block text-sm leading-6 text-foreground/65">
Create a password-protected file you can store safely and use
to restore your identity.
</span>
<Button
className="mt-5 h-9 w-fit gap-2 rounded-full border border-white/20 bg-white/10 px-5 text-foreground hover:bg-white/15 hover:text-foreground"
data-testid="backup-option-password"
onClick={onOpenPasswordBackup}
type="button"
variant="ghost"
>
<ShieldCheck className="h-5 w-5" aria-hidden="true" />
Back up with a password
</Button>
</div>
</div>
</div>
{copyError ? (
<p
className="mt-4 text-center text-sm text-destructive"
data-testid="backup-copy-error"
>
Could not retrieve your private key: {copyError}. You can continue
and find it later in Settings &gt; Profile &gt; Identity.
</p>
) : null}
</div>
</OnboardingSlideTransition>
);
}
return (
<OnboardingSlideTransition
className="flex min-h-0 w-full flex-col items-center"
data-testid="onboarding-page-backup"
direction={direction}
transitionKey={`backup-${direction}`}
effect={returningFromSecurity ? "mask-reveal-down" : "line-slide"}
transitionKey={`backup-${direction}-${returningFromSecurity ? "down" : "line"}`}
>
<div className="flex w-full max-w-[500px] shrink-0 flex-col text-center">
{/* Plain string concat: cn()'s tailwind-merge misreads the custom
@@ -204,55 +325,37 @@ export function BackupStep({ direction, onBack, onDownload }: BackupStepProps) {
{isRevealed && nsec ? nsec : maskedKey}
</p>
</div>
<div className="flex shrink-0 gap-1.5">
<Button
aria-label={
isRevealed ? "Hide private key" : "Reveal private key"
}
className="h-10 w-10 text-muted-foreground hover:text-foreground"
data-testid="backup-key-reveal-toggle"
onClick={() => void toggleReveal()}
size="icon"
type="button"
variant="ghost"
>
{isRevealed ? (
<EyeOff className="h-6 w-6" aria-hidden="true" />
) : (
<Eye className="h-6 w-6" aria-hidden="true" />
)}
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="Copy private key"
className="h-10 w-10 text-muted-foreground hover:text-foreground"
data-testid="backup-copy-key"
disabled={copyState === "copying"}
onClick={() => void copyKeyToClipboard()}
size="icon"
type="button"
variant="ghost"
>
{copyState === "copying" ? (
<Spinner className="h-5 w-5 border-2" />
) : copyState === "copied" ? (
<Check
className="h-6 w-6 text-primary"
aria-hidden="true"
/>
) : (
<Copy className="h-6 w-6" aria-hidden="true" />
)}
</Button>
</TooltipTrigger>
<TooltipContent className="max-w-[240px] text-center">
Copy your key and save it somewhere safe — a password
manager is a great place for it.
</TooltipContent>
</Tooltip>
</div>
<Button
aria-label={
isRevealed ? "Hide private key" : "Reveal private key"
}
className="h-10 w-10 shrink-0 text-muted-foreground hover:text-foreground"
data-testid="backup-key-reveal-toggle"
onClick={() => void toggleReveal()}
size="icon"
type="button"
variant="ghost"
>
{isRevealed ? (
<EyeOff className="h-6 w-6" aria-hidden="true" />
) : (
<Eye className="h-6 w-6" aria-hidden="true" />
)}
</Button>
</div>
</Card>
<div className="mt-4 flex flex-col items-center">
<Button
className="h-9 gap-1.5 rounded-full bg-foreground/10 px-6 hover:bg-foreground/15"
data-testid="backup-options-toggle"
onClick={onShowOptions}
type="button"
variant="ghost"
>
Backup options
<ChevronDown className="h-4 w-4" aria-hidden="true" />
</Button>
{copyError ? (
<p
className="mt-3 text-center text-sm text-destructive"
@@ -263,9 +366,9 @@ export function BackupStep({ direction, onBack, onDownload }: BackupStepProps) {
Identity.
</p>
) : null}
</Card>
</div>
<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)]">
<p className="mx-auto mt-5 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
@@ -282,7 +385,7 @@ export function BackupStep({ direction, onBack, onDownload }: BackupStepProps) {
className={ONBOARDING_PRIMARY_CTA_CLASS}
data-testid="onboarding-next"
disabled={backupNextDisabled()}
onClick={onDownload}
onClick={onNext}
type="button"
>
Next
@@ -448,7 +448,7 @@ export function CommunityOnboardingFlow({
>
<StartupWindowDragRegion />
{isProfileStage || isTeamStage ? (
<OnboardingChrome current={isTeamStage ? 8 : 7} />
<OnboardingChrome current={isTeamStage ? 7 : 6} />
) : null}
<OnboardingFooterProvider>
<div
@@ -11,7 +11,6 @@ import {
} from "./OnboardingSlideTransition";
import {
type EncryptedBackupSession,
backupSessionToPasswordEntry,
EncryptedBackupCreator,
} from "./EncryptedBackupCreator";
@@ -23,20 +22,17 @@ type DownloadKeyStepProps = {
* the backup-test progress.
*/
session: EncryptedBackupSession;
onBack: () => void;
onNext: () => void;
};
/**
* Onboarding download step — the password-first encrypted key download
* flow, promoted to its own page in the machine onboarding flow.
* Password-backup security subview within the identity-key onboarding step.
* The raw key never enters this component: Rust builds the NIP-49 payload
* locally and the native save dialog produces the user-owned file.
*/
export function DownloadKeyStep({
direction,
session,
onBack,
onNext,
}: DownloadKeyStepProps) {
const reduceMotion = useReducedMotion() ?? false;
@@ -79,7 +75,7 @@ export function DownloadKeyStep({
initial={reduceMotion ? false : { opacity: 0, y: 12 }}
transition={{ delay: 0.12, duration: 0.4, ease: "easeOut" }}
>
<Card className="w-full px-8 py-6" variant="textured">
<Card className="w-full border-foreground/15 bg-card px-8 py-6 shadow-none">
<div className="mx-auto w-full max-w-[832px]">
<EncryptedBackupCreator
createButtonClassName={ONBOARDING_PRIMARY_CTA_CLASS}
@@ -138,20 +134,6 @@ export function DownloadKeyStep({
</div>
)}
<Button
className="h-9 rounded-full bg-foreground/10 px-6 hover:bg-foreground/15"
data-testid="onboarding-back"
onClick={
// From the test view, Back first returns to the password form;
// only from the form does it leave the step entirely.
hasCreated ? () => backupSessionToPasswordEntry(session) : onBack
}
type="button"
variant="ghost"
>
Back
</Button>
{hasCreated ? null : (
<p className="text-xs text-foreground/50">
You can back up your key anytime in Settings &rarr; Profile &rarr;
@@ -255,10 +255,12 @@ function PassphraseGeneratorPopover({
disabled = false,
onRequestGenerate,
onGenerated,
securityTheme = false,
}: {
disabled?: boolean;
onRequestGenerate?: () => void;
onGenerated: (value: string) => void;
securityTheme?: boolean;
}) {
const [open, setOpen] = React.useState(false);
const [words, setWords] = React.useState(DEFAULT_GENERATED_WORDS);
@@ -335,7 +337,10 @@ function PassphraseGeneratorPopover({
</PopoverAnchor>
<PopoverContent
align="end"
className="w-72 space-y-3"
className={cn(
"w-72 space-y-3",
securityTheme && "buzz-onboarding-security-theme",
)}
onInteractOutside={(event) => {
// Clicking the anchor icon is "outside" the content — keep the
// popover open so that click re-rolls instead of closing.
@@ -677,6 +682,7 @@ export function EncryptedBackupCreator({
// A generated password must be visible so the user can save it.
setIsRevealed(true);
}}
securityTheme={variant === "spotlight"}
/>
{issue ? (
<p
@@ -769,7 +775,12 @@ export function EncryptedBackupCreator({
open={confirmNewPassword}
onOpenChange={setConfirmNewPassword}
>
<AlertDialogContent data-testid="backup-change-password-dialog">
<AlertDialogContent
className={cn(
variant === "spotlight" && "buzz-onboarding-security-theme",
)}
data-testid="backup-change-password-dialog"
>
<AlertDialogHeader>
<AlertDialogTitle>Create a new backup password?</AlertDialogTitle>
<AlertDialogDescription>
@@ -1,5 +1,6 @@
import * as React from "react";
import type { QueryClient } from "@tanstack/react-query";
import { ArrowUp } from "lucide-react";
import {
getIdentity,
@@ -30,10 +31,11 @@ export type MachineOnboardingPage =
| "identity"
| "key-import"
| "backup"
| "download"
| "setup"
| "config";
type BackupSubview = "created" | "options" | "password";
/** A pending navigation the parent should execute after RouterProvider mounts. */
export type PostOnboardingNavigation = {
to: string;
@@ -71,9 +73,17 @@ export function MachineOnboardingFlow({
null,
);
const [readyRuntimeIds, setReadyRuntimeIds] = React.useState<string[]>([]);
// Owned here (not by DownloadKeyStep) so Back navigation — which unmounts
// the step — keeps the created backup, entered password, and test progress.
const [backupSubview, setBackupSubview] =
React.useState<BackupSubview>("created");
const [backupDirection, setBackupDirection] = React.useState<
"forward" | "backward"
>("forward");
const [returningFromSecurity, setReturningFromSecurity] =
React.useState(false);
// Owned here so switching between the yellow onboarding view and the dark
// security subview keeps the created backup, password, and test progress.
const backupSession = useEncryptedBackupSession();
const isSecuritySubview = page === "backup" && backupSubview !== "created";
const handleReadyRuntimeIdsChange = React.useCallback(
(runtimeIds: readonly string[]) => {
setReadyRuntimeIds(Array.from(new Set(runtimeIds)));
@@ -88,6 +98,9 @@ export function MachineOnboardingFlow({
const identity = await getIdentity();
queryClient.setQueryData(["identity"], identity);
setSelectedPubkey(identity.pubkey);
setBackupDirection("forward");
setReturningFromSecurity(false);
setBackupSubview("created");
setPage("backup");
} catch (cause) {
setError(
@@ -110,6 +123,9 @@ export function MachineOnboardingFlow({
const identity = await persistCurrentIdentity();
queryClient.setQueryData(["identity"], identity);
setSelectedPubkey(identity.pubkey);
setBackupDirection("forward");
setReturningFromSecurity(false);
setBackupSubview("created");
setPage("backup");
} catch (cause) {
setError(
@@ -135,6 +151,8 @@ export function MachineOnboardingFlow({
return (
<div
className={`buzz-onboarding-neutral-theme buzz-startup-shell flex max-h-dvh items-start justify-center overflow-x-hidden overflow-y-auto px-4 text-foreground ${
isSecuritySubview ? "buzz-onboarding-security-theme" : ""
} ${
page === "identity"
? "buzz-onboarding-welcome py-8"
: "pb-28 pt-[106px]"
@@ -143,17 +161,26 @@ export function MachineOnboardingFlow({
>
<StartupWindowDragRegion />
{page === "identity" ? <LandingBees /> : null}
{page !== "identity" ? (
{isSecuritySubview ? (
<div className="fixed inset-x-0 top-8 z-20 flex justify-center px-6">
<Button
className="h-9 gap-2 rounded-full bg-foreground/10 px-5 text-foreground hover:bg-foreground/15 hover:text-foreground"
data-testid="backup-return-to-onboarding"
onClick={() => {
setBackupDirection("backward");
setReturningFromSecurity(true);
setBackupSubview("created");
}}
type="button"
variant="ghost"
>
<ArrowUp className="h-4 w-4" aria-hidden="true" />
Return to onboarding
</Button>
</div>
) : page !== "identity" ? (
<OnboardingChrome
current={
page === "config"
? 5
: page === "setup"
? 4
: page === "download"
? 3
: 2
}
current={page === "config" ? 4 : page === "setup" ? 3 : 2}
/>
) : null}
<OnboardingFooterProvider>
@@ -241,31 +268,47 @@ export function MachineOnboardingFlow({
</div>
</OnboardingSlideTransition>
) : page === "backup" ? (
<BackupStep
direction="forward"
onBack={() => setPage("identity")}
onDownload={() => setPage("download")}
/>
) : page === "download" ? (
<DownloadKeyStep
direction="forward"
onBack={() => setPage("backup")}
onNext={() => setPage("setup")}
session={backupSession}
/>
backupSubview === "password" ? (
<DownloadKeyStep
direction={backupDirection}
onNext={() => setPage("setup")}
session={backupSession}
/>
) : (
<BackupStep
direction={backupDirection}
onBack={() => setPage("identity")}
onNext={() => setPage("setup")}
onOpenPasswordBackup={() => {
setBackupDirection("forward");
setReturningFromSecurity(false);
setBackupSubview("password");
}}
onShowOptions={() => {
setBackupDirection("forward");
setReturningFromSecurity(false);
setBackupSubview("options");
}}
optionsExpanded={backupSubview === "options"}
returningFromSecurity={returningFromSecurity}
/>
)
) : page === "setup" ? (
<SetupStep
actions={{
// Fresh-key users return to the "Backup your key with a
// password" form (not the test flow they may have finished);
// imported keys skip that step entirely.
// Fresh-key users return to whichever identity backup subview
// they used to reach setup; imported keys skip backup entirely.
back: () => {
if (identityWasImported) {
setPage("key-import");
return;
}
backupSessionToPasswordEntry(backupSession);
setPage("download");
if (backupSubview === "password") {
backupSessionToPasswordEntry(backupSession);
}
setBackupDirection("backward");
setReturningFromSecurity(false);
setPage("backup");
},
next: (runtimeIds) => {
const ids = Array.from(runtimeIds);
@@ -1,12 +1,11 @@
import { BuzzMark } from "@/shared/ui/buzz-logo/BuzzMark";
/**
* Positions in the first-launch flow: landing, identity/key, key download,
* harness setup, default config, community choice, community profile, meet
* the team. Used as the default pagination length when a flow doesn't pass an
* explicit total.
* Positions in the first-launch flow: landing, identity/key, harness setup,
* default config, community choice, community profile, meet the team. Password
* backup is an optional subview of identity/key, not another position.
*/
export const TOTAL_ONBOARDING_PAGES = 8;
export const TOTAL_ONBOARDING_PAGES = 7;
/** Shared pill shape (38px tall) for every onboarding primary CTA. */
const ONBOARDING_CTA_SHAPE = "h-[2.375rem] rounded-full px-6";
@@ -14,6 +14,7 @@ export type OnboardingTransitionDirection = "forward" | "backward";
export type OnboardingTransitionEffect =
| "fade"
| "line-slide"
| "mask-reveal-down"
| "mask-reveal-up"
| "none";
@@ -241,6 +241,54 @@
--buzz-onboarding-avatar-dialog-shadow: 0 0 0;
}
/*
* Backup options and password backup intentionally leave the bright
* onboarding world for a dark security-focused subview. Keep these semantic
* overrides on a reusable class as well as the shell so portaled
* popovers/dialogs can opt into the same treatment.
*/
.buzz-onboarding-security-theme {
color-scheme: dark;
--buzz-onboarding-shell-bottom: #082b49;
--buzz-onboarding-cta-label: #f5f5f5;
--background: 222 45% 4%;
--foreground: 0 0% 96%;
--card: 220 14% 11%;
--card-foreground: 0 0% 96%;
--popover: 220 14% 9%;
--popover-foreground: 0 0% 96%;
--primary: 0 0% 16%;
--primary-foreground: 0 0% 96%;
--secondary: 0 0% 12%;
--secondary-foreground: 0 0% 96%;
--muted: 0 0% 12%;
--muted-foreground: 0 0% 72%;
--accent: 0 0% 16%;
--accent-foreground: 0 0% 96%;
--destructive: 0 84% 70%;
--destructive-foreground: 0 0% 4%;
--border: 0 0% 22%;
--input: 0 0% 22%;
--ring: 0 0% 84%;
}
.buzz-onboarding-neutral-theme.buzz-startup-shell.buzz-onboarding-security-theme {
background-color: #010103;
background-image:
radial-gradient(circle, rgb(143 211 255 / 0.11) 1px, transparent 1px),
radial-gradient(
ellipse at 50% 58%,
rgb(28 112 196 / 0.24) 0%,
rgb(18 76 138 / 0.1) 38%,
transparent 66%
),
linear-gradient(to bottom, #010103 0%, #040914 46%, #082b49 100%);
background-size:
24px 24px,
auto,
auto;
}
.buzz-onboarding-key-text {
@apply w-full break-all [overflow-wrap:anywhere] font-mono text-nsec-key;
@@ -376,10 +424,13 @@
animation-name: buzz-onboarding-line-slide-backward;
}
.buzz-onboarding-transition-line[data-onboarding-effect="mask-reveal-down"],
.buzz-onboarding-transition-line[data-onboarding-effect="mask-reveal-up"] {
overflow: visible;
}
.buzz-onboarding-transition-line[data-onboarding-effect="mask-reveal-down"]
> .buzz-onboarding-transition-content,
.buzz-onboarding-transition-line[data-onboarding-effect="mask-reveal-up"]
> .buzz-onboarding-transition-content {
/* `backwards` (not `both`): the reveal ends on an identity transform, so
@@ -387,12 +438,23 @@
that establishes a containing block and traps `position: fixed`
descendants (the bottom-docked onboarding footer). Reverting to no
transform at rest looks identical and frees fixed positioning. */
animation: buzz-onboarding-mask-reveal-up 760ms
cubic-bezier(0.22, 1, 0.36, 1) backwards;
animation-duration: 760ms;
animation-fill-mode: backwards;
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
animation-delay: var(--buzz-onboarding-transition-delay, 0ms);
transform-origin: 50% 70%;
}
.buzz-onboarding-transition-line[data-onboarding-effect="mask-reveal-down"]
> .buzz-onboarding-transition-content {
animation-name: buzz-onboarding-mask-reveal-down;
}
.buzz-onboarding-transition-line[data-onboarding-effect="mask-reveal-up"]
> .buzz-onboarding-transition-content {
animation-name: buzz-onboarding-mask-reveal-up;
}
.buzz-onboarding-name-placeholder-caret {
animation: buzz-onboarding-caret-blink 1.1s steps(1, end) infinite;
}
@@ -543,6 +605,25 @@
}
}
@keyframes buzz-onboarding-mask-reveal-down {
from {
filter: blur(8px);
opacity: 0;
transform: translate3d(0, -30px, 0) scale(0.985);
}
56% {
filter: blur(2px);
opacity: 0.86;
}
to {
filter: blur(0);
opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.buzz-onboarding-runtime-check,
.buzz-onboarding-runtime-checkmark {
+46 -40
View File
@@ -16,6 +16,20 @@ async function enterMachineBackup(page: import("@playwright/test").Page) {
await page.getByRole("button", { name: "Create a new identity key" }).click();
}
async function openBackupOptions(page: import("@playwright/test").Page) {
await expect(page.getByTestId("backup-intro-logo")).toHaveCount(0);
await page.getByTestId("backup-options-toggle").click();
await expect(
page.getByTestId("onboarding-page-backup-options"),
).toBeVisible();
}
async function openPasswordBackup(page: import("@playwright/test").Page) {
await openBackupOptions(page);
await page.getByTestId("backup-option-password").click();
await expect(page.getByTestId("onboarding-page-download")).toBeVisible();
}
async function invokedCommands(page: import("@playwright/test").Page) {
return page.evaluate(
() =>
@@ -55,11 +69,11 @@ test("backup step appears on fresh-key path after profile submit", async ({
});
// ---------------------------------------------------------------------------
// Chooser: masked key with reveal toggle and inline copy. The raw key is
// fetched only on explicit reveal/copy, and Next is never blocked.
// Key-created view: masked key with reveal toggle. Backup options open the
// dark security view; the raw key is fetched only on explicit reveal/copy.
// ---------------------------------------------------------------------------
test("chooser shows masked key; reveal and copy fetch it explicitly", async ({
test("key view reveals explicitly; options copy explicitly", async ({
page,
}) => {
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
@@ -86,18 +100,24 @@ test("chooser shows masked key; reveal and copy fetch it explicitly", async ({
await page.getByTestId("backup-key-reveal-toggle").click();
await expect(key).not.toContainText("nsec1");
// Inline copy goes straight to the clipboard.
// Copy is available only after opening the dark backup-options view.
await page.getByTestId("backup-options-toggle").click();
await expect(
page.getByTestId("onboarding-page-backup-options"),
).toBeVisible();
await expect(page.getByTestId("onboarding-next")).toHaveCount(0);
await page.getByTestId("backup-copy-key").click();
await expect
.poll(async () => invokedCommands(page))
.toContain("copy_text_to_clipboard");
expect(await invokedCommands(page)).toContain("get_nsec");
// Next leads into the download step, where backup stays skippable.
// Return restores the yellow key-created view; its Next skips backup and
// continues directly to setup.
await page.getByTestId("backup-return-to-onboarding").click();
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
await expect(page.getByTestId("onboarding-next")).toBeEnabled();
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("onboarding-page-download")).toBeVisible();
await page.getByTestId("onboarding-skip").click();
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
});
@@ -112,10 +132,9 @@ test("download happy path: generated password, encrypt, native save, Next", asyn
}) => {
await enterMachineBackup(page);
// The download flow is its own onboarding step behind the footer's Next.
await expect(page.getByTestId("backup-intro-logo")).toHaveCount(0);
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("onboarding-page-download")).toBeVisible();
// Password backup opens from the dark options state without adding an
// onboarding progress step.
await openPasswordBackup(page);
// The password field starts empty; the create button sits in the footer's
// primary slot and stays disabled until a valid password exists.
@@ -239,28 +258,27 @@ test("download happy path: generated password, encrypt, native save, Next", asyn
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
});
test("download step Back returns to the backup chooser", async ({ page }) => {
test("security view returns to the yellow onboarding view", async ({
page,
}) => {
await enterMachineBackup(page);
await openPasswordBackup(page);
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("onboarding-page-download")).toBeVisible();
await expect(page.getByTestId("backup-passphrase-input")).toBeVisible();
// The chooser's footer Next belongs to the previous step; the download
// step only mounts its own Next once the backup exists.
await expect(page.getByTestId("onboarding-step-dots")).toHaveCount(0);
await expect(page.getByTestId("onboarding-next")).toHaveCount(0);
await page.getByTestId("onboarding-back").click();
await page.getByTestId("backup-return-to-onboarding").click();
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
await expect(page.getByTestId("backup-key-value")).toBeVisible();
await expect(page.getByTestId("onboarding-next")).toBeVisible();
});
test("test-view Back returns to a secure saved-password placeholder", async ({
test("returning to onboarding preserves password-backup progress", async ({
page,
}) => {
await enterMachineBackup(page);
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("onboarding-page-download")).toBeVisible();
await openPasswordBackup(page);
const input = page.getByTestId("backup-passphrase-input");
await input.fill("mock-horse-battery-staple");
@@ -269,30 +287,20 @@ test("test-view Back returns to a secure saved-password placeholder", async ({
page.getByRole("heading", { name: "Now, test your backup" }),
).toBeVisible();
// Back retains only the encrypted blob and renders a fixed visual mask over
// an empty readonly input.
await page.getByTestId("onboarding-back").click();
await expect(
page.getByRole("heading", { name: "Backup your key with a password" }),
).toBeVisible();
await expect(input).toHaveValue("");
await expect(input).toHaveAttribute("readonly", "");
await expect(page.getByTestId("backup-saved-password-mask")).toBeVisible();
await page.getByTestId("backup-return-to-onboarding").click();
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
// Re-downloading needs no password or re-encryption.
await page.getByTestId("encrypted-backup-create").click();
// Re-entering the security flow restores the in-progress backup test.
await openPasswordBackup(page);
await expect(
page.getByRole("heading", { name: "Now, test your backup" }),
).toBeVisible();
await page.getByTestId("onboarding-back").click();
await page.getByTestId("onboarding-back").click();
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
await expect(page.getByTestId("backup-test-dropzone")).toBeVisible();
});
test("typed password requires 12 characters", async ({ page }) => {
await enterMachineBackup(page);
await page.getByTestId("onboarding-next").click();
await openPasswordBackup(page);
const create = page.getByTestId("encrypted-backup-create");
await expect(create).toBeDisabled(); // empty field
@@ -345,12 +353,10 @@ test("reveal shows inline error when get_nsec fails and Next still advances", as
await page.getByTestId("backup-key-reveal-toggle").click();
await expect(page.getByTestId("backup-copy-error")).toBeVisible();
// Keychain failure does not trap the user: Next still advances into the
// download step, and Skip there continues to setup.
// Keychain failure does not trap the user: Next still skips backup and
// advances directly to setup.
await expect(page.getByTestId("onboarding-next")).toBeEnabled();
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("onboarding-page-download")).toBeVisible();
await page.getByTestId("onboarding-skip").click();
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
});
@@ -68,8 +68,17 @@ test("machine onboarding: landing, backup, setup docked CTAs", async ({
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/02b-backup-revealed.png` });
// Next leads into the download step; Skip there continues to setup.
await page.getByTestId("onboarding-next").click();
// Backup options leave the yellow flow for the dark security view without
// adding a progress step or a generic Next action.
await page.getByTestId("backup-options-toggle").click();
await expect(
page.getByTestId("onboarding-page-backup-options"),
).toBeVisible();
await expect(page.getByTestId("onboarding-next")).toHaveCount(0);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/02c-backup-options.png` });
await page.getByTestId("backup-option-password").click();
await expect(page.getByTestId("onboarding-page-download")).toBeVisible();
await page.getByTestId("onboarding-skip").click();
await expect(