feat(onboarding): morph nsec card into bracketed shell box on download

Add a shared-element transition in the backup step: clicking "Download my
key" scales the textured nsec card down and translates it up into a compact
white square "shell box", framing the metaphor that the user is working on
the secure shell that surrounds their key while the encrypted download view
loads in below.

- BackupStep.tsx: wrap the chooser's nsec Card and the download view's shell
  box in motion.div elements sharing a layoutId, so Motion runs the FLIP
  morph (550ms, same cubic-bezier as the onboarding line-slide) with an
  automatic crossfade; going back via "All backup options" reverses the morph
- Add ShellBracket: black square brackets (6px lines, 2rem outer radius)
  that fade in and clamp inward around the box once the morph settles
  (onLayoutAnimationComplete gates via new shellSettled state); geometry
  keeps the lines 10px clear of the box on all sides, concentric with its
  rounded-2xl corners, forming a 96px square around the 64px box
- Fade/rise the "Download your key" card in concurrently with the morph,
  replacing the old VIEW_ANIMATION_CLASS crossfade
- Respect prefers-reduced-motion via useReducedMotion: morph and bracket
  entrance render their final state instantly
- No e2e changes needed: all existing test ids are preserved and the
  onboarding-backup.spec.ts suite passes unmodified (8/8)

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:13:59 -07:00
parent 8487e9fa5d
commit fea87b8abc
+185 -89
View File
@@ -1,4 +1,5 @@
import { ArrowLeft, Check, Copy, Eye, EyeOff, Info } from "lucide-react";
import { motion, type Transition, useReducedMotion } from "motion/react";
import * as React from "react";
import { getNsec } from "@/shared/api/tauriIdentity";
@@ -35,9 +36,62 @@ let introPlayed = false;
const REVEAL_ANIMATION_CLASS =
"animate-in fade-in duration-700 motion-reduce:animate-none";
/** Quicker fade for switching between the chooser and a backup method. */
const VIEW_ANIMATION_CLASS =
"animate-in fade-in duration-300 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
@@ -84,6 +138,9 @@ type BackupStepProps = {
export function BackupStep({ direction, onBack, 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");
@@ -219,81 +276,88 @@ export function BackupStep({ direction, onBack, onNext }: BackupStepProps) {
>
{view === "choose" ? (
<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">
<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">
<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>
</div>
{copyError ? (
<p
className={cn(
ONBOARDING_KEY_TEXT_CLASS,
isRevealed && nsec
? "select-text"
: "select-none blur-[4px]",
)}
data-testid="backup-key-value"
className="mt-3 text-center text-sm text-destructive"
data-testid="backup-copy-error"
>
{isRevealed && nsec ? nsec : maskedKey}
Could not retrieve your private key: {copyError}. You can
continue and find it later in Settings &gt; Profile &gt;
Identity.
</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>
</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>
) : 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" />
@@ -304,21 +368,53 @@ export function BackupStep({ direction, onBack, onNext }: BackupStepProps) {
</p>
</div>
) : (
<div className={VIEW_ANIMATION_CLASS}>
<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 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}
/>
</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>
<EncryptedBackupCreator variant="spotlight" />
</div>
</Card>
<BackToOptions onClick={() => setView("choose")} />
</Card>
<BackToOptions
onClick={() => {
setShellSettled(false);
setView("choose");
}}
/>
</motion.div>
</div>
)}
</div>