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