diff --git a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx index 89c284f237..0f4fe422a3 100644 --- a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx @@ -15,18 +15,28 @@ import { LandingBees } from "./LandingBees"; import { NostrKeyImportForm } from "./NostrKeyImportForm"; import { ONBOARDING_LANDING_CTA_CLASS, + ONBOARDING_PRIMARY_CTA_CLASS, OnboardingChrome, } from "./OnboardingChrome"; -import { OnboardingFooterProvider } from "./OnboardingFooter"; +import { OnboardingFooter, OnboardingFooterProvider } from "./OnboardingFooter"; import { OnboardingSlideTransition } from "./OnboardingSlideTransition"; import { SetupStep } from "./SetupStep"; +import { + machineOnboardingChromePosition, + type MachineOnboardingPage, + machineSetupBackTarget, +} from "./machineOnboardingPages"; -export type MachineOnboardingPage = - | "identity" - | "key-import" - | "backup" - | "setup" - | "config"; +export type { MachineOnboardingPage } from "./machineOnboardingPages"; + +/** + * The two screens that can kick off key creation. Backup's Back button + * returns to whichever one the user came from. + */ +type AccountEntryPage = Extract< + MachineOnboardingPage, + "signup" | "advanced-identity" +>; export function MachineOnboardingFlow({ complete, @@ -44,6 +54,11 @@ export function MachineOnboardingFlow({ const [page, setPage] = React.useState( identityLost ? "key-import" : (initialPage ?? "identity"), ); + // Which account-entry screen led to key creation. Backup's Back button + // returns here, so a "Sign up without email" user is never dropped onto the + // signup form (and vice versa). + const [accountEntryPage, setAccountEntryPage] = + React.useState("advanced-identity"); const [error, setError] = React.useState(null); const [isPending, setIsPending] = React.useState(false); const [identityWasImported, setIdentityWasImported] = React.useState(false); @@ -109,6 +124,8 @@ export function MachineOnboardingFlow({ [continueWithIdentity, queryClient], ); + const chromePosition = machineOnboardingChromePosition(page); + return (
{page === "identity" ? : null} - {page !== "identity" ? ( - + {chromePosition !== null ? ( + ) : null}
+ +
+ {/* Tertiary entry to the no-email flow, docked to the bottom of + the viewport via the shared footer slot rather than stacked + under the primary CTAs. */} + + + + + ) : page === "signup" ? ( + +

+ Sign up for Buzz +

+

+ Create an account with your email and a password. Your unique + identity key is created along with your account. +

+ {error ? ( +

{error}

+ ) : null} + {/* Placeholder — the email + password form lands in the + signup/login PR. "Create account" runs the same key creation + the account will be tied to, keeping the flow walkable. */} + + + + +
+ ) : page === "login" ? ( + +

+ Log in to Buzz +

+

+ Welcome back. Log in with the email and password you signed up + with. +

+ {/* Placeholder — the login form lands in the signup/login PR. */} + + + +
+ ) : page === "advanced-identity" ? ( + + {/* Placeholder — the advanced-entry PR replaces this content + with the full create/import choice screen. The two actions + below keep the no-email path walkable meanwhile. */} +

+ Sign up without email +

+

+ Use Buzz with an identity key alone — create a new key, or bring + one you already have. +

+ {error ? ( +

{error}

+ ) : null} +
+
+ + +
) : page === "key-import" ? ( void replaceLostIdentity() - : () => setPage("identity") + : () => setPage("advanced-identity") } onImport={importExistingIdentity} variant="spotlight" @@ -206,14 +358,14 @@ export function MachineOnboardingFlow({ ) : page === "backup" ? ( setPage("identity")} + onBack={() => setPage(accountEntryPage)} onNext={() => setPage("setup")} /> ) : page === "setup" ? ( - setPage(identityWasImported ? "key-import" : "backup"), + setPage(machineSetupBackTarget(identityWasImported)), next: (runtimeIds) => { setReadyRuntimeIds(Array.from(runtimeIds)); setPage("config"); diff --git a/desktop/src/features/onboarding/ui/OnboardingChrome.tsx b/desktop/src/features/onboarding/ui/OnboardingChrome.tsx index 936313bce0..43fde8f3cd 100644 --- a/desktop/src/features/onboarding/ui/OnboardingChrome.tsx +++ b/desktop/src/features/onboarding/ui/OnboardingChrome.tsx @@ -1,9 +1,11 @@ import { BuzzMark } from "@/shared/ui/buzz-logo/BuzzMark"; /** - * Positions in the first-launch flow: landing, identity/key, harness setup, - * default config, community choice, community profile, meet the team. Used as - * the default pagination length when a flow doesn't pass an explicit total. + * Positions in the first-launch flow: account entry (signup / login / + * sign-up-without-email), identity key, harness setup, default config, + * community choice, community profile, meet the team. The landing screen + * renders no chrome and holds no position. Used as the default pagination + * length when a flow doesn't pass an explicit total. */ export const TOTAL_ONBOARDING_PAGES = 7; diff --git a/desktop/src/features/onboarding/ui/machineOnboardingPages.test.mjs b/desktop/src/features/onboarding/ui/machineOnboardingPages.test.mjs new file mode 100644 index 0000000000..61dc2ec6a4 --- /dev/null +++ b/desktop/src/features/onboarding/ui/machineOnboardingPages.test.mjs @@ -0,0 +1,56 @@ +/** + * Tests for the machine onboarding page graph (onboarding v2): chrome + * positions on the pagination track and the setup Back target. Pure-logic + * tests — no React rendering needed. + * + * The page names asserted here are a cross-PR contract (the advanced-entry + * PR builds against `advanced-identity`; the signup/login PR fills `signup` + * and `login`) — renames must be coordinated in #buzz-onboarding-v2. + */ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + machineOnboardingChromePosition, + machineSetupBackTarget, +} from "./machineOnboardingPages.ts"; + +// --------------------------------------------------------------------------- +// Chrome positions +// --------------------------------------------------------------------------- + +test("landing_renders_no_chrome", () => { + assert.equal(machineOnboardingChromePosition("identity"), null); +}); + +test("account_entry_screens_share_position_1", () => { + // Signup, login, and the no-email entry are branches of the same flow + // position — the track counts positions, not branches. + assert.equal(machineOnboardingChromePosition("signup"), 1); + assert.equal(machineOnboardingChromePosition("login"), 1); + assert.equal(machineOnboardingChromePosition("advanced-identity"), 1); +}); + +test("key_screens_share_position_2", () => { + assert.equal(machineOnboardingChromePosition("key-import"), 2); + assert.equal(machineOnboardingChromePosition("backup"), 2); +}); + +test("setup_is_position_3_and_config_is_4", () => { + assert.equal(machineOnboardingChromePosition("setup"), 3); + assert.equal(machineOnboardingChromePosition("config"), 4); +}); + +// --------------------------------------------------------------------------- +// Setup Back target — import-skips-backup asymmetry +// --------------------------------------------------------------------------- + +test("setup_back_returns_to_key_import_for_imported_identities", () => { + // Imported keys skip the backup step on the way in (the user already has + // their key saved); Back must never surface backup for them. + assert.equal(machineSetupBackTarget(true), "key-import"); +}); + +test("setup_back_returns_to_backup_for_fresh_identities", () => { + assert.equal(machineSetupBackTarget(false), "backup"); +}); diff --git a/desktop/src/features/onboarding/ui/machineOnboardingPages.ts b/desktop/src/features/onboarding/ui/machineOnboardingPages.ts new file mode 100644 index 0000000000..8f72a5ba9d --- /dev/null +++ b/desktop/src/features/onboarding/ui/machineOnboardingPages.ts @@ -0,0 +1,62 @@ +/** + * Pure page-graph data for the machine onboarding flow (onboarding v2). + * + * State names are a cross-PR contract — the advanced (no-email) entry PR + * builds against `advanced-identity`, and the signup/login PR fills `signup` + * and `login`. Do not rename without coordinating in #buzz-onboarding-v2. + */ + +export type MachineOnboardingPage = + /** Landing screen: wordmark + tagline + entry CTAs. Renders no chrome. */ + | "identity" + /** Standard flow: email + password signup (placeholder until PR 3). */ + | "signup" + /** Returning users: email + password login (placeholder until PR 3). */ + | "login" + /** + * Advanced (no-email) flow behind "Sign up without email": create/import + * key choice. Placeholder until the advanced-entry PR fills it. + */ + | "advanced-identity" + | "key-import" + | "backup" + | "setup" + | "config"; + +/** + * Position of a machine-flow page on the shared onboarding pagination track + * (`OnboardingChrome`), or `null` for the landing screen, which renders no + * chrome. The account-entry screens (signup / login / advanced-identity) + * share position 1; key screens share position 2 — the track counts flow + * positions, not branches. + */ +export function machineOnboardingChromePosition( + page: MachineOnboardingPage, +): number | null { + switch (page) { + case "identity": + return null; + case "signup": + case "login": + case "advanced-identity": + return 1; + case "key-import": + case "backup": + return 2; + case "setup": + return 3; + case "config": + return 4; + } +} + +/** + * Back target from the setup (providers) step. Imported identities skip the + * backup step on the way in (the user already has their key saved), so Back + * must return to key-import — never surface backup for an imported key. + */ +export function machineSetupBackTarget( + identityWasImported: boolean, +): MachineOnboardingPage { + return identityWasImported ? "key-import" : "backup"; +} diff --git a/desktop/tests/e2e/identity-key-help.spec.ts b/desktop/tests/e2e/identity-key-help.spec.ts index 2e8967a43b..57a5b99f90 100644 --- a/desktop/tests/e2e/identity-key-help.spec.ts +++ b/desktop/tests/e2e/identity-key-help.spec.ts @@ -11,6 +11,8 @@ test("identity key help explains the first-run choice", async ({ page }) => { skipOnboardingSeed: true, }); await page.goto("/"); + // The help trigger lives on the advanced (no-email) identity screen. + await page.getByRole("button", { name: "Sign up without email" }).click(); const trigger = page.getByTestId("identity-key-help-trigger"); // No initial opacity-0 assertion: on a slow runner the 2s reveal timer can @@ -51,6 +53,7 @@ test("identity key help explains the first-run choice", async ({ page }) => { await expect(trigger).toHaveCSS("opacity", "1"); await page.reload(); + await page.getByRole("button", { name: "Sign up without email" }).click(); await expect(page.getByTestId("identity-key-help-trigger")).toHaveCSS( "opacity", "1", diff --git a/desktop/tests/e2e/identity-lost.spec.ts b/desktop/tests/e2e/identity-lost.spec.ts index 71c663ab4f..78b9c74bf1 100644 --- a/desktop/tests/e2e/identity-lost.spec.ts +++ b/desktop/tests/e2e/identity-lost.spec.ts @@ -20,9 +20,11 @@ test("normal first launch uses the already-persisted identity", async ({ // Landing carries a subtle dot-grid pattern over the chartreuse fill. await expect(gate).toHaveCSS("background-image", /radial-gradient/); await expect(gate).toHaveCSS("color", "rgb(23, 23, 23)"); - await expect( - page.getByRole("button", { name: "Create a new identity key" }), - ).toHaveCSS("background-color", "rgb(23, 23, 23)"); + await expect(page.getByRole("button", { name: "Get started" })).toHaveCSS( + "background-color", + "rgb(23, 23, 23)", + ); + await page.getByRole("button", { name: "Sign up without email" }).click(); await page.getByRole("button", { name: "Create a new identity key" }).click(); await expect( diff --git a/desktop/tests/e2e/key-import-reveal.spec.ts b/desktop/tests/e2e/key-import-reveal.spec.ts index cd05f71e76..e766df2158 100644 --- a/desktop/tests/e2e/key-import-reveal.spec.ts +++ b/desktop/tests/e2e/key-import-reveal.spec.ts @@ -18,6 +18,7 @@ test("key import masks the key with a reveal toggle", async ({ page }) => { }); await page.goto("/"); + await page.getByRole("button", { name: "Sign up without email" }).click(); await page.getByRole("button", { name: "Use an existing key" }).click(); const input = page.getByTestId("nostr-import-nsec-input"); await expect(input).toBeVisible(); diff --git a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts index 40ddc64ef6..44ad4acd41 100644 --- a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts +++ b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts @@ -38,6 +38,7 @@ function runtime( async function navigateToSetupPage( page: Parameters[0], ) { + await page.getByRole("button", { name: "Sign up without email" }).click(); await page.getByRole("button", { name: "Create a new identity key" }).click(); await passThroughBackupStep(page); await expect(page.getByTestId("onboarding-page-2")).toBeVisible(); diff --git a/desktop/tests/e2e/onboarding-backup.spec.ts b/desktop/tests/e2e/onboarding-backup.spec.ts index 14f50e924f..72448c2ea6 100644 --- a/desktop/tests/e2e/onboarding-backup.spec.ts +++ b/desktop/tests/e2e/onboarding-backup.spec.ts @@ -8,6 +8,7 @@ async function enterMachineBackup(page: import("@playwright/test").Page) { skipOnboardingSeed: true, }); await page.goto("/"); + await page.getByRole("button", { name: "Sign up without email" }).click(); await page.getByRole("button", { name: "Create a new identity key" }).click(); } @@ -103,6 +104,7 @@ test("backup step shows error banner and retry button when get_nsec fails", asyn { skipCommunitySeed: true, skipOnboardingSeed: true }, ); await page.goto("/"); + await page.getByRole("button", { name: "Sign up without email" }).click(); await page.getByRole("button", { name: "Create a new identity key" }).click(); await expect(page.getByTestId("onboarding-page-backup")).toBeVisible(); @@ -127,6 +129,7 @@ test("backup step retry succeeds and shows key after initial failure", async ({ { skipCommunitySeed: true, skipOnboardingSeed: true }, ); await page.goto("/"); + await page.getByRole("button", { name: "Sign up without email" }).click(); await page.getByRole("button", { name: "Create a new identity key" }).click(); await expect(page.getByTestId("backup-load-error")).toBeVisible(); diff --git a/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts b/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts index dbb8a92a1f..e24bd0470e 100644 --- a/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts +++ b/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts @@ -27,6 +27,7 @@ test("machine onboarding: landing, backup, setup docked CTAs", async ({ await waitForAnimations(page); await page.screenshot({ path: `${SHOT_DIR}/01-landing.png` }); + await page.getByRole("button", { name: "Sign up without email" }).click(); await page.getByRole("button", { name: "Use an existing key" }).click(); await expect( page.getByRole("heading", { name: "Enter your private key" }), @@ -82,6 +83,7 @@ test("machine key import remains usable in a short viewport", async ({ skipOnboardingSeed: true, }); await page.goto("/"); + await page.getByRole("button", { name: "Sign up without email" }).click(); await page.getByRole("button", { name: "Use an existing key" }).click(); const heading = page.getByRole("heading", { name: "Enter your private key" }); diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index c3c716bec3..4c13058a52 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -550,6 +550,7 @@ test("first-launch key import continues to machine setup", async ({ page }) => { }); await page.goto("/"); + await page.getByRole("button", { name: "Sign up without email" }).click(); await page.getByRole("button", { name: "Use an existing key" }).click(); const importedNsec = nsecEncode(hexToBytes(TEST_IDENTITIES.alice.privateKey)); await page.getByTestId("nostr-import-nsec-input").fill(importedNsec); diff --git a/desktop/tests/helpers/onboarding.ts b/desktop/tests/helpers/onboarding.ts index 61b31e6e5b..67f487760f 100644 --- a/desktop/tests/helpers/onboarding.ts +++ b/desktop/tests/helpers/onboarding.ts @@ -16,6 +16,23 @@ export async function seedActiveIdentity( ); } +/** + * From the landing screen, enter the advanced (no-email) identity screen — + * the create-key / import-key choice behind "Sign up without email". + */ +export async function enterAdvancedIdentity(page: Page) { + await page.getByRole("button", { name: "Sign up without email" }).click(); + await expect( + page.getByRole("button", { name: "Create a new identity key" }), + ).toBeVisible(); +} + +/** From the landing screen, create a fresh key via the advanced path. */ +export async function createFreshIdentityKey(page: Page) { + await enterAdvancedIdentity(page); + await page.getByRole("button", { name: "Create a new identity key" }).click(); +} + /** Navigate through the backup step (fresh-key path). */ export async function passThroughBackupStep(page: Page) { await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();