feat(onboarding): rewire machine onboarding step graph around signup-first landing

Replaces the identity-first landing with the onboarding v2 entry: the
landing now offers Get started (signup), Log in, and a bottom-docked
Sign up without email tertiary entry. Signup and login render
placeholder screens until the account-credentials PR fills in the
shared form; the no-email path routes to a new advanced-identity page
carrying today's create-key/import actions (real Tauri identity
commands) plus the identity-key help dialog moved off the landing.

New machineOnboardingPages module owns the page union, chrome-position
mapping (landing renders no chrome; TOTAL_ONBOARDING_PAGES stays 7),
and back-target helpers, with unit tests. An accountEntryPage state
makes Backup's Back return to whichever entry created the key, and
key-import's Back returns to advanced-identity. Import to setup still
skips the backup screen.

Enum contract for the advanced-entry PR: advanced-identity is the
create/import choice state (placeholder content for now — the no-email
path is paused); key-import and backup are unchanged.

E2e specs and helpers route via the new advanced-identity entry
(new enterAdvancedIdentity helper). PR 2 of the onboarding v2 sequence.

Co-authored-by: npub19x6jnl6rhepymwyl2xlltz3ce7rfg2ktllle3g2vu59n3s490k8s9n40l3 <29b529ff43be424db89f51bff58a38cf86942acbffff98a14ce50b38c2a57d8f@buzz.block.builderlab.xyz>
Signed-off-by: npub19x6jnl6rhepymwyl2xlltz3ce7rfg2ktllle3g2vu59n3s490k8s9n40l3 <29b529ff43be424db89f51bff58a38cf86942acbffff98a14ce50b38c2a57d8f@buzz.block.builderlab.xyz>
This commit is contained in:
npub19x6jnl6rhepymwyl2xlltz3ce7rfg2ktllle3g2vu59n3s490k8s9n40l3
2026-07-23 10:20:01 -07:00
parent 747720cff9
commit fc6600be9a
12 changed files with 323 additions and 21 deletions

View File

