From 05d09373d1eea479b67e4dee4f59a1bf25fc972e Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 29 Jul 2026 00:12:53 -0700 Subject: [PATCH] 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 " 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 Co-authored-by: Taylor Ho --- .../features/onboarding/ui/BackupTestFlow.tsx | 85 ++++++++++++++----- .../onboarding/ui/DownloadKeyStep.tsx | 40 +++++++-- .../onboarding/ui/EncryptedBackupCreator.tsx | 4 + desktop/tests/e2e/onboarding-backup.spec.ts | 14 ++- 4 files changed, 109 insertions(+), 34 deletions(-) diff --git a/desktop/src/features/onboarding/ui/BackupTestFlow.tsx b/desktop/src/features/onboarding/ui/BackupTestFlow.tsx index 5b857de8a..3645312f3 100644 --- a/desktop/src/features/onboarding/ui/BackupTestFlow.tsx +++ b/desktop/src/features/onboarding/ui/BackupTestFlow.tsx @@ -113,6 +113,38 @@ export function BackupTestFlow({ const reduceMotion = useReducedMotion() ?? false; const [stage, setStage] = React.useState("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(null); const [fileError, setFileError] = React.useState(null); const [attempt, setAttempt] = React.useState(""); @@ -249,11 +281,16 @@ export function BackupTestFlow({ /> {fileError ? (

{savedPath ? ( diff --git a/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx b/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx index 25e18f348..2749bebe8 100644 --- a/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx +++ b/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx @@ -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(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. */}

- {hasCreated ? "Test your backup" : "Backup your key with a password"} + {hasCreated + ? "Now, test your backup" + : "Backup your key with a password"}

{hasCreated @@ -69,6 +74,7 @@ export function DownloadKeyStep({ createButtonClassName={ONBOARDING_PRIMARY_CTA_CLASS} createButtonPortal={createButtonSlot} onCreated={() => setHasCreated(true)} + onVerified={() => setHasVerified(true)} variant="spotlight" /> @@ -79,14 +85,30 @@ export function DownloadKeyStep({ {hasCreated ? ( - + /* Relative row keeps Next truly centered while Skip hangs off its + right edge without shifting the center. */ +

+ + {hasVerified ? null : ( + + )} +
) : ( /* Relative row keeps the Download CTA truly centered while Skip hangs off its right edge without shifting the center. */ diff --git a/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx b/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx index bcca3fb09..a892af326 100644 --- a/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx +++ b/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx @@ -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} diff --git a/desktop/tests/e2e/onboarding-backup.spec.ts b/desktop/tests/e2e/onboarding-backup.spec.ts index 6944a6cd2..dfaf9269c 100644 --- a/desktop/tests/e2e/onboarding-backup.spec.ts +++ b/desktop/tests/e2e/onboarding-backup.spec.ts @@ -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(); });