From fea87b8abc74a09df249cff422ff7bab417f4b95 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 28 Jul 2026 22:13:59 -0700 Subject: [PATCH] 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 Signed-off-by: Taylor Ho --- .../src/features/onboarding/ui/BackupStep.tsx | 274 ++++++++++++------ 1 file changed, 185 insertions(+), 89 deletions(-) diff --git a/desktop/src/features/onboarding/ui/BackupStep.tsx b/desktop/src/features/onboarding/ui/BackupStep.tsx index 54c8f45cd..54ce78626 100644 --- a/desktop/src/features/onboarding/ui/BackupStep.tsx +++ b/desktop/src/features/onboarding/ui/BackupStep.tsx @@ -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 ( + + ); +} /** * 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("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" ? (
- -
-
+ + +
+
+

+ {isRevealed && nsec ? nsec : maskedKey} +

+
+
+ + + + + + + Copy your key and save it somewhere safe — a password + manager is a great place for it. + + +
+
+ {copyError ? (

- {isRevealed && nsec ? nsec : maskedKey} + Could not retrieve your private key: {copyError}. You can + continue and find it later in Settings > Profile > + Identity.

-
-
- - - - - - - Copy your key and save it somewhere safe — a password - manager is a great place for it. - - -
-
- {copyError ? ( -

- Could not retrieve your private key: {copyError}. You can - continue and find it later in Settings > Profile > - Identity. -

- ) : null} -
+ ) : null} + +

@@ -304,21 +368,53 @@ export function BackupStep({ direction, onBack, onNext }: BackupStepProps) {

) : ( -
- -
-
-

Download your key

-

- 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. -

+
+
+ + setShellSettled(true)} + transition={ + reduceMotion ? { duration: 0 } : SHELL_MORPH_TRANSITION + } + /> + +
+ + +
+
+

Download your key

+

+ 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. +

+
+
- -
- - setView("choose")} /> + + { + setShellSettled(false); + setView("choose"); + }} + /> +
)}