feat(onboarding): promote encrypted key download to a "Backup your key" step

Split the backup step's in-place download view into its own page in the
machine onboarding flow, with its own progress dot, slide transition, and
footer. The chooser keeps the masked-key card; "Backup your key" (primary
CTA) now navigates forward instead of swapping views, with a delayed
fade-in ghost "Skip for now" beside it and a Settings hint below.

- DownloadKeyStep.tsx (new): "Backup your key" page — title + encryption
  explainer, shell box + closing brackets animating in on mount (replaces
  the card→box layoutId morph, which can't span two pages), textured card
  hosting EncryptedBackupCreator, and a footer whose primary slot holds
  the portaled "Encrypt and download" button until the payload exists,
  then a primary "Next" that continues to setup
- BackupStep.tsx: reduced to the chooser; removed view state, ShellBracket,
  BackToOptions, and the morph; new onDownload prop; footer reworked to a
  centered primary CTA with an absolutely-positioned "Skip for now" that
  fades in after 1s (animation-delay/duration arbitrary properties so the
  hover transition isn't delayed) and a Settings → Profile → Identity hint
- EncryptedBackupCreator.tsx: new opt-in createButtonPortal /
  createButtonClassName / onCreated props so onboarding can relocate the
  create button into its footer (undefined = inline for Settings; null =
  slot not mounted yet, render nothing to avoid an inline flash)
- MachineOnboardingFlow.tsx: new "download" page between backup and setup
  (Back → backup, Next → setup); progress indices shifted (download=3,
  setup=4, config=5)
- OnboardingChrome.tsx: TOTAL_ONBOARDING_PAGES 7 → 8; WelcomeSetup 5→6 and
  CommunityOnboardingFlow 6→7/7→8 dot positions bumped to match
- SetupStep.tsx: same footer treatment — centered Next with a no-background
  "Skip for now" hanging off its right edge, fading in after 1s
- tests: passThroughBackupStep helper now skips from the chooser;
  onboarding-backup.spec.ts covers the new page (create button absent from
  footer until encryption, Back returns to the chooser); 47 e2e + 3778
  unit tests pass

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
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 22:38:18 -07:00
parent fea87b8abc
commit 0d704d9904
10 changed files with 400 additions and 303 deletions
@@ -103,7 +103,7 @@ export function WelcomeSetup({
data-system-color-scheme={systemColorScheme}
>
<StartupWindowDragRegion />
<OnboardingChrome current={5} />
<OnboardingChrome current={6} />
<OnboardingFooterProvider>
<div className="relative flex min-h-0 w-full max-w-[920px] flex-1 flex-col items-center text-center">
{page === "welcome" ? (
+110 -242
View File
@@ -1,5 +1,4 @@
import { ArrowLeft, Check, Copy, Eye, EyeOff, Info } from "lucide-react";
import { motion, type Transition, useReducedMotion } from "motion/react";
import { Check, Copy, Eye, EyeOff, Info } from "lucide-react";
import * as React from "react";
import { getNsec } from "@/shared/api/tauriIdentity";
@@ -16,7 +15,6 @@ import {
type OnboardingTransitionDirection,
OnboardingSlideTransition,
} from "./OnboardingSlideTransition";
import { EncryptedBackupCreator } from "./EncryptedBackupCreator";
import { ONBOARDING_KEY_TEXT_CLASS } from "./NsecMaskedDisplay";
/**
@@ -36,87 +34,6 @@ let introPlayed = false;
const REVEAL_ANIMATION_CLASS =
"animate-in fade-in duration-700 motion-reduce:animate-none";
/**
* Shared-element id for the nsec card → shell box morph between the
* chooser and the download view. Same id on both sides lets Motion run the
* scale-down/translate-up FLIP with an automatic crossfade.
*/
const NSEC_SHELL_LAYOUT_ID = "nsec-shell-box";
/** Morph tween — same curve as the onboarding line-slide keyframes. */
const SHELL_MORPH_TRANSITION: Transition = {
type: "tween",
duration: 0.55,
ease: [0.22, 1, 0.36, 1],
};
/**
* One half of the "[ ]" pair that clamps around the shell box once it
* settles — the visual is the user working on the secure shell that
* surrounds their key. Brackets close inward as they fade in.
*/
function ShellBracket({
reduceMotion,
side,
visible,
}: {
reduceMotion: boolean;
side: "left" | "right";
visible: boolean;
}) {
return (
<motion.div
animate={
visible
? { opacity: 1, x: 0 }
: { opacity: 0, x: side === "left" ? -10 : 10 }
}
aria-hidden
className={cn(
// The bracket pair frames the h-16 (64px) box as a concentric 96px
// square: each line sits 10px clear of the box on every side (plus
// the 6px line itself), and the 2rem corner radius wraps the box's
// rounded-2xl (16px) corners at a constant 16px offset. The vertical
// line lives on the OUTER edge of the 32px-deep arm, so each bracket
// pulls 16px back over the box (negative margin) to keep that line
// 10px from the box edge.
"h-24 w-8 shrink-0 border-black",
side === "left"
? "-mr-4 rounded-l-[2rem] border-y-[6px] border-l-[6px]"
: "-ml-4 rounded-r-[2rem] border-y-[6px] border-r-[6px]",
)}
initial={false}
transition={
reduceMotion ? { duration: 0 } : { duration: 0.35, ease: "easeOut" }
}
/>
);
}
/**
* The chooser offers both backup methods: copying happens in place, while the
* encrypted download flow expands into its own view.
*/
type BackupView = "choose" | "download";
function BackToOptions({ onClick }: { onClick: () => void }) {
return (
<div className="mt-4 flex justify-center">
<Button
className="h-8 gap-1 text-sm text-muted-foreground hover:text-accent-foreground"
data-testid="backup-back-to-options"
onClick={onClick}
size="sm"
type="button"
variant="ghost"
>
<ArrowLeft className="h-3.5 w-3.5" aria-hidden="true" />
All backup options
</Button>
</div>
);
}
/** Saving a Keycase is recommended, never required to continue onboarding. */
export function backupNextDisabled(): boolean {
return false;
@@ -125,22 +42,25 @@ export function backupNextDisabled(): boolean {
type BackupStepProps = {
direction: OnboardingTransitionDirection;
onBack: () => void;
/** Advances to the dedicated "Download your key" onboarding step. */
onDownload: () => void;
onNext: () => void;
};
/**
* Onboarding backup step — offers two ways to back up the new key without
* blocking setup: a portable password-protected Keycase (recommended) or a
* direct clipboard copy destined for a password manager. The raw key is
* fetched only when the user explicitly clicks Copy, goes straight to the
* clipboard, and is never rendered or held in state.
* Onboarding backup step — shows the freshly created key and offers two ways
* to back it up without blocking setup: the encrypted download flow (its own
* onboarding step, via `onDownload`) or a direct clipboard copy destined for
* a password manager. The raw key is fetched only when the user explicitly
* clicks Copy or Reveal, and is never held in state before that.
*/
export function BackupStep({ direction, onBack, onNext }: BackupStepProps) {
export function BackupStep({
direction,
onBack,
onDownload,
onNext,
}: BackupStepProps) {
const [created, setCreated] = React.useState(introPlayed);
const [view, setView] = React.useState<BackupView>("choose");
// True once the card → shell-box morph has finished; gates the brackets.
const [shellSettled, setShellSettled] = React.useState(false);
const reduceMotion = useReducedMotion() ?? false;
const [copyState, setCopyState] = React.useState<
"idle" | "copying" | "copied"
>("idle");
@@ -274,175 +194,118 @@ export function BackupStep({ direction, onBack, onNext }: BackupStepProps) {
REVEAL_ANIMATION_CLASS,
)}
>
{view === "choose" ? (
<div className="w-full">
<motion.div
layoutId={NSEC_SHELL_LAYOUT_ID}
transition={
reduceMotion ? { duration: 0 } : SHELL_MORPH_TRANSITION
}
>
<Card className="px-8 py-6" variant="textured">
<div className="mx-auto flex w-full min-w-0 max-w-[832px] items-center gap-4">
<div className="min-w-0 flex-1">
<p
className={cn(
ONBOARDING_KEY_TEXT_CLASS,
isRevealed && nsec
? "select-text"
: "select-none blur-[4px]",
)}
data-testid="backup-key-value"
>
{isRevealed && nsec ? nsec : maskedKey}
</p>
</div>
<div className="flex shrink-0 gap-1.5">
<div className="w-full">
<Card className="px-8 py-6" variant="textured">
<div className="mx-auto flex w-full min-w-0 max-w-[832px] items-center gap-4">
<div className="min-w-0 flex-1">
<p
className={cn(
ONBOARDING_KEY_TEXT_CLASS,
isRevealed && nsec
? "select-text"
: "select-none blur-[4px]",
)}
data-testid="backup-key-value"
>
{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={
isRevealed ? "Hide private key" : "Reveal private key"
}
aria-label="Copy private key"
className="h-10 w-10 text-muted-foreground hover:text-foreground"
data-testid="backup-key-reveal-toggle"
onClick={() => void toggleReveal()}
data-testid="backup-copy-key"
disabled={copyState === "copying"}
onClick={() => void copyKeyToClipboard()}
size="icon"
type="button"
variant="ghost"
>
{isRevealed ? (
<EyeOff className="h-6 w-6" aria-hidden="true" />
{copyState === "copying" ? (
<Spinner className="h-5 w-5 border-2" />
) : copyState === "copied" ? (
<Check
className="h-6 w-6 text-primary"
aria-hidden="true"
/>
) : (
<Eye className="h-6 w-6" aria-hidden="true" />
<Copy 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>
</div>
{copyError ? (
<p
className="mt-3 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}
</Card>
</motion.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)]">
<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>
</div>
) : (
<div className="w-full">
<div className="mb-6 flex items-center justify-center">
<ShellBracket
reduceMotion={reduceMotion}
side="left"
visible={shellSettled || reduceMotion}
/>
<motion.div
className="h-16 w-16 rounded-2xl bg-white"
data-testid="backup-key-shell"
layoutId={NSEC_SHELL_LAYOUT_ID}
onLayoutAnimationComplete={() => setShellSettled(true)}
transition={
reduceMotion ? { duration: 0 } : SHELL_MORPH_TRANSITION
}
/>
<ShellBracket
reduceMotion={reduceMotion}
side="right"
visible={shellSettled || reduceMotion}
/>
</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>
</div>
<motion.div
animate={{ opacity: 1, y: 0 }}
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">
<div className="mx-auto w-full max-w-[832px]">
<div className="mb-5 space-y-2 text-center">
<h2 className="text-lg font-medium">Download your key</h2>
<p className="text-xs leading-5 text-muted-foreground">
Your key is encrypted with your password before it
downloads. Keep the file private — you need both it and
the password to restore your identity.
</p>
</div>
<EncryptedBackupCreator variant="spotlight" />
</div>
</Card>
<BackToOptions
onClick={() => {
setShellSettled(false);
setView("choose");
}}
/>
</motion.div>
</div>
)}
{copyError ? (
<p
className="mt-3 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}
</Card>
<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>
</div>
</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>
{view === "choose" ? (
{/* Relative row keeps the primary CTA truly centered while Skip
hangs off its right edge without shifting the center. */}
<div className="relative flex items-center justify-center">
<Button
className="h-9 rounded-full bg-foreground/10 px-6 hover:bg-foreground/15"
className={ONBOARDING_PRIMARY_CTA_CLASS}
data-testid="backup-option-download"
onClick={() => setView("download")}
onClick={onDownload}
type="button"
>
Backup your key
</Button>
<Button
className="absolute left-full ml-3 h-9 animate-in whitespace-nowrap rounded-full px-6 fade-in fill-mode-backwards [animation-delay:1000ms] animation-duration-[500ms] hover:bg-foreground/10 motion-reduce:animate-none"
data-testid="onboarding-next"
disabled={backupNextDisabled()}
onClick={onNext}
type="button"
variant="ghost"
>
Download my key
Skip for now
</Button>
) : null}
</div>
<Button
className="h-9 rounded-full bg-foreground/10 px-6 hover:bg-foreground/15"
@@ -453,6 +316,11 @@ export function BackupStep({ direction, onBack, onNext }: BackupStepProps) {
>
Back
</Button>
<p className="text-xs text-foreground/50">
You can back up your key anytime in Settings &rarr; Profile &rarr;
Identity.
</p>
</OnboardingFooter>
) : null}
</OnboardingSlideTransition>
@@ -448,7 +448,7 @@ export function CommunityOnboardingFlow({
>
<StartupWindowDragRegion />
{isProfileStage || isTeamStage ? (
<OnboardingChrome current={isTeamStage ? 7 : 6} />
<OnboardingChrome current={isTeamStage ? 8 : 7} />
) : null}
<OnboardingFooterProvider>
<div
@@ -0,0 +1,185 @@
import { motion, useReducedMotion } from "motion/react";
import * as React from "react";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Card } from "@/shared/ui/card";
import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome";
import { OnboardingFooter } from "./OnboardingFooter";
import {
type OnboardingTransitionDirection,
OnboardingSlideTransition,
} from "./OnboardingSlideTransition";
import { EncryptedBackupCreator } from "./EncryptedBackupCreator";
/**
* One half of the "[ ]" pair that clamps around the shell box once it
* settles — the visual is the user working on the secure shell that
* surrounds their key. Brackets close inward as they fade in.
*/
function ShellBracket({
reduceMotion,
side,
visible,
}: {
reduceMotion: boolean;
side: "left" | "right";
visible: boolean;
}) {
return (
<motion.div
animate={
visible
? { opacity: 1, x: 0 }
: { opacity: 0, x: side === "left" ? -10 : 10 }
}
aria-hidden
className={cn(
// The bracket pair frames the h-16 (64px) box as a concentric 96px
// square: each line sits 10px clear of the box on every side (plus
// the 6px line itself), and the 2rem corner radius wraps the box's
// rounded-2xl (16px) corners at a constant 16px offset. The vertical
// line lives on the OUTER edge of the 32px-deep arm, so each bracket
// pulls 16px back over the box (negative margin) to keep that line
// 10px from the box edge.
"h-24 w-8 shrink-0 border-black",
side === "left"
? "-mr-4 rounded-l-[2rem] border-y-[6px] border-l-[6px]"
: "-ml-4 rounded-r-[2rem] border-y-[6px] border-r-[6px]",
)}
initial={false}
transition={
reduceMotion ? { duration: 0 } : { duration: 0.35, ease: "easeOut" }
}
/>
);
}
type DownloadKeyStepProps = {
direction: OnboardingTransitionDirection;
onBack: () => void;
onNext: () => void;
};
/**
* Onboarding download step — the password-first encrypted key download
* (Keycase) flow, promoted to its own page in the machine onboarding flow.
* 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,
onBack,
onNext,
}: DownloadKeyStepProps) {
const reduceMotion = useReducedMotion() ?? false;
// True once the shell box has landed; gates the closing brackets.
const [shellSettled, setShellSettled] = React.useState(false);
// True once the encrypted payload exists — the create button (living in the
// footer's primary slot) disappears with the form, so Next takes its place.
const [hasCreated, setHasCreated] = React.useState(false);
// Footer slot the creator portals its "Encrypt and download" button into.
const [createButtonSlot, setCreateButtonSlot] =
React.useState<HTMLElement | null>(null);
return (
<OnboardingSlideTransition
className="flex min-h-0 w-full flex-col items-center"
data-testid="onboarding-page-download"
direction={direction}
transitionKey={`download-${direction}`}
>
<div className="flex w-full max-w-[500px] shrink-0 flex-col text-center">
{/* 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">
Backup your key
</h1>
<p className="mt-5 text-sm leading-6 text-foreground/80">
Your key is encrypted with your password before it downloads. Keep the
file private — you need both it and the password to restore your
identity.
</p>
</div>
<div className="flex w-full max-w-[1040px] flex-1 flex-col justify-center py-10">
<div className="w-full">
<div className="mb-6 flex items-center justify-center">
<ShellBracket
reduceMotion={reduceMotion}
side="left"
visible={shellSettled || reduceMotion}
/>
<motion.div
animate={{ opacity: 1, scale: 1 }}
className="h-16 w-16 rounded-2xl bg-white"
data-testid="backup-key-shell"
initial={reduceMotion ? false : { opacity: 0, scale: 0.8 }}
onAnimationComplete={() => setShellSettled(true)}
transition={
reduceMotion
? { duration: 0 }
: { duration: 0.45, ease: [0.22, 1, 0.36, 1] }
}
/>
<ShellBracket
reduceMotion={reduceMotion}
side="right"
visible={shellSettled || reduceMotion}
/>
</div>
<motion.div
animate={{ opacity: 1, y: 0 }}
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">
<div className="mx-auto w-full max-w-[832px]">
<EncryptedBackupCreator
createButtonClassName={ONBOARDING_PRIMARY_CTA_CLASS}
createButtonPortal={createButtonSlot}
onCreated={() => setHasCreated(true)}
variant="spotlight"
/>
</div>
</Card>
</motion.div>
</div>
</div>
<OnboardingFooter>
{hasCreated ? (
<Button
className={ONBOARDING_PRIMARY_CTA_CLASS}
data-testid="onboarding-next"
onClick={onNext}
type="button"
>
Next
</Button>
) : (
<div
className="flex justify-center"
data-testid="onboarding-create-slot"
ref={setCreateButtonSlot}
/>
)}
<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>
<p className="text-xs text-foreground/50">
You can back up your key anytime in Settings &rarr; Profile &rarr;
Identity.
</p>
</OnboardingFooter>
</OnboardingSlideTransition>
);
}
@@ -1,5 +1,6 @@
import { AlertTriangle, RefreshCw } from "lucide-react";
import * as React from "react";
import { createPortal } from "react-dom";
import {
createNcryptsecBackup,
@@ -23,6 +24,15 @@ import { NsecMaskedDisplay } from "./NsecMaskedDisplay";
type EncryptedBackupCreatorProps = {
/** "spotlight" is the onboarding treatment; "boxed" fits settings cards. */
variant?: "spotlight" | "boxed";
/**
* When set, the "Encrypt and download" button is portaled into this element
* (e.g. the onboarding footer's primary slot) instead of rendering inline.
*/
createButtonPortal?: HTMLElement | null;
/** Extra classes for the "Encrypt and download" button. */
createButtonClassName?: string;
/** Fired once the encrypted payload has been created (before saving). */
onCreated?: () => void;
/** Fired only after the encrypted key file has been saved successfully. */
onSaved?: (path: string) => void;
};
@@ -35,6 +45,9 @@ type EncryptedBackupCreatorProps = {
*/
export function EncryptedBackupCreator({
variant = "spotlight",
createButtonPortal,
createButtonClassName,
onCreated,
onSaved,
}: EncryptedBackupCreatorProps) {
const [state, dispatch] = React.useReducer(
@@ -79,6 +92,7 @@ export function EncryptedBackupCreator({
const ncryptsec = await createNcryptsecBackup(passphrase);
if (!mountedRef.current) return;
dispatch({ type: "create-succeeded", ncryptsec });
onCreated?.();
setIsSaving(true);
setSaveError(null);
try {
@@ -103,7 +117,7 @@ export function EncryptedBackupCreator({
err instanceof Error ? err.message : "Failed to encrypt your key.",
});
}
}, [onSaved, state]);
}, [onCreated, onSaved, state]);
const handleSaveCopy = React.useCallback(async () => {
if (!state.ncryptsec || isSaving) return;
@@ -300,24 +314,33 @@ export function EncryptedBackupCreator({
</p>
) : null}
<div className="flex justify-center">
<Button
className="h-9 rounded-full px-6"
data-testid="encrypted-backup-create"
disabled={createDisabled(state)}
onClick={() => void handleCreate()}
type="button"
>
{state.isCreating ? (
<>
<Spinner className="h-4 w-4 border-2" />
Encrypting… this takes a couple of seconds
</>
) : (
"Encrypt and download"
)}
</Button>
</div>
{(() => {
const createButton = (
<Button
className={cn("h-9 rounded-full px-6", createButtonClassName)}
data-testid="encrypted-backup-create"
disabled={createDisabled(state)}
onClick={() => void handleCreate()}
type="button"
>
{state.isCreating ? (
<>
<Spinner className="h-4 w-4 border-2" />
Encrypting… this takes a couple of seconds
</>
) : (
"Encrypt and download"
)}
</Button>
);
// `undefined` = inline (settings); `null` = slot not mounted yet
// (skip a frame rather than flashing the button inline).
if (createButtonPortal === undefined)
return <div className="flex justify-center">{createButton}</div>;
return createButtonPortal
? createPortal(createButton, createButtonPortal)
: null;
})()}
</div>
);
}
@@ -10,6 +10,7 @@ import { Button } from "@/shared/ui/button";
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
import { BackupStep } from "./BackupStep";
import { DefaultConfigStep } from "./DefaultConfigStep";
import { DownloadKeyStep } from "./DownloadKeyStep";
import { IdentityKeyHelpDialog } from "./IdentityKeyHelpDialog";
import { LandingBees } from "./LandingBees";
import { NostrKeyImportForm } from "./NostrKeyImportForm";
@@ -25,6 +26,7 @@ export type MachineOnboardingPage =
| "identity"
| "key-import"
| "backup"
| "download"
| "setup"
| "config";
@@ -136,7 +138,15 @@ export function MachineOnboardingFlow({
{page === "identity" ? <LandingBees /> : null}
{page !== "identity" ? (
<OnboardingChrome
current={page === "config" ? 4 : page === "setup" ? 3 : 2}
current={
page === "config"
? 5
: page === "setup"
? 4
: page === "download"
? 3
: 2
}
/>
) : null}
<OnboardingFooterProvider>
@@ -227,6 +237,13 @@ export function MachineOnboardingFlow({
<BackupStep
direction="forward"
onBack={() => setPage("identity")}
onDownload={() => setPage("download")}
onNext={() => setPage("setup")}
/>
) : page === "download" ? (
<DownloadKeyStep
direction="forward"
onBack={() => setPage("backup")}
onNext={() => setPage("setup")}
/>
) : page === "setup" ? (
@@ -1,11 +1,12 @@
import { BuzzMark } from "@/shared/ui/buzz-logo/BuzzMark";
/**
* Positions in the first-launch flow: landing, identity/key, 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, 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.
*/
export const TOTAL_ONBOARDING_PAGES = 7;
export const TOTAL_ONBOARDING_PAGES = 8;
/** Shared pill shape (38px tall) for every onboarding primary CTA. */
const ONBOARDING_CTA_SHAPE = "h-[2.375rem] rounded-full px-6";
@@ -685,25 +685,28 @@ function SetupStepContent({
/>
<OnboardingFooter>
<Button
className={`${ONBOARDING_PRIMARY_CTA_CLASS} text-sm`}
data-testid="onboarding-setup-next"
disabled={readyRuntimeIds.length === 0}
onClick={() => actions.next(readyRuntimeIds)}
type="button"
>
Next
</Button>
<Button
className="h-9 rounded-full bg-foreground/10 px-6 text-sm hover:bg-foreground/15"
data-testid="onboarding-setup-skip"
onClick={() => actions.next([])}
type="button"
variant="ghost"
>
Skip for now
</Button>
{/* Relative row keeps the primary CTA truly centered while Skip
hangs off its right edge without shifting the center. */}
<div className="relative flex items-center justify-center">
<Button
className={`${ONBOARDING_PRIMARY_CTA_CLASS} text-sm`}
data-testid="onboarding-setup-next"
disabled={readyRuntimeIds.length === 0}
onClick={() => actions.next(readyRuntimeIds)}
type="button"
>
Next
</Button>
<Button
className="absolute left-full ml-3 h-9 animate-in whitespace-nowrap rounded-full px-6 text-sm fade-in fill-mode-backwards [animation-delay:1000ms] animation-duration-[500ms] hover:bg-foreground/10 motion-reduce:animate-none"
data-testid="onboarding-setup-skip"
onClick={() => actions.next([])}
type="button"
variant="ghost"
>
Skip for now
</Button>
</div>
<Button
className="h-9 rounded-full bg-foreground/10 px-6 text-sm hover:bg-foreground/15"
+13 -10
View File
@@ -89,8 +89,9 @@ test("chooser shows masked key; reveal and copy fetch it explicitly", async ({
});
// ---------------------------------------------------------------------------
// Encrypted download path: password → encrypt locally → native save → saved
// confirmation. The raw key must never be fetched on this path.
// Encrypted download path ("Backup your key" step): password → encrypt
// locally → native save → saved confirmation. The raw key must never be
// fetched on this path.
// ---------------------------------------------------------------------------
test("download happy path: generated password, encrypt, native save, Next", async ({
@@ -98,13 +99,15 @@ test("download happy path: generated password, encrypt, native save, Next", asyn
}) => {
await enterMachineBackup(page);
// The download flow sits behind the footer CTA.
// The download flow is its own onboarding step behind the footer CTA.
await expect(page.getByTestId("backup-intro-logo")).toHaveCount(0);
await page.getByTestId("backup-option-download").click();
await expect(page.getByTestId("onboarding-page-download")).toBeVisible();
// Default mode: generated password shown, but backup remains optional.
// Default mode: generated password shown; the create button sits in the
// footer's primary slot until the encrypted payload exists.
await expect(page.getByTestId("backup-passphrase-generated")).toBeVisible();
await expect(page.getByTestId("onboarding-next")).toBeEnabled();
await expect(page.getByTestId("onboarding-next")).toHaveCount(0);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/03-backup-download-passphrase.png` });
@@ -135,17 +138,17 @@ test("download happy path: generated password, encrypt, native save, Next", asyn
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
});
test("download view returns to the chooser via All backup options", async ({
page,
}) => {
test("download step Back returns to the backup chooser", async ({ page }) => {
await enterMachineBackup(page);
await page.getByTestId("backup-option-download").click();
await expect(page.getByTestId("onboarding-page-download")).toBeVisible();
await expect(page.getByTestId("backup-passphrase-generated")).toBeVisible();
// The footer download CTA hides while the flow is open.
// The chooser's footer CTA belongs to the previous step.
await expect(page.getByTestId("backup-option-download")).toHaveCount(0);
await page.getByTestId("backup-back-to-options").click();
await page.getByTestId("onboarding-back").click();
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
await expect(page.getByTestId("backup-key-value")).toBeVisible();
await expect(page.getByTestId("backup-option-download")).toBeVisible();
});
+3 -6
View File
@@ -16,13 +16,10 @@ export async function seedActiveIdentity(
);
}
/** Navigate through the backup step (fresh-key path, encrypted default). */
/** Navigate through the backup step (fresh-key path). */
export async function passThroughBackupStep(page: Page) {
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
// Encrypted-by-default: create the backup with the generated passphrase,
// then advance. (The raw key path is behind "Show raw key instead".)
await expect(page.getByTestId("backup-passphrase-generated")).toBeVisible();
await page.getByTestId("encrypted-backup-create").click();
await expect(page.getByTestId("ncryptsec-value")).toBeVisible();
// Backing up is recommended, never required — "Skip for now" advances
// straight to setup without visiting the "Backup your key" step.
await page.getByTestId("onboarding-next").click();
}