feat(onboarding): gate Next on backup test with select-button dropzone

- BackupTestFlow.tsx: replace the always-on dashed dropzone with an
  oversized onboarding-style primary pill ("Select your backup file",
  h-14, rounded-full, bg-primary with the onboarding CTA label color)
  that opens the file browser; window-level dragenter/dragleave
  listeners (depth-counted, filtered to file drags) morph it back into
  the dashed drop target only while a file is dragged over the window
- BackupTestFlow.tsx: restyle "Re-download backup" as the flow's ghost
  pill secondary (h-12, rounded-full, bg-foreground/10) and move the
  "Saved to <path>" label below the button in a centered column
- EncryptedBackupCreator.tsx: add onVerified prop and forward it to
  BackupTestFlow so parents can observe a passed backup test
- DownloadKeyStep.tsx: track hasVerified; after the backup is created,
  Next renders disabled until the test passes while a ghost "Skip for
  now" button hangs off its right edge as the escape hatch; retitle the
  post-download page to "Now, test your backup"
- onboarding-backup.spec.ts: assert the new heading, the disabled
  Next + visible Skip state after download, and that a passed test
  enables Next and removes Skip (all 8 tests passing)

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-07-29 00:12:53 -07:00
parent 6dca931df6
commit 05d09373d1
4 changed files with 109 additions and 34 deletions
@@ -113,6 +113,38 @@ export function BackupTestFlow({
const reduceMotion = useReducedMotion() ?? false;
const [stage, setStage] = React.useState<BackupTestStage>("drop");
const [isDragActive, setIsDragActive] = React.useState(false);
// True while a file drag is anywhere over the window — the select button
// renders as a dropzone only for the duration of the drag.
const [isWindowDragging, setIsWindowDragging] = React.useState(false);
const dragDepthRef = React.useRef(0);
React.useEffect(() => {
// dragenter/dragleave fire per nested element, so track depth to know
// when the drag has actually left the window.
const handleDragEnter = (event: DragEvent) => {
if (!event.dataTransfer?.types.includes("Files")) return;
dragDepthRef.current += 1;
setIsWindowDragging(true);
};
const handleDragLeave = () => {
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
if (dragDepthRef.current === 0) setIsWindowDragging(false);
};
const handleDragEnd = () => {
dragDepthRef.current = 0;
setIsWindowDragging(false);
};
window.addEventListener("dragenter", handleDragEnter);
window.addEventListener("dragleave", handleDragLeave);
window.addEventListener("drop", handleDragEnd);
window.addEventListener("dragend", handleDragEnd);
return () => {
window.removeEventListener("dragenter", handleDragEnter);
window.removeEventListener("dragleave", handleDragLeave);
window.removeEventListener("drop", handleDragEnd);
window.removeEventListener("dragend", handleDragEnd);
};
}, []);
const [fileName, setFileName] = React.useState<string | null>(null);
const [fileError, setFileError] = React.useState<string | null>(null);
const [attempt, setAttempt] = React.useState("");
@@ -249,11 +281,16 @@ export function BackupTestFlow({
/>
<button
className={cn(
"flex w-full flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed px-6 text-center transition-colors",
isSpotlight ? "h-44" : "h-32",
isDragActive
? "border-primary bg-primary/10"
: "border-foreground/25 bg-background/40 hover:border-foreground/40 hover:bg-background/70",
"flex flex-col items-center justify-center gap-2 text-center transition-all",
isWindowDragging
? cn(
"w-full rounded-2xl border-2 border-dashed px-6",
isSpotlight ? "h-44" : "h-32",
isDragActive
? "border-primary bg-primary/10"
: "border-foreground/25 bg-background/40",
)
: "mx-auto h-14 rounded-full bg-primary px-12 text-(--buzz-onboarding-cta-label) shadow hover:bg-primary/90",
)}
data-testid="backup-test-dropzone"
onClick={() => fileInputRef.current?.click()}
@@ -270,19 +307,24 @@ export function BackupTestFlow({
}}
type="button"
>
<FileUp
aria-hidden="true"
className={cn(
"h-8 w-8 transition-colors",
isDragActive ? "text-primary" : "text-muted-foreground",
)}
/>
<span className="text-sm font-medium text-foreground">
Drop your backup file here
</span>
<span className="text-xs text-muted-foreground">
or click to browse for it
</span>
{isWindowDragging ? (
<>
<FileUp
aria-hidden="true"
className={cn(
"h-8 w-8 transition-colors",
isDragActive ? "text-primary" : "text-muted-foreground",
)}
/>
<span className="text-sm font-medium text-foreground">
Drop your backup file here
</span>
</>
) : (
<span className="text-base font-medium">
Select your backup file
</span>
)}
</button>
{fileError ? (
<p
@@ -294,15 +336,14 @@ export function BackupTestFlow({
) : null}
<div className="flex flex-col items-center gap-2">
<Button
className="h-8 gap-1.5 text-sm"
className="h-12 gap-1.5 rounded-full bg-foreground/10 px-10 text-base hover:bg-foreground/15"
data-testid="encrypted-backup-save-copy"
disabled={isSaving}
onClick={onSaveCopy}
size="sm"
type="button"
variant="outline"
variant="ghost"
>
{isSaving ? <Spinner className="h-3.5 w-3.5 border-2" /> : null}
{isSaving ? <Spinner className="h-4 w-4 border-2" /> : null}
Re-download backup
</Button>
{savedPath ? (
@@ -32,6 +32,9 @@ export function DownloadKeyStep({
// 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);
// True once the user has passed the backup test — until then Next stays
// disabled and "Skip for now" remains the escape hatch.
const [hasVerified, setHasVerified] = React.useState(false);
// Footer slot the creator portals its "Download" button into.
const [createButtonSlot, setCreateButtonSlot] =
React.useState<HTMLElement | null>(null);
@@ -47,7 +50,9 @@ export function DownloadKeyStep({
{/* 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">
{hasCreated ? "Test your backup" : "Backup your key with a password"}
{hasCreated
? "Now, test your backup"
: "Backup your key with a password"}
</h1>
<p className="mt-5 text-sm leading-6 text-foreground/80">
{hasCreated
@@ -69,6 +74,7 @@ export function DownloadKeyStep({
createButtonClassName={ONBOARDING_PRIMARY_CTA_CLASS}
createButtonPortal={createButtonSlot}
onCreated={() => setHasCreated(true)}
onVerified={() => setHasVerified(true)}
variant="spotlight"
/>
</div>
@@ -79,14 +85,30 @@ export function DownloadKeyStep({
<OnboardingFooter>
{hasCreated ? (
<Button
className={ONBOARDING_PRIMARY_CTA_CLASS}
data-testid="onboarding-next"
onClick={onNext}
type="button"
>
Next
</Button>
/* Relative row keeps Next 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}
data-testid="onboarding-next"
disabled={!hasVerified}
onClick={onNext}
type="button"
>
Next
</Button>
{hasVerified ? null : (
<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-skip"
onClick={onNext}
type="button"
variant="ghost"
>
Skip for now
</Button>
)}
</div>
) : (
/* Relative row keeps the Download CTA truly centered while Skip
hangs off its right edge without shifting the center. */
@@ -144,6 +144,8 @@ type EncryptedBackupCreatorProps = {
onCreated?: () => void;
/** Fired only after the encrypted key file has been saved successfully. */
onSaved?: (path: string) => void;
/** Fired once when the user completes the backup test successfully. */
onVerified?: () => void;
};
/**
@@ -324,6 +326,7 @@ export function EncryptedBackupCreator({
createButtonClassName,
onCreated,
onSaved,
onVerified,
}: EncryptedBackupCreatorProps) {
const [state, dispatch] = React.useReducer(
encryptedBackupReducer,
@@ -449,6 +452,7 @@ export function EncryptedBackupCreator({
isSaving={isSaving}
ncryptsec={state.ncryptsec}
onSaveCopy={() => void handleSaveCopy()}
onVerified={onVerified}
passphrase={state.passphrase}
saveError={saveError}
savedPath={savedPath}
+11 -3
View File
@@ -148,10 +148,11 @@ test("download happy path: generated password, encrypt, native save, Next", asyn
await page.getByTestId("encrypted-backup-create").click();
// Download commits the blob and hands over to the "Test your backup"
// flow: a dropzone for the saved file, then the password to unlock it.
// Download commits the blob and hands over to the "Now, test your backup"
// flow: a select-file button (dropzone while dragging) for the saved file,
// then the password to unlock it.
await expect(
page.getByRole("heading", { name: "Test your backup" }),
page.getByRole("heading", { name: "Now, test your backup" }),
).toBeVisible();
const dropzone = page.getByTestId("backup-test-dropzone");
await expect(dropzone).toBeVisible();
@@ -159,6 +160,11 @@ test("download happy path: generated password, encrypt, native save, Next", asyn
"identity.ncryptsec",
);
// Until the test passes, Next stays disabled and Skip remains the escape
// hatch.
await expect(page.getByTestId("onboarding-next")).toBeDisabled();
await expect(page.getByTestId("onboarding-skip")).toBeVisible();
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/04-backup-test-dropzone.png` });
@@ -203,7 +209,9 @@ test("download happy path: generated password, encrypt, native save, Next", asyn
expect(commands).not.toContain("get_nsec");
expect(commands).toContain("create_ncryptsec_backup");
// A passed test unlocks Next and retires the Skip escape hatch.
await expect(page.getByTestId("onboarding-next")).toBeEnabled();
await expect(page.getByTestId("onboarding-skip")).toHaveCount(0);
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
});