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 ( + + + + + + + + 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 ( + + ); +} + +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 ( +
+ + +
+
+
+
+

+ Buzz communities +

+

+ Where do you want to go? +

+

+ Your communities, all in one place. Choose one to enter or make a + new space for your people. +

+
+ +
+ +
*: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. + + + + + + + + + + + + +
+ ); +} 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({ + + 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 ( + <> + } + 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 ( <>