From a3a032459227020066dd632e60bf016aecc661f1 Mon Sep 17 00:00:00 2001
From: npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79
<1a7496365ae2df8adb1b932a1a6f68b9cb3950f3d179c8a451c32d194e8f7dc1@sprout-oss.stage.blox.sqprod.co>
Date: Sat, 18 Jul 2026 22:36:11 -0700
Subject: [PATCH 1/4] feat(desktop): add community home
Co-authored-by: npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79 <1a7496365ae2df8adb1b932a1a6f68b9cb3950f3d179c8a451c32d194e8f7dc1@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79 <1a7496365ae2df8adb1b932a1a6f68b9cb3950f3d179c8a451c32d194e8f7dc1@sprout-oss.stage.blox.sqprod.co>
---
desktop/playwright.config.ts | 1 +
desktop/src/app/App.tsx | 35 ++-
desktop/src/app/AppShell.tsx | 4 +-
.../features/communities/communityStorage.ts | 4 +
.../features/communities/ui/CommunityHome.tsx | 278 ++++++++++++++++++
.../communities/ui/CommunitySwitcher.tsx | 2 +-
.../features/communities/ui/WelcomeSetup.tsx | 5 +-
.../features/communities/useCommunities.tsx | 50 ++--
.../src/features/sidebar/ui/CommunityRail.tsx | 27 +-
desktop/tests/e2e/community-home.spec.ts | 91 ++++++
10 files changed, 452 insertions(+), 45 deletions(-)
create mode 100644 desktop/src/features/communities/ui/CommunityHome.tsx
create mode 100644 desktop/tests/e2e/community-home.spec.ts
diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts
index 5b9b96468c..4c2986762a 100644
--- a/desktop/playwright.config.ts
+++ b/desktop/playwright.config.ts
@@ -71,6 +71,7 @@ export default defineConfig({
"**/top-chrome-zoom-clearance.spec.ts",
"**/thread-unread.spec.ts",
"**/workspace-rail.spec.ts",
+ "**/community-home.spec.ts",
"**/boot-splash.spec.ts",
"**/thread-reply-anchor-roleplay.spec.ts",
"**/threadpane-ultrawide.spec.ts",
diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx
index 782cf1c14c..d91ed70667 100644
--- a/desktop/src/app/App.tsx
+++ b/desktop/src/app/App.tsx
@@ -35,6 +35,7 @@ import {
onAddCommunityPrefillAvailable,
requestAddCommunityPrefill,
} from "@/features/communities/addCommunityPrefill";
+import { CommunityHome } from "@/features/communities/ui/CommunityHome";
import { WelcomeSetup } from "@/features/communities/ui/WelcomeSetup";
import { CommunityApplyErrorScreen } from "@/features/communities/ui/CommunityApplyErrorScreen";
import { CommunityChangeOverlay } from "@/features/communities/ui/CommunityChangeOverlay";
@@ -275,8 +276,7 @@ function CommunityApp({
const communityOnboarding = useCommunityOnboarding();
const connectingTransactionRef = useRef(null);
const [isCommunityChangeOpen, setIsCommunityChangeOpen] = useState(false);
- const [resumeFirstCommunityJoin, setResumeFirstCommunityJoin] =
- useState(false);
+ const [isJoinCommunityOpen, setIsJoinCommunityOpen] = useState(false);
// Surface nest-related backend events (repos-dir errors, legacy migration)
// as toasts. Mounted before useCommunityInit so the listeners are registered
@@ -303,6 +303,12 @@ function CommunityApp({
sharedIdentity,
);
+ useEffect(() => {
+ if (activeCommunity !== null) {
+ setIsJoinCommunityOpen(false);
+ }
+ }, [activeCommunity]);
+
const handleCommunityOnboardingConnect = useCallback(() => {
const transaction = communityOnboarding.transaction;
if (transaction?.stage !== "connecting") return;
@@ -353,10 +359,8 @@ function CommunityApp({
return;
}
if (communities.length === 1) {
- if (transaction.source === "first-community") {
- setResumeFirstCommunityJoin(true);
- }
clearCommunities();
+ setIsJoinCommunityOpen(true);
return;
}
removeCommunity(transaction.communityId);
@@ -407,13 +411,22 @@ function CommunityApp({
let appContent: ReactNode = null;
if (!transaction) {
if (community.needsSetup) {
- // Show welcome setup for first-run users with no communities
- appContent = (
+ appContent = isJoinCommunityOpen ? (
setIsJoinCommunityOpen(false)}
/>
+ ) : (
+
+ setIsJoinCommunityOpen(true)}
+ onOpenCommunity={switchCommunity}
+ onRemoveCommunity={removeCommunity}
+ />
+
);
} else if ("error" in community && community.error) {
// Surface apply failures so the user can retry or change community.
@@ -489,10 +502,10 @@ function CommunityApp({
}
function MachineBootstrap({ sharedIdentity }: { sharedIdentity: boolean }) {
- const { activeCommunity } = useCommunities();
+ const { activeCommunity, communities } = useCommunities();
const communityOnboarding = useCommunityOnboarding();
const machine = useMachineOnboardingState({
- hasConfiguredCommunity: activeCommunity !== null,
+ hasConfiguredCommunity: communities.length > 0,
isSharedIdentity: sharedIdentity,
});
const [machineInitialPage, setMachineInitialPage] =
diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx
index 5b65584e8c..e3434d2252 100644
--- a/desktop/src/app/AppShell.tsx
+++ b/desktop/src/app/AppShell.tsx
@@ -94,14 +94,13 @@ const LazySettingsScreen = React.lazy(async () => {
const module = await import("@/features/settings/ui/SettingsScreen");
return { default: module.SettingsScreen };
});
-
export function AppShell() {
useWebviewZoomShortcuts();
useTauriWindowDrag();
useWebviewScrollBoundaryLock();
const communitiesHook = useCommunities();
- const hasCommunityRail = communitiesHook.communities.length > 1;
+ const hasCommunityRail = communitiesHook.communities.length > 0;
const addCommunityDialog = useAddCommunityDialogState();
const [isChannelManagementOpen, setIsChannelManagementOpen] =
React.useState(false);
@@ -771,6 +770,7 @@ export function AppShell() {
}
onAddCommunity={addCommunityDialog.openDialog}
onRemoveCommunity={communitiesHook.removeCommunity}
+ onShowCommunityHome={communitiesHook.showCommunityHome}
onSwitchCommunity={handleSwitchCommunity}
onUpdateCommunity={communitiesHook.updateCommunity}
communities={communitiesHook.communities}
diff --git a/desktop/src/features/communities/communityStorage.ts b/desktop/src/features/communities/communityStorage.ts
index e406d9087f..58eae5bf3c 100644
--- a/desktop/src/features/communities/communityStorage.ts
+++ b/desktop/src/features/communities/communityStorage.ts
@@ -101,6 +101,10 @@ export function saveActiveCommunityId(id: string): void {
setLocalStorageItemWithRecovery(ACTIVE_COMMUNITY_KEY, id);
}
+export function clearActiveCommunityId(storage: Storage = localStorage): void {
+ storage.removeItem(ACTIVE_COMMUNITY_KEY);
+}
+
export function normalizeRelayUrl(url: string): string {
if (!url.startsWith("ws://") && !url.startsWith("wss://")) {
return `wss://${url}`;
diff --git a/desktop/src/features/communities/ui/CommunityHome.tsx b/desktop/src/features/communities/ui/CommunityHome.tsx
new file mode 100644
index 0000000000..6583398326
--- /dev/null
+++ b/desktop/src/features/communities/ui/CommunityHome.tsx
@@ -0,0 +1,278 @@
+import { openUrl } from "@tauri-apps/plugin-opener";
+import { ArrowRight, Link2, Plus, Settings2, Trash2 } from "lucide-react";
+import * as React from "react";
+
+import { ThemeGrainientBackground } from "@/app/ThemeGrainientBackground";
+import type { Community } from "@/features/communities/types";
+import { useCommunityIcons } from "@/features/communities/useCommunityIcons";
+import { getInitials } from "@/shared/lib/initials";
+import { cn } from "@/shared/lib/cn";
+import { Button } from "@/shared/ui/button";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/shared/ui/alert-dialog";
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuSeparator,
+ ContextMenuTrigger,
+} from "@/shared/ui/context-menu";
+import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
+
+const CREATE_COMMUNITY_URL = "https://app.builderlab.xyz/signup?returnTo=/buzz";
+const HEX_CLIP_PATH =
+ "polygon(25% 6.7%, 75% 6.7%, 100% 50%, 75% 93.3%, 25% 93.3%, 0 50%)";
+
+function CommunityHex({
+ community,
+ iconUrl,
+ onOpen,
+ onRemove,
+}: {
+ community: Community;
+ iconUrl: string | null;
+ onOpen: () => void;
+ onRemove: () => void;
+}) {
+ return (
+
+
+
+
+
+
+
+ {iconUrl ? (
+
+ ) : (
+ getInitials(community.name) || "๐"
+ )}
+
+
+ {community.name}
+
+
+ Enter
+
+
+
+
+
+
+
+ Open community
+
+ void navigator.clipboard.writeText(community.relayUrl)}
+ >
+
+ Copy relay URL
+
+
+
+
+ Remove from Buzz
+
+
+
+ );
+}
+
+function ActionHex({
+ label,
+ detail,
+ icon,
+ onClick,
+ testId,
+}: {
+ label: string;
+ detail: string;
+ icon: React.ReactNode;
+ onClick: () => void;
+ testId: string;
+}) {
+ return (
+
+
+
+
+ {icon}
+
+ {label}
+ {detail}
+
+
+ );
+}
+
+export function CommunityHome({
+ communities,
+ onOpenCommunity,
+ onJoinCommunity,
+ onRemoveCommunity,
+ onBackToMachineConfig,
+}: {
+ communities: Community[];
+ onOpenCommunity: (id: string) => void;
+ onJoinCommunity: () => void;
+ onRemoveCommunity: (id: string) => void;
+ onBackToMachineConfig: () => void;
+}) {
+ const iconsByCommunity = useCommunityIcons(communities);
+ const [communityToRemove, setCommunityToRemove] =
+ React.useState(null);
+
+ return (
+
+
+
+
+
+
+
+
*:nth-child(even)]:translate-y-[45%] sm:[&>*:nth-child(even)]:translate-y-0",
+ "sm:[&>*:nth-child(3n+2)]:translate-y-[45%] md:[&>*:nth-child(3n+2)]:translate-y-0",
+ "md:[&>*:nth-child(even)]:translate-y-[45%]",
+ )}
+ >
+ {communities.map((community) => (
+ onOpenCommunity(community.id)}
+ onRemove={() => setCommunityToRemove(community)}
+ />
+ ))}
+ }
+ label="Join a community"
+ onClick={onJoinCommunity}
+ testId="community-home-join"
+ />
+ โฆ}
+ label="Create a community"
+ onClick={() => void openUrl(CREATE_COMMUNITY_URL)}
+ testId="community-home-create"
+ />
+
+
+ {communities.length === 0 ? (
+
+ This is your community home. It will stay available whenever you
+ want a neutral place to join, create, or switch communities.
+
+ ) : null}
+
+ {
+ if (!open) setCommunityToRemove(null);
+ }}
+ open={communityToRemove !== null}
+ >
+
+
+
+ Remove {communityToRemove?.name ?? "community"} from Buzz?
+
+
+ This removes the saved community from this device. It does not
+ delete the community or your membership.
+
+
+
+
+
+ Cancel
+
+
+
+ {
+ if (communityToRemove) {
+ onRemoveCommunity(communityToRemove.id);
+ }
+ setCommunityToRemove(null);
+ }}
+ type="button"
+ variant="destructive"
+ >
+ Remove community
+
+
+
+
+
+
+ );
+}
diff --git a/desktop/src/features/communities/ui/CommunitySwitcher.tsx b/desktop/src/features/communities/ui/CommunitySwitcher.tsx
index 020a2e8838..ee4e99e3ab 100644
--- a/desktop/src/features/communities/ui/CommunitySwitcher.tsx
+++ b/desktop/src/features/communities/ui/CommunitySwitcher.tsx
@@ -373,7 +373,7 @@ export function CommunitySwitcher({
)}
1}
+ canRemove={communities.length > 0}
onOpenChange={(open) => {
if (!open) setEditingCommunity(null);
}}
diff --git a/desktop/src/features/communities/ui/WelcomeSetup.tsx b/desktop/src/features/communities/ui/WelcomeSetup.tsx
index 428f9dcfe8..0effba4272 100644
--- a/desktop/src/features/communities/ui/WelcomeSetup.tsx
+++ b/desktop/src/features/communities/ui/WelcomeSetup.tsx
@@ -301,7 +301,10 @@ export function WelcomeSetup({
showPage("welcome")}
+ data-testid="welcome-setup-back"
+ onClick={() =>
+ initialPage === "join" ? onBack() : showPage("welcome")
+ }
type="button"
variant="ghost"
>
diff --git a/desktop/src/features/communities/useCommunities.tsx b/desktop/src/features/communities/useCommunities.tsx
index 48668a374b..8f25342689 100644
--- a/desktop/src/features/communities/useCommunities.tsx
+++ b/desktop/src/features/communities/useCommunities.tsx
@@ -10,6 +10,7 @@ import type { ReactNode } from "react";
import type { Community } from "./types";
import {
+ clearActiveCommunityId,
clearCommunityStorage,
loadActiveCommunityId,
loadCommunities,
@@ -81,6 +82,8 @@ export type UseCommunitiesReturn = {
addCommunity: (community: Community) => string;
clearCommunities: () => void;
removeCommunity: (id: string) => void;
+ /** Clear the current selection without removing any saved community. */
+ showCommunityHome: () => void;
switchCommunity: (id: string) => void;
/** Force the active community to re-init (e.g. after a deep-link reconnect). */
reconnectCommunity: () => void;
@@ -122,7 +125,7 @@ function useCommunitiesInternal(): UseCommunitiesReturn {
communitiesRef.current = communities;
const activeCommunity = useMemo(
- () => communities.find((w) => w.id === activeId) ?? communities[0] ?? null,
+ () => communities.find((community) => community.id === activeId) ?? null,
[communities, activeId],
);
@@ -162,40 +165,34 @@ function useCommunitiesInternal(): UseCommunitiesReturn {
const removeCommunity = useCallback(
(id: string) => {
- // GC self-profile caches for the removed community's relay. Mirror the
- // updater guard (length > 1) so we only GC when removal will actually
- // proceed. Runs outside the updater โ updaters can execute twice under
- // React StrictMode.
- if (communities.length > 1) {
- const removed = communities.find((w) => w.id === id);
- if (removed) {
- removeSelfProfileCachesForRelay(removed.relayUrl);
- removeChannelSnapshotForRelay(removed.relayUrl);
- removeMessageSnapshotsForRelay(removed.relayUrl);
- clearSavedCommunitySnapshot(id);
- }
- }
+ const removed = communities.find((community) => community.id === id);
+ if (!removed) return;
+
+ removeSelfProfileCachesForRelay(removed.relayUrl);
+ removeChannelSnapshotForRelay(removed.relayUrl);
+ removeMessageSnapshotsForRelay(removed.relayUrl);
+ clearSavedCommunitySnapshot(id);
setCommunitiesState((prev) => {
- // Never allow removing the last community
- if (prev.length <= 1) {
- return prev;
- }
- const next = prev.filter((w) => w.id !== id);
+ const next = prev.filter((community) => community.id !== id);
saveCommunities(next);
-
- // If removing the active community, switch to first remaining
- if (activeId === id && next.length > 0) {
- saveActiveCommunityId(next[0].id);
- setActiveId(next[0].id);
- }
-
return next;
});
+
+ if (activeId === id) {
+ clearActiveCommunityId();
+ setActiveId(null);
+ }
},
[activeId, communities],
);
+ const showCommunityHome = useCallback(() => {
+ if (activeId === null) return;
+ clearActiveCommunityId();
+ setActiveId(null);
+ }, [activeId]);
+
const switchCommunity = useCallback(
(id: string) => {
if (id === activeId) return;
@@ -249,6 +246,7 @@ function useCommunitiesInternal(): UseCommunitiesReturn {
addCommunity,
clearCommunities,
removeCommunity,
+ showCommunityHome,
switchCommunity,
reconnectCommunity,
updateCommunity,
diff --git a/desktop/src/features/sidebar/ui/CommunityRail.tsx b/desktop/src/features/sidebar/ui/CommunityRail.tsx
index a234d6cf20..bde98ed74f 100644
--- a/desktop/src/features/sidebar/ui/CommunityRail.tsx
+++ b/desktop/src/features/sidebar/ui/CommunityRail.tsx
@@ -1,4 +1,4 @@
-import { CheckCheck, Link2, Plus, Settings2 } from "lucide-react";
+import { CheckCheck, Hexagon, Link2, Plus, Settings2 } from "lucide-react";
import * as React from "react";
import type { Community } from "@/features/communities/types";
@@ -27,6 +27,7 @@ type CommunityRailProps = {
communities: Community[];
activeCommunityId: string | null;
onSwitchCommunity: (id: string) => void;
+ onShowCommunityHome: () => void;
onAddCommunity: () => void;
onUpdateCommunity: (
id: string,
@@ -160,12 +161,15 @@ function CommunityButton({
* `useCommunityUnread`) and switches relays on click. Right-click opens a
* per-community menu: mark all as read, copy relay URL, community settings.
*
- * Hidden entirely with a single community โ a rail of one adds no value.
+ * The rail stays available whenever a community is active, even with a single
+ * saved community, because Community Home is the stable navigation target and
+ * null-selection state.
*/
export function CommunityRail({
communities,
activeCommunityId,
onSwitchCommunity,
+ onShowCommunityHome,
onAddCommunity,
onUpdateCommunity,
onRemoveCommunity,
@@ -179,7 +183,7 @@ export function CommunityRail({
const { markAllChannelsRead } = useAppShell();
const [editingCommunity, setEditingCommunity] =
React.useState(null);
- if (communities.length <= 1) {
+ if (communities.length === 0) {
return null;
}
@@ -211,6 +215,21 @@ export function CommunityRail({
)}
data-testid="community-rail"
>
+
+
+
+
+
+
+ Community home
+
+
{communities.map((community) => (
Add community
1}
+ canRemove={communities.length > 0}
onOpenChange={(open) => {
if (!open) setEditingCommunity(null);
}}
diff --git a/desktop/tests/e2e/community-home.spec.ts b/desktop/tests/e2e/community-home.spec.ts
new file mode 100644
index 0000000000..6527b14eff
--- /dev/null
+++ b/desktop/tests/e2e/community-home.spec.ts
@@ -0,0 +1,91 @@
+import { expect, test } from "@playwright/test";
+
+import { installMockBridge } from "../helpers/bridge";
+import { waitForAnimations } from "../helpers/animations";
+
+const COMMUNITIES = [
+ {
+ id: "garden",
+ name: "Garden Club",
+ relayUrl: "ws://localhost:3000",
+ addedAt: "2026-01-01T00:00:00.000Z",
+ },
+ {
+ id: "makers",
+ name: "Maker Hive",
+ relayUrl: "ws://localhost:3001",
+ addedAt: "2026-01-02T00:00:00.000Z",
+ },
+ {
+ id: "neighbors",
+ name: "Good Neighbors",
+ relayUrl: "ws://localhost:3002",
+ addedAt: "2026-01-03T00:00:00.000Z",
+ },
+];
+
+test.describe("community home", () => {
+ test.beforeEach(async ({ page }) => {
+ await page.addInitScript((communities) => {
+ window.localStorage.setItem(
+ "buzz-communities",
+ JSON.stringify(communities),
+ );
+ window.localStorage.removeItem("buzz-active-community-id");
+ }, COMMUNITIES);
+ await installMockBridge(page, undefined, { skipCommunitySeed: true });
+ });
+
+ test("renders saved communities when no community is selected", async ({
+ page,
+ }) => {
+ await page.goto("/");
+
+ await expect(page.getByTestId("community-home")).toBeVisible();
+ await expect(
+ page.getByRole("heading", { name: "Where do you want to go?" }),
+ ).toBeVisible();
+ await expect(
+ page.getByTestId("community-home-community-garden"),
+ ).toBeVisible();
+ await expect(
+ page.getByTestId("community-home-community-makers"),
+ ).toBeVisible();
+ await expect(page.getByTestId("community-home-join")).toBeVisible();
+ await expect(page.getByTestId("community-home-create")).toBeVisible();
+ });
+
+ test("selects a community from the home", async ({ page }) => {
+ await page.goto("/");
+ await page.getByTestId("community-home-community-makers").click();
+
+ await expect
+ .poll(() =>
+ page.evaluate(() => localStorage.getItem("buzz-active-community-id")),
+ )
+ .toBe("makers");
+ await expect(page.getByTestId("app-sidebar")).toBeVisible();
+ });
+
+ test("opens the join flow without making onboarding the home", async ({
+ page,
+ }) => {
+ await page.goto("/");
+ await page.getByTestId("community-home-join").click();
+
+ await expect(
+ page.getByRole("heading", { name: "Request access to community" }),
+ ).toBeVisible();
+ await page.getByTestId("welcome-setup-back").click();
+ await expect(page.getByTestId("community-home")).toBeVisible();
+ });
+
+ test("captures the responsive hex grid", async ({ page }) => {
+ await page.goto("/");
+ await expect(page.getByTestId("community-home")).toBeVisible();
+ await waitForAnimations(page);
+ await page.screenshot({
+ path: "test-results/community-home/community-home.png",
+ });
+ });
+});
From f98532f0a0b15d169c0f1c534cc89e68d892b302 Mon Sep 17 00:00:00 2001
From: Bradley Axen
Date: Sat, 18 Jul 2026 23:23:14 -0700
Subject: [PATCH 2/4] refine(desktop): interlocking honeycomb for Community
Home
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Design refinement pass on the Community Home surface (visual only โ
nullable selection, join/create/remove, and navigation are unchanged):
- Rebuild the hex grid as a true interlocking honeycomb: pointy-top
hexes that tessellate, ResizeObserver-driven sizing, and balanced
rows (e.g. 5 tiles render 3+2, not 4+1) instead of a staggered grid
with large gaps.
- Fix hierarchy: communities now read as elevated hero tiles; join and
create are lighter, additive cells rather than the heaviest elements.
- Square the icon tiles, strengthen tile contrast/borders, and use a
clip-path-safe drop shadow for real elevation.
- Polish typography, hover/focus affordances (lift + primary outline +
"Enter" hint), and add ease-out motion with reduced-motion fallbacks.
Co-Authored-By: Claude Opus 4.8 (1M context)
Signed-off-by: Bradley Axen
---
.../features/communities/ui/CommunityHome.tsx | 239 +++++++++++++-----
1 file changed, 178 insertions(+), 61 deletions(-)
diff --git a/desktop/src/features/communities/ui/CommunityHome.tsx b/desktop/src/features/communities/ui/CommunityHome.tsx
index 6583398326..25b6d801a0 100644
--- a/desktop/src/features/communities/ui/CommunityHome.tsx
+++ b/desktop/src/features/communities/ui/CommunityHome.tsx
@@ -5,8 +5,8 @@ import * as React from "react";
import { ThemeGrainientBackground } from "@/app/ThemeGrainientBackground";
import type { Community } from "@/features/communities/types";
import { useCommunityIcons } from "@/features/communities/useCommunityIcons";
+import { useElementWidth } from "@/shared/hooks/use-mobile";
import { getInitials } from "@/shared/lib/initials";
-import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import {
AlertDialog,
@@ -28,41 +28,98 @@ import {
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
const CREATE_COMMUNITY_URL = "https://app.builderlab.xyz/signup?returnTo=/buzz";
+
+// Pointy-top hexagon: flat vertical sides (so hexes in a row touch along their
+// edges) and points at top/bottom (so alternating rows nest into each other).
const HEX_CLIP_PATH =
- "polygon(25% 6.7%, 75% 6.7%, 100% 50%, 75% 93.3%, 25% 93.3%, 0 50%)";
+ "polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%)";
+
+// Pointy-top geometry, as ratios of the hex width W.
+// height = W / 0.866 (โ 1.1547ยทW)
+// row nest = rows overlap vertically by 1/4 of the hex height.
+const HEX_ASPECT = 0.866; // width / height
+const ROW_OVERLAP = 0.2887; // 0.25 ยท (1 / 0.866) โ negative margin between rows
+
+/** Pick a hex width + column cap that keeps regular hexagons at any width. */
+function honeycombLayout(containerWidth: number): {
+ hexWidth: number;
+ maxColumns: number;
+} {
+ if (containerWidth >= 860) return { hexWidth: 176, maxColumns: 4 };
+ if (containerWidth >= 640) return { hexWidth: 156, maxColumns: 4 };
+ if (containerWidth >= 440) return { hexWidth: 140, maxColumns: 3 };
+ return { hexWidth: 118, maxColumns: 2 };
+}
+
+/** Split items into honeycomb rows given how many fit per row. */
+function chunkRows(items: T[], perRow: number): T[][] {
+ const rows: T[][] = [];
+ for (let i = 0; i < items.length; i += perRow) {
+ rows.push(items.slice(i, i + perRow));
+ }
+ return rows;
+}
+
+/** Shared shell: a fixed-width regular hexagon with a hairline edge + fill. */
+function HexShell({
+ children,
+ edgeClassName,
+ fillClassName,
+ width,
+}: {
+ children: React.ReactNode;
+ edgeClassName: string;
+ fillClassName: string;
+ width: number;
+}) {
+ return (
+ <>
+
+
+ {children}
+
+ >
+ );
+}
function CommunityHex({
community,
iconUrl,
onOpen,
onRemove,
+ width,
}: {
community: Community;
iconUrl: string | null;
onOpen: () => void;
onRemove: () => void;
+ width: number;
}) {
return (
-
-
-
-
+
+
{iconUrl ? (
-
+
{community.name}
-
+
Enter
-
+
@@ -110,34 +167,36 @@ function ActionHex({
icon,
onClick,
testId,
+ width,
}: {
label: string;
detail: string;
icon: React.ReactNode;
onClick: () => void;
testId: string;
+ width: number;
}) {
return (
-
-
-
+
{icon}
- {label}
- {detail}
-
+ {label}
+
+ {detail}
+
+
);
}
@@ -158,6 +217,68 @@ export function CommunityHome({
const iconsByCommunity = useCommunityIcons(communities);
const [communityToRemove, setCommunityToRemove] =
React.useState(null);
+ const [gridRef, gridWidth] = useElementWidth();
+
+ const { hexWidth, maxColumns } = honeycombLayout(gridWidth);
+ // How many hexes fit per row, leaving room for the half-hex stagger.
+ const tileCount = communities.length + 2;
+ const measuredColumns =
+ gridWidth > 0
+ ? Math.floor((gridWidth - hexWidth / 2) / hexWidth)
+ : maxColumns;
+ const columnCap = Math.max(1, Math.min(maxColumns, measuredColumns));
+ // Balance the last row: e.g. 5 tiles in a 4-wide comb becomes 3+2, not 4+1.
+ const rowCount = Math.ceil(tileCount / columnCap);
+ const perRow = Math.max(1, Math.ceil(tileCount / rowCount));
+
+ // Build the honeycomb: community tiles first, then the additive actions.
+ // Each tile carries a stable key so rows can be keyed by content, not index.
+ const tiles: { key: string; node: React.ReactNode }[] = [
+ ...communities.map((community) => ({
+ key: community.id,
+ node: (
+ onOpenCommunity(community.id)}
+ onRemove={() => setCommunityToRemove(community)}
+ width={hexWidth}
+ />
+ ),
+ })),
+ {
+ key: "__join",
+ node: (
+ }
+ key="__join"
+ label="Join a community"
+ onClick={onJoinCommunity}
+ testId="community-home-join"
+ width={hexWidth}
+ />
+ ),
+ },
+ {
+ key: "__create",
+ node: (
+ โฆ}
+ key="__create"
+ label="Create a community"
+ onClick={() => void openUrl(CREATE_COMMUNITY_URL)}
+ testId="community-home-create"
+ width={hexWidth}
+ />
+ ),
+ },
+ ];
+
+ const rows = chunkRows(tiles, perRow);
+ const hasStagger = rows.length > 1;
return (
Buzz communities
-
+
Where do you want to go?
@@ -195,42 +316,38 @@ export function CommunityHome({
*:nth-child(even)]:translate-y-[45%] sm:[&>*:nth-child(even)]:translate-y-0",
- "sm:[&>*:nth-child(3n+2)]:translate-y-[45%] md:[&>*:nth-child(3n+2)]:translate-y-0",
- "md:[&>*:nth-child(even)]:translate-y-[45%]",
- )}
+ className="mt-14 flex flex-col items-center"
+ ref={gridRef}
>
- {communities.map((community) => (
- onOpenCommunity(community.id)}
- onRemove={() => setCommunityToRemove(community)}
- />
- ))}
- }
- label="Join a community"
- onClick={onJoinCommunity}
- testId="community-home-join"
- />
- โฆ}
- label="Create a community"
- onClick={() => void openUrl(CREATE_COMMUNITY_URL)}
- testId="community-home-create"
- />
+ {rows.map((row, rowIndex) => {
+ const isOffsetRow = rowIndex % 2 === 1;
+ // Nest each row up into the previous one and stagger alternating
+ // rows by half a hex so the pointed edges interlock.
+ const translateX = hasStagger
+ ? isOffsetRow
+ ? hexWidth / 4
+ : -hexWidth / 4
+ : 0;
+ return (
+ tile.key).join("|")}
+ style={{
+ marginTop:
+ rowIndex === 0 ? 0 : -(hexWidth / HEX_ASPECT) * ROW_OVERLAP,
+ transform: `translateX(${translateX}px)`,
+ }}
+ >
+ {row.map((tile) => tile.node)}
+
+ );
+ })}
{communities.length === 0 ? (
-
- This is your community home. It will stay available whenever you
- want a neutral place to join, create, or switch communities.
+
+ This is your community home. It stays available whenever you want a
+ neutral place to join, create, or switch communities.
) : null}
From f1fd8a11ab517391f30c7ba2a47acc45da68fc50 Mon Sep 17 00:00:00 2001
From: npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79
<1a7496365ae2df8adb1b932a1a6f68b9cb3950f3d179c8a451c32d194e8f7dc1@sprout-oss.stage.blox.sqprod.co>
Date: Sat, 18 Jul 2026 23:28:39 -0700
Subject: [PATCH 3/4] test(desktop): update Community Home back navigation
Co-authored-by: npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79 <1a7496365ae2df8adb1b932a1a6f68b9cb3950f3d179c8a451c32d194e8f7dc1@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79 <1a7496365ae2df8adb1b932a1a6f68b9cb3950f3d179c8a451c32d194e8f7dc1@sprout-oss.stage.blox.sqprod.co>
---
desktop/tests/e2e/onboarding-agent-defaults.spec.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts
index 7c2f3aabf9..1b62720375 100644
--- a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts
+++ b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts
@@ -1005,9 +1005,9 @@ test("community setup back button returns to agent defaults", async ({
await navigateToConfigPage(page);
await page.getByTestId("onboarding-finish").click();
- await expect(page.getByText("Join or create a community")).toBeVisible();
+ await expect(page.getByTestId("community-home")).toBeVisible();
- await page.getByTestId("welcome-setup-back").click();
+ await page.getByRole("button", { name: "Identity settings" }).click();
await expect(page.getByTestId("onboarding-page-config")).toBeVisible();
});
From 0d92504199785bd86059d8aba0ebfe4d4eb97608 Mon Sep 17 00:00:00 2001
From: Bradley Axen
Date: Sun, 19 Jul 2026 10:23:41 -0700
Subject: [PATCH 4/4] redesign(desktop): grow-your-own hex lattice for
Community Home
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Reframe Community Home from a grid of community tiles into a single
connected honeycomb you build out โ the model baxen asked for after
closing #2117.
- Real lattice: axial hex coordinates -> absolute pixel positions
(pointy-top), ResizeObserver fit-to-container. Gapless and fully
connected at any size, replacing the row-of-flexbox layout that read
as floating hexes.
- Center is you: a profile hex from useIdentityQuery (works with no
relay), defaulting to "You". Click opens identity settings.
- Peers on one board: saved communities and local managed agents
(useManagedAgentsQuery, offline) spiral outward as filled hexes.
- Hover to grow: idle shows only filled cells; pointer-enter/focus
reveals the connected frontier โ faint ghost cells plus three live
create tiles (New agent / New community / Connect community). Hidden
tiles are pointer-events-none so blank-area clicks can't fire them.
- Wire "New agent" via requestOpenCreateAgent and mount
RequestedAgentCreateDialogs here so it works before any relay.
Required radius is never capped, so no profile/community/agent/create
cell is ever silently dropped; MAX_RADIUS only bounds the decorative
ghost ring.
Co-Authored-By: Claude Opus 4.8 (1M context)
Signed-off-by: Bradley Axen
---
.../features/communities/ui/CommunityHome.tsx | 654 +++++++++++++-----
desktop/tests/e2e/community-home.spec.ts | 12 +-
2 files changed, 478 insertions(+), 188 deletions(-)
diff --git a/desktop/src/features/communities/ui/CommunityHome.tsx b/desktop/src/features/communities/ui/CommunityHome.tsx
index 25b6d801a0..5cb3d524b4 100644
--- a/desktop/src/features/communities/ui/CommunityHome.tsx
+++ b/desktop/src/features/communities/ui/CommunityHome.tsx
@@ -1,13 +1,25 @@
import { openUrl } from "@tauri-apps/plugin-opener";
-import { ArrowRight, Link2, Plus, Settings2, Trash2 } from "lucide-react";
+import {
+ ArrowRight,
+ Link2,
+ Plus,
+ Settings2,
+ Sparkles,
+ Trash2,
+} from "lucide-react";
import * as React from "react";
import { ThemeGrainientBackground } from "@/app/ThemeGrainientBackground";
+import { useManagedAgentsQuery } from "@/features/agents/hooks";
+import { requestOpenCreateAgent } from "@/features/agents/openCreateAgentEvent";
+import { RequestedAgentCreateDialogs } from "@/features/agents/ui/RequestedAgentCreateDialogs";
import type { Community } from "@/features/communities/types";
import { useCommunityIcons } from "@/features/communities/useCommunityIcons";
-import { useElementWidth } from "@/shared/hooks/use-mobile";
-import { getInitials } from "@/shared/lib/initials";
+import { useIdentityQuery } from "@/shared/api/hooks";
+import type { ManagedAgent } from "@/shared/api/types";
+import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
+import { UserAvatar } from "@/shared/ui/UserAvatar";
import {
AlertDialog,
AlertDialogAction,
@@ -29,59 +41,153 @@ import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
const CREATE_COMMUNITY_URL = "https://app.builderlab.xyz/signup?returnTo=/buzz";
-// Pointy-top hexagon: flat vertical sides (so hexes in a row touch along their
-// edges) and points at top/bottom (so alternating rows nest into each other).
+// Pointy-top hexagon: flat vertical sides (so hexes in a column touch along
+// their edges) and points at top/bottom (so alternating rows nest together).
const HEX_CLIP_PATH =
"polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%)";
-// Pointy-top geometry, as ratios of the hex width W.
-// height = W / 0.866 (โ 1.1547ยทW)
-// row nest = rows overlap vertically by 1/4 of the hex height.
-const HEX_ASPECT = 0.866; // width / height
-const ROW_OVERLAP = 0.2887; // 0.25 ยท (1 / 0.866) โ negative margin between rows
-
-/** Pick a hex width + column cap that keeps regular hexagons at any width. */
-function honeycombLayout(containerWidth: number): {
- hexWidth: number;
- maxColumns: number;
-} {
- if (containerWidth >= 860) return { hexWidth: 176, maxColumns: 4 };
- if (containerWidth >= 640) return { hexWidth: 156, maxColumns: 4 };
- if (containerWidth >= 440) return { hexWidth: 140, maxColumns: 3 };
- return { hexWidth: 118, maxColumns: 2 };
+// Pointy-top geometry. Width W is the flat-to-flat measure; height = W / ASPECT.
+// Neighboring cells sit exactly ASPECTยทW apart vertically and W apart
+// horizontally, so the lattice interlocks with no gaps at any scale.
+const HEX_ASPECT = 0.866; // width / height = โ3 / 2
+const HALF_HEIGHT_UNITS = 0.5 / HEX_ASPECT; // half hex height when W = 1
+
+const MIN_HEX_WIDTH = 96;
+const MAX_HEX_WIDTH = 172;
+const MAX_RADIUS = 4;
+
+// Axial neighbor directions for a pointy-top hex, walked in order to trace a
+// ring. Index 4 ([-1, 1]) is the ring's start offset from center.
+const AXIAL_DIRECTIONS: ReadonlyArray = [
+ [1, 0],
+ [1, -1],
+ [0, -1],
+ [-1, 0],
+ [-1, 1],
+ [0, 1],
+];
+
+/** Total cells in a filled hexagon of the given ring radius. */
+function spiralCount(radius: number): number {
+ return 1 + 3 * radius * (radius + 1);
+}
+
+/** Axial coordinates from the center outward, ring by ring, in spiral order. */
+function hexSpiral(radius: number): [number, number][] {
+ const cells: [number, number][] = [[0, 0]];
+ for (let ring = 1; ring <= radius; ring++) {
+ // Start one ring out along direction 4, then walk each of the 6 edges.
+ let q = -ring;
+ let r = ring;
+ for (let side = 0; side < 6; side++) {
+ const [dq, dr] = AXIAL_DIRECTIONS[side];
+ for (let step = 0; step < ring; step++) {
+ cells.push([q, r]);
+ q += dq;
+ r += dr;
+ }
+ }
+ }
+ return cells;
+}
+
+/** Axial (q, r) โ unit-space center (before scaling by hex width W). */
+function axialToUnitCenter(q: number, r: number): { cx: number; cy: number } {
+ return { cx: q + r / 2, cy: HEX_ASPECT * r };
}
-/** Split items into honeycomb rows given how many fit per row. */
-function chunkRows(items: T[], perRow: number): T[][] {
- const rows: T[][] = [];
- for (let i = 0; i < items.length; i += perRow) {
- rows.push(items.slice(i, i + perRow));
+type PlacedCell = {
+ q: number;
+ r: number;
+ cx: number;
+ cy: number;
+};
+
+type LatticeExtent = {
+ cells: PlacedCell[];
+ minLeft: number;
+ minTop: number;
+ unitWidth: number;
+ unitHeight: number;
+};
+
+/** Precompute cell centers + the unit bounding box for a given radius. */
+function computeExtent(radius: number): LatticeExtent {
+ const raw = hexSpiral(radius);
+ const cells: PlacedCell[] = raw.map(([q, r]) => {
+ const { cx, cy } = axialToUnitCenter(q, r);
+ return { q, r, cx, cy };
+ });
+ let minLeft = Infinity;
+ let maxRight = -Infinity;
+ let minTop = Infinity;
+ let maxBottom = -Infinity;
+ for (const cell of cells) {
+ minLeft = Math.min(minLeft, cell.cx - 0.5);
+ maxRight = Math.max(maxRight, cell.cx + 0.5);
+ minTop = Math.min(minTop, cell.cy - HALF_HEIGHT_UNITS);
+ maxBottom = Math.max(maxBottom, cell.cy + HALF_HEIGHT_UNITS);
}
- return rows;
+ return {
+ cells,
+ minLeft,
+ minTop,
+ unitWidth: maxRight - minLeft,
+ unitHeight: maxBottom - minTop,
+ };
}
-/** Shared shell: a fixed-width regular hexagon with a hairline edge + fill. */
-function HexShell({
+/** ResizeObserver-backed width + height for fit-to-container scaling. */
+function useElementSize(): [
+ React.RefObject,
+ { width: number; height: number },
+] {
+ const ref = React.useRef(null);
+ const [size, setSize] = React.useState({ width: 0, height: 0 });
+
+ React.useEffect(() => {
+ const element = ref.current;
+ if (!element) return;
+ const update = () => {
+ const rect = element.getBoundingClientRect();
+ setSize({ width: rect.width, height: rect.height });
+ };
+ update();
+ if (typeof ResizeObserver === "undefined") {
+ window.addEventListener("resize", update);
+ return () => window.removeEventListener("resize", update);
+ }
+ const observer = new ResizeObserver(update);
+ observer.observe(element);
+ return () => observer.disconnect();
+ }, []);
+
+ return [ref, size];
+}
+
+/** Shared hex frame: a hairline edge behind a clipped, filled interior. */
+function HexFrame({
children,
edgeClassName,
fillClassName,
- width,
}: {
children: React.ReactNode;
edgeClassName: string;
fillClassName: string;
- width: number;
}) {
return (
<>
{children}
@@ -89,36 +195,77 @@ function HexShell({
);
}
+/** The central "you" cell โ always present, works with no relay/profile. */
+function ProfileHex({
+ displayName,
+ avatarUrl,
+ onOpenSettings,
+}: {
+ displayName: string;
+ avatarUrl: string | null;
+ onOpenSettings: () => void;
+}) {
+ return (
+
+
+
+
+
+
+
+ {displayName}
+
+
+ You
+
+
+
+ );
+}
+
+/** A saved community / relay you belong to. */
function CommunityHex({
community,
iconUrl,
onOpen,
onRemove,
- width,
}: {
community: Community;
iconUrl: string | null;
onOpen: () => void;
onRemove: () => void;
- width: number;
}) {
return (
-
-
+
{iconUrl ? (
) : (
- getInitials(community.name) || "๐"
+ (community.name.trim()[0] ?? "๐").toUpperCase()
)}
-
+
{community.name}
Enter
-
+
@@ -161,46 +308,119 @@ function CommunityHex({
);
}
-function ActionHex({
- label,
+/** One of your local agents โ a peer on the same board as communities. */
+function AgentHex({ agent }: { agent: ManagedAgent }) {
+ const isRunning = agent.status === "running";
+ return (
+
+
+
+
+
+
+
+ {agent.name}
+
+
+ Agent
+
+
+
+ );
+}
+
+/** A frontier "grow your space" action, revealed on hover. */
+function CreateHex({
detail,
icon,
+ label,
onClick,
+ revealed,
testId,
- width,
}: {
- label: string;
detail: string;
icon: React.ReactNode;
+ label: string;
onClick: () => void;
+ revealed: boolean;
testId: string;
- width: number;
}) {
return (
-
{icon}
- {label}
-
+ {label}
+
{detail}
-
+
);
}
+/** A prefilled-but-empty lattice cell: only a faint outline, and only on hover.
+ * Makes the grid read as one connected honeycomb rather than floating hexes. */
+function GhostHex({ revealed }: { revealed: boolean }) {
+ return (
+
+
+
+
+ );
+}
+
+type CreateAction = {
+ detail: string;
+ icon: React.ReactNode;
+ label: string;
+ onClick: () => void;
+ testId: string;
+};
+
export function CommunityHome({
communities,
onOpenCommunity,
@@ -215,142 +435,208 @@ export function CommunityHome({
onBackToMachineConfig: () => void;
}) {
const iconsByCommunity = useCommunityIcons(communities);
+ const identityQuery = useIdentityQuery();
+ const managedAgentsQuery = useManagedAgentsQuery();
+ const agents = managedAgentsQuery.data ?? [];
+
const [communityToRemove, setCommunityToRemove] =
React.useState(null);
- const [gridRef, gridWidth] = useElementWidth();
-
- const { hexWidth, maxColumns } = honeycombLayout(gridWidth);
- // How many hexes fit per row, leaving room for the half-hex stagger.
- const tileCount = communities.length + 2;
- const measuredColumns =
- gridWidth > 0
- ? Math.floor((gridWidth - hexWidth / 2) / hexWidth)
- : maxColumns;
- const columnCap = Math.max(1, Math.min(maxColumns, measuredColumns));
- // Balance the last row: e.g. 5 tiles in a 4-wide comb becomes 3+2, not 4+1.
- const rowCount = Math.ceil(tileCount / columnCap);
- const perRow = Math.max(1, Math.ceil(tileCount / rowCount));
-
- // Build the honeycomb: community tiles first, then the additive actions.
- // Each tile carries a stable key so rows can be keyed by content, not index.
- const tiles: { key: string; node: React.ReactNode }[] = [
- ...communities.map((community) => ({
- key: community.id,
- node: (
- onOpenCommunity(community.id)}
- onRemove={() => setCommunityToRemove(community)}
- width={hexWidth}
- />
- ),
- })),
- {
- key: "__join",
- node: (
- }
- key="__join"
- label="Join a community"
- onClick={onJoinCommunity}
- testId="community-home-join"
- width={hexWidth}
- />
- ),
- },
- {
- key: "__create",
- node: (
- โฆ}
- key="__create"
- label="Create a community"
- onClick={() => void openUrl(CREATE_COMMUNITY_URL)}
- testId="community-home-create"
- width={hexWidth}
- />
- ),
- },
- ];
-
- const rows = chunkRows(tiles, perRow);
- const hasStagger = rows.length > 1;
+ const [areaActive, setAreaActive] = React.useState(false);
+ const [gridRef, gridSize] = useElementSize();
+
+ const profileName = identityQuery.data?.displayName?.trim() || "You";
+
+ // Order of the create-frontier actions, filling the first empty cells.
+ const createActions = React.useMemo(
+ () => [
+ {
+ detail: "Spin up a helper",
+ icon: ,
+ label: "New agent",
+ onClick: () => requestOpenCreateAgent(),
+ testId: "community-home-create-agent",
+ },
+ {
+ detail: "Start something new",
+ icon: ,
+ label: "New community",
+ onClick: () => void openUrl(CREATE_COMMUNITY_URL),
+ testId: "community-home-create",
+ },
+ {
+ detail: "Use a relay URL",
+ icon: ,
+ label: "Connect community",
+ onClick: onJoinCommunity,
+ testId: "community-home-join",
+ },
+ ],
+ [onJoinCommunity],
+ );
+
+ // How many cells must be filled: you + your communities + your agents.
+ const filledCount = 1 + communities.length + agents.length;
+
+ // Grow the lattice one ring past the filled cluster so there is always a
+ // connected frontier to reveal. The required radius is never capped โ every
+ // profile/community/agent/create cell must have a slot, so nothing is ever
+ // silently dropped. MAX_RADIUS only bounds the extra decorative ghost ring.
+ const extent = React.useMemo(() => {
+ let needRing = 0;
+ while (spiralCount(needRing) < filledCount + createActions.length) {
+ needRing++;
+ }
+ const radius = needRing < MAX_RADIUS ? needRing + 1 : needRing;
+ return computeExtent(radius);
+ }, [filledCount, createActions.length]);
+
+ // Fit the whole lattice inside the measured area (width and height), then
+ // clamp to a comfortable hex size.
+ const hexWidth = React.useMemo(() => {
+ if (gridSize.width === 0) return MIN_HEX_WIDTH;
+ const byWidth = (gridSize.width * 0.94) / extent.unitWidth;
+ const byHeight =
+ gridSize.height > 0
+ ? (gridSize.height * 0.94) / extent.unitHeight
+ : byWidth;
+ return Math.max(
+ MIN_HEX_WIDTH,
+ Math.min(MAX_HEX_WIDTH, Math.min(byWidth, byHeight)),
+ );
+ }, [gridSize.width, gridSize.height, extent]);
+
+ const hexHeight = hexWidth / HEX_ASPECT;
+ const boardWidth = extent.unitWidth * hexWidth;
+ const boardHeight = extent.unitHeight * hexWidth;
+
+ // Assign each spiral cell a role: center = profile, then communities, then
+ // agents, then the create actions, then decorative ghost cells.
+ const rendered = extent.cells.map((cell, index) => {
+ const left = (cell.cx - 0.5 - extent.minLeft) * hexWidth;
+ const top = (cell.cy - HALF_HEIGHT_UNITS - extent.minTop) * hexWidth;
+ const style: React.CSSProperties = {
+ left,
+ top,
+ width: hexWidth,
+ height: hexHeight,
+ };
+ const key = `${cell.q},${cell.r}`;
+
+ if (index === 0) {
+ return (
+
+ );
+ }
+
+ const communityIndex = index - 1;
+ if (communityIndex < communities.length) {
+ const community = communities[communityIndex];
+ return (
+
+ onOpenCommunity(community.id)}
+ onRemove={() => setCommunityToRemove(community)}
+ />
+
+ );
+ }
+
+ const agentIndex = communityIndex - communities.length;
+ if (agentIndex < agents.length) {
+ const agent = agents[agentIndex];
+ return (
+
+ );
+ }
+
+ const createIndex = agentIndex - agents.length;
+ if (createIndex < createActions.length) {
+ const action = createActions[createIndex];
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+ });
return (
-
-
+
+ {/* A quiet, one-time nudge toward the hidden frontier โ fades away the
+ moment you start exploring the grid. */}
+
+ Hover to build your space
+
+
+
+ {/* The in-app create-agent flow normally lives in the connected shell;
+ mount it here so "New agent" works before any relay is connected. */}
+
+
{
if (!open) setCommunityToRemove(null);
diff --git a/desktop/tests/e2e/community-home.spec.ts b/desktop/tests/e2e/community-home.spec.ts
index 6527b14eff..a65181c606 100644
--- a/desktop/tests/e2e/community-home.spec.ts
+++ b/desktop/tests/e2e/community-home.spec.ts
@@ -36,21 +36,22 @@ test.describe("community home", () => {
await installMockBridge(page, undefined, { skipCommunitySeed: true });
});
- test("renders saved communities when no community is selected", async ({
+ test("renders the personal lattice with communities and create tiles", async ({
page,
}) => {
await page.goto("/");
await expect(page.getByTestId("community-home")).toBeVisible();
- await expect(
- page.getByRole("heading", { name: "Where do you want to go?" }),
- ).toBeVisible();
+ // The central "you" cell anchors the honeycomb, relay or not.
+ await expect(page.getByTestId("community-home-profile")).toBeVisible();
await expect(
page.getByTestId("community-home-community-garden"),
).toBeVisible();
await expect(
page.getByTestId("community-home-community-makers"),
).toBeVisible();
+ // Create-frontier tiles: agent, community, and connect all live on the grid.
+ await expect(page.getByTestId("community-home-create-agent")).toBeVisible();
await expect(page.getByTestId("community-home-join")).toBeVisible();
await expect(page.getByTestId("community-home-create")).toBeVisible();
});
@@ -71,6 +72,9 @@ test.describe("community home", () => {
page,
}) => {
await page.goto("/");
+ // The create-frontier tiles reveal (and become interactive) once the
+ // pointer is over the grid โ hover the profile cell before clicking.
+ await page.getByTestId("community-home-profile").hover();
await page.getByTestId("community-home-join").click();
await expect(