explore(desktop): discovery-first onboarding landing (Discord-style)

First open now lands on a community discovery screen instead of the
agent-setup corridor. Featured communities are joinable in one click:
identity is persisted silently and a first-community onboarding
transaction connects to the chosen relay, deferring backup, harness,
and agent config to post-join. The old corridor remains reachable via
'advanced setup', and key import via 'use an existing key'.

Also fixes OnboardingFooter stacking so the Back button stays
clickable above wide docked CTA groups.

Exploration for the community-first onboarding flip (Track 2).

Co-authored-by: Thomas Petersen <thomasp@squareup.com>
Signed-off-by: Thomas Petersen <thomasp@squareup.com>
This commit is contained in:
Wintermute
2026-08-15 13:59:13 -04:00
co-authored by Thomas Petersen
parent 78cbffeb64
commit 3ddaa0d59d
6 changed files with 343 additions and 35 deletions
+1
View File
@@ -23,6 +23,7 @@ export default defineConfig({
"**/sidebar-offcanvas-rail.spec.ts",
"**/search-scope-screenshots.spec.ts",
"**/onboarding-docked-cta-screenshots.spec.ts",
"**/discovery-landing-shot.spec.ts",
"**/identity-key-help.spec.ts",
"**/key-import-reveal.spec.ts",
"**/navigation.spec.ts",
@@ -0,0 +1,62 @@
/**
* EXPLORATION — Discord-style first-open discovery.
*
* A compiled directory of communities a brand-new user can join right away,
* shown on the first-open landing before any identity/agent setup. In a real
* implementation this list would come from a directory service (or a
* curated kind:30xxx event on a bootstrap relay); for the exploration it is
* a compiled constant, exactly like the compiled default relays that
* `initFirstCommunity` already admits token-less.
*/
export type FeaturedCommunity = {
/** Stable id for test hooks and keys. */
id: string;
name: string;
tagline: string;
relayUrl: string;
/** Approximate member count shown as social proof. */
members: number;
/** Accent emoji standing in for a community icon. */
emoji: string;
/** True while a relay is open to token-less first connections. */
openJoin: boolean;
};
export const FEATURED_COMMUNITIES: FeaturedCommunity[] = [
{
id: "buzz-hq",
name: "Buzz HQ",
tagline: "The team building Buzz — ask anything, meet the agents.",
relayUrl: "wss://buzz.block.builderlab.xyz",
members: 128,
emoji: "🐝",
openJoin: true,
},
{
id: "agent-builders",
name: "Agent Builders",
tagline: "Share agents, adopt the best ones, learn by watching.",
relayUrl: "wss://agents.buzz.builderlab.xyz",
members: 342,
emoji: "🤖",
openJoin: true,
},
{
id: "nostr-devs",
name: "Nostr Devs",
tagline: "Protocol talk, NIPs, relays, and open social infrastructure.",
relayUrl: "wss://nostr.buzz.builderlab.xyz",
members: 214,
emoji: "🟣",
openJoin: true,
},
{
id: "digital-nomads",
name: "Digital Nomads",
tagline: "500 travelers coordinating meetups, visas, and city guides.",
relayUrl: "wss://nomads.buzz.builderlab.xyz",
members: 507,
emoji: "🌍",
openJoin: true,
},
];
@@ -0,0 +1,123 @@
import {
FEATURED_COMMUNITIES,
type FeaturedCommunity,
} from "@/features/onboarding/featuredCommunities";
import { Button } from "@/shared/ui/button";
import { Card } from "@/shared/ui/card";
import { ONBOARDING_SECONDARY_CTA_CLASS } from "./OnboardingChrome";
/**
* EXPLORATION — Discord-style first-open landing.
*
* The very first screen a fresh install shows: a directory of communities
* the user can join right away. No identity ceremony, no agent corridor —
* clicking Join creates the key silently and connects. Agent setup and key
* backup become one avenue off this screen (`onAdvancedSetup`) instead of
* the mandatory path.
*/
export function DiscoveryLanding({
error,
isPending,
onAdvancedSetup,
onImportKey,
onJoin,
}: {
error: string | null;
isPending: boolean;
/** Classic corridor: identity → backup → harness → config. */
onAdvancedSetup: () => void;
onImportKey: () => void;
onJoin: (community: FeaturedCommunity) => void;
}) {
return (
<div
className="flex w-full max-w-[860px] flex-col items-center text-center"
data-testid="discovery-landing"
>
<img
alt="Buzz"
className="w-full max-w-[420px]"
src="/landing/buzz-wordmark.png"
/>
<h1 className="mt-4 text-2xl font-normal leading-tight text-foreground">
Find your people
</h1>
<p className="mt-2 max-w-[520px] text-sm leading-6 text-foreground/80">
Jump into a community well set up your identity as you go. Your
agents, backups, and settings are one click away once youre in.
</p>
{error ? <p className="mt-4 text-sm text-destructive">{error}</p> : null}
<div className="mt-10 grid w-full grid-cols-1 gap-x-10 gap-y-12 sm:grid-cols-2">
{FEATURED_COMMUNITIES.map((community) => (
<Card
className="items-stretch px-7 py-5 text-left [--buzz-card-textured-min-height:132px]"
key={community.id}
variant="textured"
>
<div className="flex items-start gap-4">
<span
aria-hidden
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-foreground/8 text-2xl"
>
{community.emoji}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-3">
<span className="truncate text-base font-medium text-foreground">
{community.name}
</span>
<span className="shrink-0 text-xs text-foreground/60">
{community.members.toLocaleString()} members
</span>
</div>
<p className="mt-1 line-clamp-2 text-sm leading-5 text-foreground/75">
{community.tagline}
</p>
</div>
</div>
<div className="mt-4 flex justify-end">
<Button
className="h-8 rounded-full px-5"
data-testid={`discovery-join-${community.id}`}
disabled={isPending}
onClick={() => onJoin(community)}
size="sm"
type="button"
>
Join
</Button>
</div>
</Card>
))}
</div>
<div className="mt-12 flex flex-col items-center gap-3 pb-10">
<p className="text-sm text-foreground/70">
Have an invite link, or want to run your own?
</p>
<div className="flex flex-wrap items-center justify-center gap-3">
<Button
className={ONBOARDING_SECONDARY_CTA_CLASS}
data-testid="discovery-advanced-setup"
disabled={isPending}
onClick={onAdvancedSetup}
type="button"
variant="ghost"
>
Set up identity &amp; agents first
</Button>
<Button
className={ONBOARDING_SECONDARY_CTA_CLASS}
data-testid="discovery-import-key"
disabled={isPending}
onClick={onImportKey}
type="button"
variant="ghost"
>
I already have a key
</Button>
</div>
</div>
</div>
);
}
@@ -16,8 +16,11 @@ import {
DialogTitle,
} from "@/shared/ui/dialog";
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
import type { FeaturedCommunity } from "@/features/onboarding/featuredCommunities";
import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding";
import { BackupStep } from "./BackupStep";
import { DefaultConfigStep } from "./DefaultConfigStep";
import { DiscoveryLanding } from "./DiscoveryLanding";
import { DownloadKeyStep } from "./DownloadKeyStep";
import {
backupSessionToPasswordEntry,
@@ -46,6 +49,7 @@ import { SetupStep } from "./SetupStep";
import type { DefaultConfigDraft } from "./types";
export type MachineOnboardingPage =
| "discover"
| "identity"
| "key-import"
| "backup"
@@ -84,7 +88,7 @@ export function MachineOnboardingFlow({
navigateAfterComplete?: (nav: PostOnboardingNavigation) => void;
}) {
const [page, setPage] = React.useState<MachineOnboardingPage>(
identityLost ? "key-import" : (initialPage ?? "identity"),
identityLost ? "key-import" : (initialPage ?? "discover"),
);
const [transitionDirection, setTransitionDirection] =
React.useState<OnboardingTransitionDirection>("forward");
@@ -121,6 +125,7 @@ export function MachineOnboardingFlow({
// security subview keeps the created backup, password, and test progress.
const backupSession = useEncryptedBackupSession();
const reduceMotion = useReducedMotion() ?? false;
const communityOnboarding = useCommunityOnboarding();
const isSecuritySubview = page === "backup" && backupSubview !== "created";
const handleReadyRuntimeIdsChange = React.useCallback(
(runtimeIds: readonly string[]) => {
@@ -151,6 +156,39 @@ export function MachineOnboardingFlow({
}
}, [queryClient]);
/**
* EXPLORATION — one-click join from the discovery landing.
*
* Persists a fresh identity silently (no backup ceremony — that becomes a
* post-join nudge), completes machine onboarding, and starts a community
* onboarding transaction pointed at the chosen relay. The existing
* CommunityApp machinery then connects, checks/creates the profile, and
* lands the user in the community.
*/
const quickJoinCommunity = React.useCallback(
async (community: FeaturedCommunity) => {
setIsPending(true);
setError(null);
try {
const identity = await getIdentity();
queryClient.setQueryData(["identity"], identity);
communityOnboarding.start({
source: "first-community",
relayUrl: community.relayUrl,
communityName: community.name,
});
complete(identity.pubkey);
} catch (cause) {
setError(
cause instanceof Error ? cause.message : "Failed to load identity",
);
} finally {
setIsPending(false);
}
},
[communityOnboarding, complete, queryClient],
);
const loadRecoveredIdentity = React.useCallback(async () => {
setIsPending(true);
setError(null);
@@ -253,40 +291,48 @@ export function MachineOnboardingFlow({
}, [backupSession, backupSubview, identityWasImported]);
const chromeBackAction =
page === "key-import" &&
(!identityLost || keyImportStage === "backup-password")
? { disabled: isKeyImporting, onClick: backFromKeyImport }
: page === "backup" && backupSubview !== "created"
? {
label: "Return to onboarding",
onClick: returnToCreatedKey,
testId: "backup-return-to-onboarding",
}
: page === "backup"
page === "identity"
? {
onClick: () => {
setTransitionDirection("backward");
setPage("discover");
},
testId: "identity-back-to-discover",
}
: page === "key-import" &&
(!identityLost || keyImportStage === "backup-password")
? { disabled: isKeyImporting, onClick: backFromKeyImport }
: page === "backup" && backupSubview !== "created"
? {
onClick: () => {
setTransitionDirection("backward");
setPage("identity");
},
label: "Return to onboarding",
onClick: returnToCreatedKey,
testId: "backup-return-to-onboarding",
}
: page === "setup"
? { onClick: backFromSetup }
: page === "config"
? {
disabled: isDefaultConfigSaving,
onClick: () => {
setTransitionDirection("backward");
setPage("setup");
},
}
: undefined;
: page === "backup"
? {
onClick: () => {
setTransitionDirection("backward");
setPage("identity");
},
}
: page === "setup"
? { onClick: backFromSetup }
: page === "config"
? {
disabled: isDefaultConfigSaving,
onClick: () => {
setTransitionDirection("backward");
setPage("setup");
},
}
: undefined;
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 ${
isSecuritySubview ? "buzz-onboarding-security-theme" : ""
} ${
page === "identity"
page === "discover" || page === "identity"
? "buzz-onboarding-welcome py-8"
: "pb-28 pt-[106px]"
}`}
@@ -294,7 +340,7 @@ export function MachineOnboardingFlow({
>
<StartupWindowDragRegion />
{page === "identity" ? <LandingBees /> : null}
{page !== "identity" && !isSecuritySubview ? (
{page !== "discover" && page !== "identity" && !isSecuritySubview ? (
<OnboardingChrome
current={page === "config" ? 4 : page === "setup" ? 3 : 2}
/>
@@ -302,10 +348,36 @@ export function MachineOnboardingFlow({
<OnboardingFooterProvider backAction={chromeBackAction}>
<div
className={`relative flex w-full max-w-[1040px] flex-col items-center text-center ${
page === "identity" ? "my-auto" : "buzz-onboarding-step-frame"
page === "discover" || page === "identity"
? "my-auto"
: "buzz-onboarding-step-frame"
}`}
>
{page === "identity" ? (
{page === "discover" ? (
<OnboardingSlideTransition
className="flex w-full flex-col items-center text-center"
direction={transitionDirection}
transitionKey={`machine-discover-${transitionDirection}`}
>
<DiscoveryLanding
error={error}
isPending={isPending}
onAdvancedSetup={() => {
setError(null);
setTransitionDirection("forward");
setPage("identity");
}}
onImportKey={() => {
setError(null);
setKeyImportDialog(null);
setKeyImportStage("key-entry");
setTransitionDirection("forward");
setPage("key-import");
}}
onJoin={(community) => void quickJoinCommunity(community)}
/>
</OnboardingSlideTransition>
) : page === "identity" ? (
<OnboardingSlideTransition
className="flex w-full max-w-[720px] flex-col items-center text-center"
direction={transitionDirection}
@@ -49,7 +49,16 @@ export function OnboardingFooterProvider({
aria-hidden
className="pointer-events-none fixed inset-x-0 bottom-0 z-10 h-36 bg-[linear-gradient(to_top,var(--buzz-onboarding-shell-bottom)_35%,transparent)]"
/>
<div
className="pointer-events-none fixed inset-x-0 bottom-5 z-20 flex justify-center px-4"
data-testid="onboarding-footer-slot"
ref={setTarget}
/>
{backAction ? (
// Rendered after the footer slot so the Back button stacks above the
// slot's full-width CTA groups (which re-enable pointer events) and
// stays clickable when a step docks a wide group (e.g. the identity
// help trigger).
<div className="fixed bottom-5 left-6 z-20">
<Button
className="h-9 rounded-full bg-foreground/10 px-6 text-sm text-foreground hover:bg-foreground/15 hover:text-foreground"
@@ -63,11 +72,6 @@ export function OnboardingFooterProvider({
</Button>
</div>
) : null}
<div
className="pointer-events-none fixed inset-x-0 bottom-5 z-20 flex justify-center px-4"
data-testid="onboarding-footer-slot"
ref={setTarget}
/>
</OnboardingFooterTargetContext.Provider>
);
}
@@ -0,0 +1,46 @@
import { expect, test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";
const SHOT_DIR = "test-results/discovery-landing";
test.use({ viewport: { width: 1280, height: 960 } });
test("discord-style discovery landing on first open", async ({ page }) => {
await installMockBridge(page, undefined, {
skipCommunitySeed: true,
skipOnboardingSeed: true,
});
await page.goto("/");
// Fresh install → straight to the discovery landing. No identity ceremony.
await expect(page.getByTestId("discovery-landing")).toBeVisible();
await expect(page.getByTestId("discovery-join-buzz-hq")).toBeVisible();
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/01-discovery-landing.png` });
// Advanced setup remains one click away (the classic corridor).
await page.getByTestId("discovery-advanced-setup").click();
await expect(
page.getByRole("button", { name: "Create a new identity key" }),
).toBeVisible();
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/02-advanced-setup-corridor.png` });
// Back returns to discovery.
await page.getByTestId("identity-back-to-discover").click();
await expect(page.getByTestId("discovery-landing")).toBeVisible();
// One-click join: identity is created silently, the community is added, and
// (with the mock relay resolving instantly) the user lands straight in the
// app — no agent corridor, no backup ceremony.
await page.getByTestId("discovery-join-buzz-hq").click();
await expect
.poll(() =>
page.evaluate(() => window.localStorage.getItem("buzz-communities")),
)
.toContain("Buzz HQ");
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/03-after-join-click.png` });
});