diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts
index 7d06c4da9..5e9dbc9f9 100644
--- a/desktop/playwright.config.ts
+++ b/desktop/playwright.config.ts
@@ -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",
diff --git a/desktop/src/features/onboarding/featuredCommunities.ts b/desktop/src/features/onboarding/featuredCommunities.ts
new file mode 100644
index 000000000..bb23c3f83
--- /dev/null
+++ b/desktop/src/features/onboarding/featuredCommunities.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,
+ },
+];
diff --git a/desktop/src/features/onboarding/ui/DiscoveryLanding.tsx b/desktop/src/features/onboarding/ui/DiscoveryLanding.tsx
new file mode 100644
index 000000000..39cbaadc3
--- /dev/null
+++ b/desktop/src/features/onboarding/ui/DiscoveryLanding.tsx
@@ -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 (
+
+

+
+ Find your people
+
+
+ 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.
+
+ {error ?
{error}
: null}
+
+ {FEATURED_COMMUNITIES.map((community) => (
+
+
+
+ {community.emoji}
+
+
+
+
+ {community.name}
+
+
+ {community.members.toLocaleString()} members
+
+
+
+ {community.tagline}
+
+
+
+
+
+
+
+ ))}
+
+
+
+ Have an invite link, or want to run your own?
+
+
+
+
+
+
+
+ );
+}
diff --git a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx
index 27d6b8ce4..160311179 100644
--- a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx
+++ b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx
@@ -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(
- identityLost ? "key-import" : (initialPage ?? "identity"),
+ identityLost ? "key-import" : (initialPage ?? "discover"),
);
const [transitionDirection, setTransitionDirection] =
React.useState("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 (
{page === "identity" ?
: null}
- {page !== "identity" && !isSecuritySubview ? (
+ {page !== "discover" && page !== "identity" && !isSecuritySubview ? (
@@ -302,10 +348,36 @@ export function MachineOnboardingFlow({
- {page === "identity" ? (
+ {page === "discover" ? (
+
+ {
+ setError(null);
+ setTransitionDirection("forward");
+ setPage("identity");
+ }}
+ onImportKey={() => {
+ setError(null);
+ setKeyImportDialog(null);
+ setKeyImportStage("key-entry");
+ setTransitionDirection("forward");
+ setPage("key-import");
+ }}
+ onJoin={(community) => void quickJoinCommunity(community)}
+ />
+
+ ) : page === "identity" ? (
+
{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).
) : null}
-
);
}
diff --git a/desktop/tests/e2e/discovery-landing-shot.spec.ts b/desktop/tests/e2e/discovery-landing-shot.spec.ts
new file mode 100644
index 000000000..c28972bbd
--- /dev/null
+++ b/desktop/tests/e2e/discovery-landing-shot.spec.ts
@@ -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` });
+});