From 9960d2529cac4b9a797cde0b7c41fb12550a256a Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 28 Jul 2026 23:34:36 -0700 Subject: [PATCH] feat(onboarding): ticker label and password lockdown for queued key download Polish the queued-download state of the encrypted key backup flow (Download clicked while the background KDF is still running): - EncryptedBackupCreator.tsx: the disabled Download button now shows a vertical ticker instead of a static label, cycling "Downloading once finished" / "Encrypting your password" / "Just a bit longer..." every 2.5s. The stacked column slides up inside a one-line clipped viewport (300ms ease-out, motion-reduce safe) and ends with a clone of the first message so the loop wraps from the same bottom origin, then snaps back to the real first row with the transition disabled once the clone settles. All lines render at all times, so the button holds the width of the longest message, and the viewport is aria-live=polite for screen readers. - While the download is queued, the password field flips back to masked and the reveal (eye) toggle disables, so the passphrase isn't left readable on screen while the user waits for the native save dialog (input and generator popover were already locked in this state). Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../onboarding/ui/EncryptedBackupCreator.tsx | 96 ++++++++++++++++++- 1 file changed, 95 insertions(+), 1 deletion(-) diff --git a/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx b/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx index d2d24df7d..13bc42908 100644 --- a/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx +++ b/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx @@ -43,6 +43,93 @@ const DEFAULT_SEPARATOR = SEPARATOR_OPTIONS[0].value; */ const ENCRYPT_DEBOUNCE_MS = 400; +const PENDING_TICKER_MESSAGES = [ + "Downloading once finished", + "Encrypting your password", + "Just a bit longer...", +] as const; + +/** How long each ticker message holds before sliding to the next. */ +const PENDING_TICKER_INTERVAL_MS = 2500; + +/** Matches the `duration-300` slide transition on the ticker column. */ +const PENDING_TICKER_SLIDE_MS = 300; + +/** + * Vertical ticker for the queued-download button label — cycles through the + * pending messages by sliding a stacked column inside a one-line viewport. + * The column ends with a clone of the first message, so the wrap-around + * slides up from the bottom like every other step; once the clone settles, + * the column snaps (transition disabled) back to the real first row. All + * lines render at all times, so the button keeps the width of the longest + * message instead of resizing on each swap. + */ +function PendingDownloadTicker() { + // Index into the rendered column (messages + trailing clone of the first). + const [position, setPosition] = React.useState(0); + const [snap, setSnap] = React.useState(false); + + React.useEffect(() => { + const timer = window.setInterval( + () => setPosition((current) => current + 1), + PENDING_TICKER_INTERVAL_MS, + ); + return () => window.clearInterval(timer); + }, []); + + // The clone is visually identical to the first message: once its slide-in + // finishes, jump back to the real first row without animating. + React.useEffect(() => { + if (position !== PENDING_TICKER_MESSAGES.length) return; + const timer = window.setTimeout(() => { + setSnap(true); + setPosition(0); + }, PENDING_TICKER_SLIDE_MS); + return () => window.clearTimeout(timer); + }, [position]); + + // Re-enable the transition one frame after the snap has painted. + React.useEffect(() => { + if (!snap) return; + const raf = window.requestAnimationFrame(() => setSnap(false)); + return () => window.cancelAnimationFrame(raf); + }, [snap]); + + // The clone row duplicates the first message's text, so it carries its own + // stable key. + const column = [ + ...PENDING_TICKER_MESSAGES.map((message) => ({ key: message, message })), + { key: "wrap-clone", message: PENDING_TICKER_MESSAGES[0] }, + ]; + + return ( + + + {column.map((row) => ( + + {row.message} + + ))} + + + ); +} + type EncryptedBackupCreatorProps = { /** "spotlight" is the onboarding treatment; "boxed" fits settings cards. */ variant?: "spotlight" | "boxed"; @@ -258,6 +345,12 @@ export function EncryptedBackupCreator({ }; }, []); + // A queued download locks the form — mask the password too so it isn't + // left readable on screen while the user waits for the save dialog. + React.useEffect(() => { + if (state.downloadPending) setIsRevealed(false); + }, [state.downloadPending]); + // Eager background encryption: start the KDF as soon as the passphrase is // valid (debounced against typing). Results are keyed by passphrase in the // reducer, so a completion for an edited passphrase is dropped there. @@ -414,6 +507,7 @@ export function EncryptedBackupCreator({ aria-label={isRevealed ? "Hide password" : "Reveal password"} className="absolute right-1 top-1/2 h-8 w-8 -translate-y-1/2 text-muted-foreground hover:text-foreground" data-testid="backup-passphrase-reveal-toggle" + disabled={state.downloadPending} onClick={() => setIsRevealed((revealed) => !revealed)} size="icon" type="button" @@ -471,7 +565,7 @@ export function EncryptedBackupCreator({ onClick={() => dispatch({ type: "download-clicked" })} type="button" > - {state.downloadPending ? "Downloading once finished" : "Download"} + {state.downloadPending ? : "Download"} );