@@ -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<MachineOnboardingPage>(
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<AccountEntryPage>("advanced-identity");
const [error, setError] = React.useState<string | null>(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 (
<div
className={`buzz-onboarding-neutral-theme buzz-startup-shell flex max-h-dvh items-start justify-center overflow-x-hidden overflow-y-auto px-4 text-foreground ${
@@ -120,10 +137,8 @@ export function MachineOnboardingFlow({
>
<StartupWindowDragRegion />
{page === "identity" ? <LandingBees /> : null}
{page !== "identity" ? (
<OnboardingChrome
current={page === "config" ? 4 : page === "setup" ? 3 : 2}
/>
{chromePosition !== null ? (
<OnboardingChrome current={chromePosition} />
) : null}
<OnboardingFooterProvider>
<div
@@ -153,8 +168,133 @@ export function MachineOnboardingFlow({
<div className="mt-10 flex flex-col items-center gap-3">
<Button
className={ONBOARDING_LANDING_CTA_CLASS}
onClick={() => setPage("signup")}
type="button"
>
Get started
</Button>
<Button
className="h-9 rounded-full bg-foreground/10 px-5 hover:bg-foreground/15"
onClick={() => setPage("login")}
type="button"
variant="ghost"
>
Log in
</Button>
</div>
{/* 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. */}
<OnboardingFooter>
<button
className="text-sm text-foreground/70 hover:text-foreground"
onClick={() => setPage("advanced-identity")}
type="button"
>
Sign up without email
</button>
</OnboardingFooter>
</OnboardingSlideTransition>
) : page === "signup" ? (
<OnboardingSlideTransition
className="flex w-full max-w-[720px] flex-col items-center text-center"
direction="forward"
effect="fade"
transitionKey="machine-signup"
>
<h1 className="text-title font-normal text-foreground">
Sign up for Buzz
</h1>
<p className="mt-5 max-w-[440px] text-sm leading-6 text-foreground/80">
Create an account with your email and a password. Your unique
identity key is created along with your account.
</p>
{error ? (
<p className="mt-4 text-sm text-destructive">{error}</p>
) : 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. */}
<OnboardingFooter>
<Button
className={ONBOARDING_PRIMARY_CTA_CLASS}
data-testid="onboarding-next"
disabled={isPending}
onClick={() => void loadFreshIdentity()}
onClick={() => {
setAccountEntryPage("signup");
void loadFreshIdentity();
}}
type="button"
>
{isPending ? "Creating account…" : "Create account"}
</Button>
<Button
className="h-9 rounded-full bg-foreground/10 px-6 hover:bg-foreground/15"
data-testid="onboarding-back"
disabled={isPending}
onClick={() => setPage("identity")}
type="button"
variant="ghost"
>
Back
</Button>
</OnboardingFooter>
</OnboardingSlideTransition>
) : page === "login" ? (
<OnboardingSlideTransition
className="flex w-full max-w-[720px] flex-col items-center text-center"
direction="forward"
effect="fade"
transitionKey="machine-login"
>
<h1 className="text-title font-normal text-foreground">
Log in to Buzz
</h1>
<p className="mt-5 max-w-[440px] text-sm leading-6 text-foreground/80">
Welcome back. Log in with the email and password you signed up
with.
</p>
{/* Placeholder — the login form lands in the signup/login PR. */}
<OnboardingFooter>
<Button
className="h-9 rounded-full bg-foreground/10 px-6 hover:bg-foreground/15"
data-testid="onboarding-back"
onClick={() => setPage("identity")}
type="button"
variant="ghost"
>
Back
</Button>
</OnboardingFooter>
</OnboardingSlideTransition>
) : page === "advanced-identity" ? (
<OnboardingSlideTransition
className="flex w-full max-w-[720px] flex-col items-center text-center"
direction="forward"
effect="fade"
transitionKey="machine-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. */}
<h1 className="text-title font-normal text-foreground">
Sign up without email
</h1>
<p className="mt-5 max-w-[440px] text-sm leading-6 text-foreground/80">
Use Buzz with an identity key alone — create a new key, or bring
one you already have.
</p>
{error ? (
<p className="mt-4 text-sm text-destructive">{error}</p>
) : null}
<div className="mt-10 flex flex-col items-center gap-3">
<Button
className={ONBOARDING_PRIMARY_CTA_CLASS}
disabled={isPending}
onClick={() => {
setAccountEntryPage("advanced-identity");
void loadFreshIdentity();
}}
type="button"
>
{isPending ? "Saving identity…" : "Create a new identity key"}
@@ -170,6 +310,18 @@ export function MachineOnboardingFlow({
</Button>
</div>
<IdentityKeyHelpDialog />
<OnboardingFooter>
<Button
className="h-9 rounded-full bg-foreground/10 px-6 hover:bg-foreground/15"
data-testid="onboarding-back"
disabled={isPending}
onClick={() => setPage("identity")}
type="button"
variant="ghost"
>
Back
</Button>
</OnboardingFooter>
</OnboardingSlideTransition>
) : page === "key-import" ? (
<OnboardingSlideTransition
@@ -196,7 +348,7 @@ export function MachineOnboardingFlow({
onBack={
identityLost
? () => void replaceLostIdentity()
: () => setPage("identity")
: () => setPage("advanced-identity")
}
onImport={importExistingIdentity}
variant="spotlight"
@@ -206,14 +358,14 @@ export function MachineOnboardingFlow({
) : page === "backup" ? (
<BackupStep
direction="forward"
onBack={() => setPage("identity")}
onBack={() => setPage(accountEntryPage)}
onNext={() => setPage("setup")}
/>
) : page === "setup" ? (
<SetupStep
actions={{
back: () =>
setPage(identityWasImported ? "key-import" : "backup"),
setPage(machineSetupBackTarget(identityWasImported)),
next: (runtimeIds) => {
setReadyRuntimeIds(Array.from(runtimeIds));
setPage("config");

View File

@@ -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;

View File

@@ -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");
});

View File

@@ -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";
}

View File

@@ -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",

View File

@@ -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(

View File

@@ -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();

View File

@@ -38,6 +38,7 @@ function runtime(
async function navigateToSetupPage(
page: Parameters<typeof installMockBridge>[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();

View File

@@ -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();

View File

@@ -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" });

View File

@@ -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);

View File

@@ -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();