mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Thomas Petersen
parent
78cbffeb64
commit
3ddaa0d59d
@@ -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 — we’ll set up your identity as you go. Your
|
||||
agents, backups, and settings are one click away once you’re 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 & 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` });
|
||||
});
|
||||
Reference in New Issue
Block a user