diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 71c5fc2e049..9231d002613 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -17,8 +17,8 @@ "windows": [ { "title": "", - "width": 800, - "height": 600, + "width": 900, + "height": 650, "maximized": true, "visible": false, "transparent": false, @@ -30,8 +30,8 @@ "y": 25 }, "backgroundThrottling": "disabled", - "minWidth": 800, - "minHeight": 500 + "minWidth": 900, + "minHeight": 650 } ], "macOSPrivateApi": true, diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index da0fbf65c49..e632e158de8 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -37,7 +37,6 @@ import { CommunityOnboardingFlow } from "@/features/onboarding/ui/CommunityOnboa import { MachineOnboardingFlow, type MachineOnboardingPage, - type PostOnboardingNavigation, } from "@/features/onboarding/ui/MachineOnboardingFlow"; import { OnboardingFlow } from "@/features/onboarding/ui/OnboardingFlow"; import { PendingInviteGate } from "@/features/onboarding/ui/PendingInviteGate"; @@ -299,9 +298,11 @@ function CommunityIdentityReplacementSentinel({ } function AppReady({ + continueOnboarding, isSharedIdentity, isCommunitySwitch, }: { + continueOnboarding: boolean; isSharedIdentity: boolean; isCommunitySwitch: boolean; }) { @@ -319,12 +320,18 @@ function AppReady({ return ; } - if (onboarding.stage === "onboarding") { + if ( + onboarding.stage === "onboarding" || + (continueOnboarding && onboarding.stage === "blocking") + ) { return ( ); @@ -351,10 +358,12 @@ function AppReady({ } function CommunityApp({ + continueOnboarding, currentPubkey, onBackToMachineConfig, sharedIdentity, }: { + continueOnboarding: boolean; currentPubkey: string | null; onBackToMachineConfig: () => void; sharedIdentity: boolean; @@ -415,6 +424,7 @@ function CommunityApp({ hasSwitchedCommunityRef.current = true; } const isCommunitySwitch = hasSwitchedCommunityRef.current; + const isContinuingOnboarding = continueOnboarding && !isCommunitySwitch; const community = useCommunityInit( activeCommunity, @@ -581,7 +591,7 @@ function CommunityApp({ // overlay just keeps the bee on screen long enough to be seen, then fades. // Community switches keep their quiet gate. const showBootSplashOverlay = - bootSplashPhase !== "done" && !isCommunitySwitch; + bootSplashPhase !== "done" && !isCommunitySwitch && !isContinuingOnboarding; let appContent: ReactNode = null; if (!transaction) { @@ -636,6 +646,7 @@ function CommunityApp({ /> ) : null} - ) : isCommunitySwitch ? ( + ) : isCommunitySwitch || isContinuingOnboarding ? ( ) : ( @@ -692,43 +703,23 @@ function MachineBootstrap({ sharedIdentity }: { sharedIdentity: boolean }) { }); const [machineInitialPage, setMachineInitialPage] = useState(); - const [postOnboardingNav, setPostOnboardingNav] = - useState(null); + const [continueOnboarding, setContinueOnboarding] = useState(false); const reopenMachineConfig = useCallback(() => { + setContinueOnboarding(false); setMachineInitialPage("config"); machine.reopen(); }, [machine.reopen]); const completeMachineOnboarding = useCallback( - (pubkey?: string) => { + (pubkey?: string, options?: { continueToProfile?: boolean }) => { + setContinueOnboarding(options?.continueToProfile === true); setMachineInitialPage(undefined); machine.complete(pubkey); }, [machine.complete], ); - const navigateAfterOnboarding = useCallback( - (nav: PostOnboardingNavigation) => { - setPostOnboardingNav(nav); - }, - [], - ); - - // Execute the pending navigation once the RouterProvider is mounted (i.e. - // machine.stage transitions to "ready"). We wait for the ready stage rather - // than using setTimeout(0) so the router is guaranteed to exist before we call - // router.navigate(). - useEffect(() => { - if (machine.stage === "ready" && postOnboardingNav) { - void router.navigate({ - to: postOnboardingNav.to, - search: postOnboardingNav.search ?? {}, - }); - setPostOnboardingNav(null); - } - }, [machine.stage, postOnboardingNav]); - const openAddCommunity = useCallback( (payload: AddCommunityDeepLinkPayload & { requestId: string }) => activeCommunity @@ -765,6 +756,7 @@ function MachineBootstrap({ sharedIdentity }: { sharedIdentity: boolean }) { if (machine.stage === "ready") { return ( {shouldAcknowledgeDeepLink ? : null} diff --git a/desktop/src/app/postOnboardingNav.test.mjs b/desktop/src/app/postOnboardingNav.test.mjs deleted file mode 100644 index 2be2eab95f5..00000000000 --- a/desktop/src/app/postOnboardingNav.test.mjs +++ /dev/null @@ -1,123 +0,0 @@ -/** - * Tests for the post-onboarding navigation contract (Thufir F8). - * - * When the user clicks "Set up agents" during machine onboarding, - * MachineOnboardingFlow calls complete() then navigateAfterComplete({to, search}). - * App.tsx records the pending nav and fires it only when machine.stage === "ready" - * (i.e. once RouterProvider is mounted), never before. - * - * These are pure-logic tests — they simulate the App.tsx useEffect predicate - * directly. No React rendering needed; the behavior under test is: - * pendingNav × stage → navigate() call count. - */ -import assert from "node:assert/strict"; -import test from "node:test"; - -// ── Simulate the App.tsx postOnboardingNav useEffect logic ────────────────── -// -// The production code: -// useEffect(() => { -// if (machine.stage === "ready" && postOnboardingNav) { -// void router.navigate({ to: postOnboardingNav.to, search: ... }); -// setPostOnboardingNav(null); -// } -// }, [machine.stage, postOnboardingNav]); -// -// We simulate this as a pure function and drive it through stage transitions. - -function runEffect(stage, nav, navigate) { - if (stage === "ready" && nav !== null) { - navigate({ to: nav.to, search: nav.search ?? {} }); - return null; // cleared - } - return nav; // unchanged -} - -test("navigate does not fire before stage reaches ready", () => { - const calls = []; - const navigate = (args) => calls.push(args); - - const nav = { to: "/settings", search: { section: "agents" } }; - - // Drive through non-ready stages — navigate must not fire. - for (const stage of ["loading", "onboarding", "blocking"]) { - runEffect(stage, nav, navigate); - } - - assert.equal(calls.length, 0, "navigate must not be called before ready"); -}); - -test("navigate fires exactly once when stage transitions to ready", () => { - const calls = []; - const navigate = (args) => calls.push(args); - - const nav = { to: "/settings", search: { section: "agents" } }; - - // Pre-ready stages — no call. - let pending = nav; - pending = runEffect("onboarding", pending, navigate); - - // Stage reaches ready — navigate fires once. - pending = runEffect("ready", pending, navigate); - - assert.equal(calls.length, 1, "navigate must fire exactly once on ready"); - assert.equal(calls[0].to, "/settings"); - assert.deepStrictEqual(calls[0].search, { section: "agents" }); -}); - -test("navigate does not fire again after nav is cleared", () => { - const calls = []; - const navigate = (args) => calls.push(args); - - const nav = { to: "/settings", search: { section: "agents" } }; - let pending = nav; - - // First ready transition fires and clears. - pending = runEffect("ready", pending, navigate); - assert.equal(calls.length, 1); - assert.equal(pending, null, "pending nav must be cleared after firing"); - - // Re-running the effect with null nav must not fire again. - pending = runEffect("ready", pending, navigate); - assert.equal( - calls.length, - 1, - "navigate must not fire again after being cleared", - ); -}); - -test("navigate fires immediately if nav is set while already ready", () => { - // Edge case: nav arrives after the machine is already ready (e.g. hot reload). - const calls = []; - const navigate = (args) => calls.push(args); - - const nav = { to: "/settings", search: { section: "agents" } }; - - // Start with no pending nav while ready. - let pending = runEffect("ready", null, navigate); - assert.equal(calls.length, 0, "no nav pending → no call"); - - // Nav arrives (complete() called while already ready). - pending = runEffect("ready", nav, navigate); - assert.equal(calls.length, 1, "nav must fire immediately when already ready"); - assert.equal(pending, null); -}); - -test("navigate intent carries exact to and search from navigateToAgentSettings", () => { - // Verifies the specific shape emitted by MachineOnboardingFlow's - // navigateToAgentSettings action: { to: '/settings', search: { section: 'agents' } } - const calls = []; - const navigate = (args) => calls.push(args); - - // Simulate MachineOnboardingFlow calling navigateAfterComplete. - const nav = { to: "/settings", search: { section: "agents" } }; - runEffect("ready", nav, navigate); - - assert.equal(calls.length, 1); - assert.equal(calls[0].to, "/settings", "must navigate to /settings"); - assert.equal( - calls[0].search.section, - "agents", - "must include section=agents", - ); -}); diff --git a/desktop/src/features/agents/acpRuntimesQuery.ts b/desktop/src/features/agents/acpRuntimesQuery.ts index 16f82a0aa95..be8ef381052 100644 --- a/desktop/src/features/agents/acpRuntimesQuery.ts +++ b/desktop/src/features/agents/acpRuntimesQuery.ts @@ -272,17 +272,32 @@ export function useAcpRuntimesQueryForced(options?: { queryFn: () => discoverAcpRuntimes({ force: true }), enabled: false, }); - const forceRefresh = React.useCallback( - () => refreshAcpRuntimes(queryClient), - [queryClient], - ); + const [hasForcedCheckStarted, setHasForcedCheckStarted] = + React.useState(false); + const [isOwnedForcedCheckPending, setIsOwnedForcedCheckPending] = + React.useState(false); + const forceRefresh = React.useCallback(async () => { + // Own the launch state instead of inferring it from the query observer. + // A fast mocked/native response can start and settle inside one React + // batch, so consumers may never render the transient query fetching state. + setHasForcedCheckStarted(true); + setIsOwnedForcedCheckPending(true); + try { + return await refreshAcpRuntimes(queryClient); + } finally { + setIsOwnedForcedCheckPending(false); + } + }, [queryClient]); React.useEffect(() => { - if (enabled && forceOnMount) void forceRefresh(); + if (!enabled || !forceOnMount) return; + void forceRefresh(); }, [enabled, forceOnMount, forceRefresh]); - const isFetching = query.isFetching || forcedQuery.isFetching; + const isFetching = + isOwnedForcedCheckPending || query.isFetching || forcedQuery.isFetching; return { ...query, error: forcedQuery.error ?? query.error, + hasForcedCheckStarted, isError: forcedQuery.isError || query.isError, isFetching, isLoading: isFetching && query.data === undefined, diff --git a/desktop/src/features/agents/ui/AgentConfigFields.tsx b/desktop/src/features/agents/ui/AgentConfigFields.tsx index 5e0a4ab9613..e640e84ec12 100644 --- a/desktop/src/features/agents/ui/AgentConfigFields.tsx +++ b/desktop/src/features/agents/ui/AgentConfigFields.tsx @@ -184,6 +184,8 @@ export type AgentConfigFieldsProps = { runtimeFileConfig?: RuntimeFileConfigSubset | null; placeholderClassName?: string; selectClassName?: string; + showApiKeyEnvVarName?: boolean; + stackModelAndEffortHorizontally?: boolean; /** * Which disclosure preset to render (PR 2 flag cleanup — replaces eight * independent show* booleans): @@ -220,6 +222,8 @@ export function AgentConfigFields({ runtimeFileConfig, placeholderClassName, selectClassName, + showApiKeyEnvVarName = true, + stackModelAndEffortHorizontally = false, disclosure = "full", unstyled = false, useCustomSelect = false, @@ -641,6 +645,27 @@ export function AgentConfigFields({ ? (config.env_vars[effortPersistenceKey] ?? "") : ""; const effortFieldVisible = showEffortField && effortField !== undefined; + const apiKeyCredentialPresent = + apiKeyValue.trim().length > 0 || apiKeyInherited; + const apiKeyValidationRequired = + stackModelAndEffortHorizontally && apiKeyEnvVar !== null; + const apiKeyValidationPending = + apiKeyValidationRequired && + apiKeyCredentialPresent && + modelDiscoveryLoading; + const apiKeyValidationSucceeded = + !apiKeyValidationRequired || + (apiKeyCredentialPresent && + !modelDiscoveryLoading && + discoveredModelOptions !== null); + const apiKeyValidationFailed = + apiKeyValidationRequired && + apiKeyCredentialPresent && + !modelDiscoveryLoading && + discoveredModelOptions === null && + modelDiscoveryStatus !== null; + const onboardingModelAndEffortVisible = + configuredProviderValue.trim().length > 0 && apiKeyValidationSucceeded; const progressiveDefaults = disclosure === "progressive-defaults"; const fieldClassName = unstyled @@ -650,7 +675,9 @@ export function AgentConfigFields({ : "space-y-1.5 p-3"; const blockClassName = unstyled ? "" : "p-3"; const fieldLabelClassName = - unstyled && !progressiveDefaults ? "pl-3" : undefined; + unstyled && !progressiveDefaults && !stackModelAndEffortHorizontally + ? "pl-3" + : undefined; const providerDropdownOptions = [ ...providerOptions .filter( @@ -770,32 +797,8 @@ export function AgentConfigFields({ ); - const dependentContent = ( + const modelAndEffortFields = ( <> - {providerFieldVisible && apiKeyEnvVar ? ( -
- - onConfigChange({ - ...config, - env_vars: { ...config.env_vars, [apiKeyEnvVar]: value }, - }) - } - value={apiKeyValue} - /> -
- ) : null} - {/* Model field — omitted only after confirmed successful empty discovery */} {modelControlVisible ? (
@@ -893,6 +896,50 @@ export function AgentConfigFields({ />
) : null} + + ); + + const dependentContent = ( + <> + {providerFieldVisible && apiKeyEnvVar ? ( +
+ + onConfigChange({ + ...config, + env_vars: { ...config.env_vars, [apiKeyEnvVar]: value }, + }) + } + validationMessage={ + apiKeyValidationFailed + ? "We couldn’t validate this API key. Check the key or your connection and try again." + : null + } + value={apiKeyValue} + /> +
+ ) : null} + + {!stackModelAndEffortHorizontally || onboardingModelAndEffortVisible ? ( + stackModelAndEffortHorizontally ? ( +
+ {modelAndEffortFields} +
+ ) : ( + modelAndEffortFields + ) + ) : null} {showAdvancedFields ? (
diff --git a/desktop/src/features/agents/ui/PersonaProviderApiKeyField.tsx b/desktop/src/features/agents/ui/PersonaProviderApiKeyField.tsx index 2be1f1c28d8..a34f81f9550 100644 --- a/desktop/src/features/agents/ui/PersonaProviderApiKeyField.tsx +++ b/desktop/src/features/agents/ui/PersonaProviderApiKeyField.tsx @@ -29,8 +29,10 @@ export function PersonaProviderApiKeyField({ isInherited, inheritedLabel, isRequired, + isValidating = false, label, onValueChange, + validationMessage, value, }: { disabled: boolean; @@ -47,9 +49,13 @@ export function PersonaProviderApiKeyField({ inheritedLabel: string; /** True when the key is required and not satisfied anywhere. */ isRequired: boolean; + /** True while the provider is checking the current key. */ + isValidating?: boolean; /** Display label, e.g. "Anthropic API Key". */ label: string; onValueChange: (next: string) => void; + /** User-facing validation error for the current key. */ + validationMessage?: string | null; /** Current agent-local value of the secret env var. */ value: string; }) { @@ -59,6 +65,12 @@ export function PersonaProviderApiKeyField({ const hintId = envVarName ? `persona-provider-api-key-hint-${uid}` : undefined; + const validationId = + isValidating || validationMessage + ? `persona-provider-api-key-validation-${uid}` + : undefined; + const describedBy = + [hintId, validationId].filter(Boolean).join(" ") || undefined; return (
@@ -77,7 +89,8 @@ export function PersonaProviderApiKeyField({ )} >
+ {isValidating ? ( +

+ Checking API key… +

+ ) : validationMessage ? ( + + ) : null}
); } diff --git a/desktop/src/features/onboarding/hooks.ts b/desktop/src/features/onboarding/hooks.ts index daf162c0aa1..723fe5b3e05 100644 --- a/desktop/src/features/onboarding/hooks.ts +++ b/desktop/src/features/onboarding/hooks.ts @@ -647,6 +647,7 @@ export function useAppOnboardingState(isSharedIdentity: boolean) { initialProfile: { profile: profileQuery.data, }, + initialProfileDecisionSettled: onboardingGate.stage !== "blocking", }; // Recovery completed this boot: force a relaunch screen regardless of any diff --git a/desktop/src/features/onboarding/ui/AvatarStep.tsx b/desktop/src/features/onboarding/ui/AvatarStep.tsx index 177f8dcaa2f..2182a828b11 100644 --- a/desktop/src/features/onboarding/ui/AvatarStep.tsx +++ b/desktop/src/features/onboarding/ui/AvatarStep.tsx @@ -9,6 +9,7 @@ import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { Spinner } from "@/shared/ui/spinner"; import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome"; +import { useOnboardingCardLayout } from "./OnboardingCard"; import { OnboardingFooter } from "./OnboardingFooter"; import { AnimatePresence, motion } from "motion/react"; import * as React from "react"; @@ -153,7 +154,7 @@ function AvatarStepActions({ {hidden ? null : ( - - {saveRecovery.canSkipForNow ? ( // Error-recovery path: exits onboarding entirely when there is no // saved display name to fall back on. )} @@ -284,6 +285,7 @@ export function AvatarStep({ isCustomColorPickerOpen || shouldHideActionsForAnimatedAvatar; const previewName = name.draftValue.trim() || name.savedValue.trim() || "Your avatar"; + const cardLayout = useOnboardingCardLayout(); const animateEmojiAvatarChange = React.useCallback(() => { setAvatarSquishKey((key) => key + 1); }, []); @@ -322,13 +324,21 @@ export function AvatarStep({ // onboarding content and overflows on short windows, so the shell's own // bottom reserve isn't enough to scroll the last rows out from under the // fixed CTA group + scrim. - className="flex w-full flex-col items-center pb-20" + className={cn( + "flex w-full flex-col", + cardLayout ? "min-h-0 items-stretch pb-0" : "items-center pb-20", + )} data-testid="onboarding-page-avatar" direction={direction} transitionKey={`avatar-${direction}`} > diff --git a/desktop/src/features/onboarding/ui/BackupStep.tsx b/desktop/src/features/onboarding/ui/BackupStep.tsx index 8793eda7942..f27192e0145 100644 --- a/desktop/src/features/onboarding/ui/BackupStep.tsx +++ b/desktop/src/features/onboarding/ui/BackupStep.tsx @@ -1,4 +1,12 @@ -import { Check, Copy, Eye, EyeOff, Info, ShieldCheck } from "lucide-react"; +import { + Check, + ChevronRight, + Copy, + Eye, + EyeOff, + FileLock2, + ShieldCheck, +} from "lucide-react"; import { useReducedMotion } from "motion/react"; import * as React from "react"; @@ -8,18 +16,19 @@ import { cn } from "@/shared/lib/cn"; import { writeTextToClipboard } from "@/shared/lib/clipboard"; import { Button } from "@/shared/ui/button"; import { FuzzyLogo } from "@/shared/ui/buzz-logo/FuzzyLogo"; -import { Card } from "@/shared/ui/card"; import { Spinner } from "@/shared/ui/spinner"; import { ONBOARDING_PRIMARY_CTA_CLASS, ONBOARDING_SECONDARY_CTA_CLASS, } from "./OnboardingChrome"; +import { useOnboardingCardLayout } from "./OnboardingCard"; import { OnboardingFooter } from "./OnboardingFooter"; import { type OnboardingTransitionDirection, OnboardingSlideTransition, } from "./OnboardingSlideTransition"; import { ONBOARDING_KEY_TEXT_CLASS } from "./NsecMaskedDisplay"; +import { ONBOARDING_CARD_NEUTRAL_SURFACE_CLASS } from "./onboardingCardStyles"; /** * How long the "Creating your identity key" loader holds the stage before the @@ -51,27 +60,27 @@ type BackupStepProps = { identityStorage?: IdentityStorage; onNext: () => void; onOpenPasswordBackup: () => void; - onShowOptions: () => void; optionsExpanded: boolean; returningFromSecurity: boolean; }; /** * Onboarding identity-key step — shows the freshly created key, then opens a - * dark backup-options state. Copy fetches the raw key only after an explicit - * click; password backup opens the separate security flow. Neither method - * blocks Next. + * dark backup-options state. The reusable secret stays out of the DOM until an + * explicit reveal or copy action; password backup opens the separate security + * flow. + * Neither method blocks Next. */ export function BackupStep({ direction, identityStorage, onNext, onOpenPasswordBackup, - onShowOptions, optionsExpanded, returningFromSecurity, }: BackupStepProps) { const reduceMotion = useReducedMotion() ?? false; + const cardLayout = useOnboardingCardLayout(); const [created, setCreated] = React.useState(introPlayed || reduceMotion); const [copyState, setCopyState] = React.useState< "idle" | "copying" | "copied" @@ -79,6 +88,7 @@ export function BackupStep({ const [copyError, setCopyError] = React.useState(null); const [nsec, setNsec] = React.useState(null); const [isRevealed, setIsRevealed] = React.useState(false); + const [isRevealPending, setIsRevealPending] = React.useState(false); const cancelledRef = React.useRef(false); const copiedTimerRef = React.useRef(null); @@ -99,10 +109,8 @@ export function BackupStep({ React.useEffect(() => { cancelledRef.current = false; return () => { - // Back-during-fetch: cancel any in-flight setState calls and clear the - // nsec from memory on unmount (backup step is only on the fresh-key path). + // Back-during-fetch: cancel any in-flight setState calls. cancelledRef.current = true; - setNsec(null); if (copiedTimerRef.current !== null) window.clearTimeout(copiedTimerRef.current); }; @@ -113,6 +121,7 @@ export function BackupStep({ setCopyError(null); try { const value = nsec ?? (await getNsec()); + if (!nsec && !cancelledRef.current) setNsec(value); await writeTextToClipboard(value); if (cancelledRef.current) return; setCopyState("copied"); @@ -135,9 +144,10 @@ export function BackupStep({ setIsRevealed(false); return; } + + setIsRevealPending(true); setCopyError(null); try { - // The raw key enters the DOM only after this explicit reveal action. const value = nsec ?? (await getNsec()); if (cancelledRef.current) return; setNsec(value); @@ -147,13 +157,13 @@ export function BackupStep({ setCopyError( err instanceof Error ? err.message : "Failed to retrieve private key.", ); + } finally { + if (!cancelledRef.current) setIsRevealPending(false); } }, [isRevealed, nsec]); - // Fixed-length decorative mask (nsec keys are 63 chars) so no key material - // is fetched just to render the blurred row. Bullets are joined with a - // zero-width space: WebKit won't line-break a run of U+2022 without an - // explicit break opportunity, so the masked row would overflow otherwise. + // Never read the credential merely to size its placeholder. The complete + // reusable secret enters the DOM only after an explicit Reveal action. const maskedKey = React.useMemo( () => Array.from({ length: nsec?.length ?? 63 }, () => "•").join("\u200b"), [nsec], @@ -170,39 +180,58 @@ export function BackupStep({ : identityStorage === "local-file" ? "Stored in private device storage" : "Protected in private device storage"; - const introStorageDescription = - identityStorage === "system-keyring" - ? "Buzz keeps your identity key in your system keychain." - : identityStorage === "local-file" - ? "Buzz keeps your identity key in a private file on this device because the system keychain wasn’t available." - : "Your identity key is protected on this device."; - if (optionsExpanded) { return ( -
+

Backup options

-

+

Your identity key works like a password for your Buzz account. Keep a copy somewhere safe. You can create a backup file and lock it with a password you can remember.

-
+
{storageTitle} @@ -212,7 +241,11 @@ export function BackupStep({
@@ -249,7 +282,11 @@ export function BackupStep({
@@ -291,39 +328,38 @@ export function BackupStep({ return ( -
+
{/* Plain string concat: cn()'s tailwind-merge misreads the custom text-title size token as conflicting with text-foreground. */}

- {created - ? "Your unique identity key has been created" - : "Creating your identity key"} + {created ? "Your private identity key" : "Creating your identity key"}

{created ? (

- {introStorageDescription} You can continue now, or{" "} - {" "} - for ways to restore your account. + Don’t share this key. Anyone who has it can access your account.

) : null}
@@ -344,49 +380,88 @@ export function BackupStep({ ) : (
- -
-
-

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

-
- -
-
+
+

+ {isRevealed ? nsec : maskedKey} +

+ + +
+ + {copyError ? (

Could not retrieve your private key: {copyError}. You can @@ -394,14 +469,6 @@ export function BackupStep({ Identity.

) : null} - -

- - - Never share your private key. Anyone with this key can - impersonate you and access everything in your account. - -

)} @@ -414,7 +481,7 @@ export function BackupStep({ onClick={onNext} type="button" > - Next + Continue diff --git a/desktop/src/features/onboarding/ui/BackupTestFlow.tsx b/desktop/src/features/onboarding/ui/BackupTestFlow.tsx index 9370d5c0614..39b59befaee 100644 --- a/desktop/src/features/onboarding/ui/BackupTestFlow.tsx +++ b/desktop/src/features/onboarding/ui/BackupTestFlow.tsx @@ -1,4 +1,14 @@ -import { Check, CircleHelp, Eye, EyeOff, FileKey2, FileUp } from "lucide-react"; +import { + Check, + ChevronRight, + CircleHelp, + Download, + Eye, + EyeOff, + FileCheck2, + FileKey2, + FileUp, +} from "lucide-react"; import { motion, useReducedMotion } from "motion/react"; import * as React from "react"; import { createPortal } from "react-dom"; @@ -18,6 +28,7 @@ import { ONBOARDING_SECURITY_PRIMARY_CTA_CLASS, ONBOARDING_SECONDARY_CTA_CLASS, } from "./OnboardingChrome"; +import { useOnboardingCardLayout } from "./OnboardingCard"; type BackupTestStage = "drop" | "password" | "success"; @@ -205,7 +216,18 @@ export function BackupTestFlow({ onProgressChange, onVerified, }: BackupTestFlowProps) { + const cardLayout = useOnboardingCardLayout(); const reduceMotion = useReducedMotion() ?? false; + const stageEntrance = reduceMotion + ? false + : cardLayout + ? { opacity: 0 } + : { opacity: 0, y: 10 }; + const successCopyEntrance = reduceMotion + ? false + : cardLayout + ? { opacity: 0 } + : { opacity: 0, y: 8 }; const { stage, fileName, ncryptsec, result } = progress; // True while a file drag is anywhere over the window — the drop overlay // takes over the host surface only for the duration of the drag. @@ -389,8 +411,8 @@ export function BackupTestFlow({