mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
redesign(desktop): grow-your-own hex lattice for Community Home
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) <noreply@anthropic.com> Signed-off-by: Bradley Axen <baxen@squareup.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
f1fd8a11ab
commit
0d92504199
@@ -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
|
||||
// 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
|
||||
|
||||
/** 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 };
|
||||
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<readonly [number, number]> = [
|
||||
[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);
|
||||
}
|
||||
|
||||
/** Split items into honeycomb rows given how many fit per row. */
|
||||
function chunkRows<T>(items: T[], perRow: number): T[][] {
|
||||
const rows: T[][] = [];
|
||||
for (let i = 0; i < items.length; i += perRow) {
|
||||
rows.push(items.slice(i, i + perRow));
|
||||
/** 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 rows;
|
||||
return cells;
|
||||
}
|
||||
|
||||
/** Shared shell: a fixed-width regular hexagon with a hairline edge + fill. */
|
||||
function HexShell({
|
||||
/** 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 };
|
||||
}
|
||||
|
||||
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 {
|
||||
cells,
|
||||
minLeft,
|
||||
minTop,
|
||||
unitWidth: maxRight - minLeft,
|
||||
unitHeight: maxBottom - minTop,
|
||||
};
|
||||
}
|
||||
|
||||
/** ResizeObserver-backed width + height for fit-to-container scaling. */
|
||||
function useElementSize<T extends HTMLElement>(): [
|
||||
React.RefObject<T | null>,
|
||||
{ width: number; height: number },
|
||||
] {
|
||||
const ref = React.useRef<T>(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 (
|
||||
<>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={edgeClassName}
|
||||
className={cn("absolute inset-0", edgeClassName)}
|
||||
style={{ clipPath: HEX_CLIP_PATH }}
|
||||
/>
|
||||
<span
|
||||
className={fillClassName}
|
||||
style={{ clipPath: HEX_CLIP_PATH, padding: `0 ${width * 0.1}px` }}
|
||||
className={cn(
|
||||
"absolute inset-[1.5px] flex flex-col items-center justify-center overflow-hidden px-[12%] text-center",
|
||||
fillClassName,
|
||||
)}
|
||||
style={{ clipPath: HEX_CLIP_PATH }}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
@@ -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 (
|
||||
<button
|
||||
aria-label="Your profile and identity settings"
|
||||
className="group absolute inset-0 outline-hidden drop-shadow-[0_10px_24px_rgba(15,18,25,0.22)] transition-transform duration-300 ease-out hover:-translate-y-1 focus-visible:-translate-y-1 motion-reduce:transition-none motion-reduce:hover:translate-y-0"
|
||||
data-testid="community-home-profile"
|
||||
onClick={onOpenSettings}
|
||||
type="button"
|
||||
>
|
||||
<HexFrame
|
||||
edgeClassName="bg-primary/60 transition-colors duration-300 ease-out group-hover:bg-primary group-focus-visible:bg-primary"
|
||||
fillClassName="bg-card text-card-foreground"
|
||||
>
|
||||
<span className="absolute inset-0 bg-[radial-gradient(circle_at_50%_28%,hsl(var(--primary)/0.2),transparent_66%)]" />
|
||||
<span className="relative flex aspect-square w-[36%] items-center justify-center transition-transform duration-300 ease-out group-hover:scale-105 motion-reduce:transition-none motion-reduce:group-hover:scale-100">
|
||||
<UserAvatar
|
||||
accent
|
||||
avatarUrl={avatarUrl}
|
||||
className="h-full w-full text-base ring-2 ring-primary/40"
|
||||
displayName={displayName}
|
||||
size="md"
|
||||
testId="community-home-profile-avatar"
|
||||
/>
|
||||
</span>
|
||||
<span className="relative mt-2 max-w-full truncate text-sm font-semibold">
|
||||
{displayName}
|
||||
</span>
|
||||
<span className="relative mt-0.5 text-2xs font-medium uppercase tracking-[0.18em] text-primary/80">
|
||||
You
|
||||
</span>
|
||||
</HexFrame>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<ContextMenu modal={false}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<button
|
||||
aria-label={`Open ${community.name}`}
|
||||
className="group relative aspect-[0.866] shrink-0 outline-hidden drop-shadow-[0_8px_16px_rgba(15,18,25,0.16)] transition-transform duration-300 ease-out hover:-translate-y-1.5 focus-visible:-translate-y-1.5 motion-reduce:transition-none motion-reduce:hover:translate-y-0"
|
||||
className="group absolute inset-0 outline-hidden drop-shadow-[0_8px_16px_rgba(15,18,25,0.16)] transition-transform duration-300 ease-out hover:-translate-y-1.5 focus-visible:-translate-y-1.5 motion-reduce:transition-none motion-reduce:hover:translate-y-0"
|
||||
data-testid={`community-home-community-${community.id}`}
|
||||
onClick={onOpen}
|
||||
style={{ width }}
|
||||
type="button"
|
||||
>
|
||||
<HexShell
|
||||
edgeClassName="absolute inset-0 bg-foreground/25 transition-colors duration-300 ease-out group-hover:bg-primary/70 group-focus-visible:bg-primary/80"
|
||||
fillClassName="absolute inset-[1.5px] flex flex-col items-center justify-center overflow-hidden bg-card text-card-foreground transition-colors duration-300 ease-out group-hover:bg-card"
|
||||
width={width}
|
||||
<HexFrame
|
||||
edgeClassName="bg-foreground/25 transition-colors duration-300 ease-out group-hover:bg-primary/70 group-focus-visible:bg-primary/80"
|
||||
fillClassName="bg-card text-card-foreground transition-colors duration-300 ease-out"
|
||||
>
|
||||
<span className="absolute inset-0 bg-[radial-gradient(circle_at_38%_20%,hsl(var(--primary)/0.14),transparent_62%)]" />
|
||||
<span className="absolute inset-0 bg-[radial-gradient(circle_at_38%_20%,hsl(var(--primary)/0.12),transparent_62%)]" />
|
||||
<span className="relative flex aspect-square w-[34%] items-center justify-center overflow-hidden rounded-[28%] bg-primary/15 text-lg font-semibold text-primary ring-1 ring-primary/20 transition-transform duration-300 ease-out group-hover:scale-105 motion-reduce:transition-none motion-reduce:group-hover:scale-100">
|
||||
{iconUrl ? (
|
||||
<img
|
||||
@@ -128,16 +275,16 @@ function CommunityHex({
|
||||
src={iconUrl}
|
||||
/>
|
||||
) : (
|
||||
getInitials(community.name) || "🐝"
|
||||
(community.name.trim()[0] ?? "🐝").toUpperCase()
|
||||
)}
|
||||
</span>
|
||||
<span className="relative mt-2.5 max-w-full truncate text-sm font-semibold">
|
||||
<span className="relative mt-2 max-w-full truncate text-sm font-semibold">
|
||||
{community.name}
|
||||
</span>
|
||||
<span className="relative mt-1 flex items-center gap-1 text-2xs font-medium text-muted-foreground opacity-0 transition-opacity duration-200 ease-out group-hover:opacity-100 group-focus-visible:opacity-100">
|
||||
Enter <ArrowRight className="h-3 w-3" />
|
||||
</span>
|
||||
</HexShell>
|
||||
</HexFrame>
|
||||
</button>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
@@ -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 (
|
||||
<div
|
||||
className="absolute inset-0 drop-shadow-[0_8px_16px_rgba(15,18,25,0.14)]"
|
||||
data-testid={`community-home-agent-${agent.pubkey}`}
|
||||
>
|
||||
<HexFrame
|
||||
edgeClassName="bg-foreground/20"
|
||||
fillClassName="bg-background/80 text-foreground backdrop-blur-xl"
|
||||
>
|
||||
<span className="relative flex aspect-square w-[32%] items-center justify-center">
|
||||
<UserAvatar
|
||||
avatarUrl={agent.avatarUrl}
|
||||
className="h-full w-full text-sm"
|
||||
displayName={agent.name}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full ring-2 ring-background",
|
||||
isRunning ? "bg-emerald-500" : "bg-muted-foreground/50",
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
<span className="relative mt-2 max-w-full truncate text-sm font-semibold">
|
||||
{agent.name}
|
||||
</span>
|
||||
<span className="relative mt-0.5 text-2xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
|
||||
Agent
|
||||
</span>
|
||||
</HexFrame>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<button
|
||||
className="group relative aspect-[0.866] shrink-0 outline-hidden transition-transform duration-300 ease-out hover:-translate-y-1.5 focus-visible:-translate-y-1.5 motion-reduce:transition-none motion-reduce:hover:translate-y-0"
|
||||
className={cn(
|
||||
"group absolute inset-0 outline-hidden transition-[opacity,transform] duration-300 ease-out hover:-translate-y-1.5 focus-visible:-translate-y-1.5 motion-reduce:transition-opacity motion-reduce:hover:translate-y-0",
|
||||
// Hidden tiles are non-interactive so a click in the blank area can't
|
||||
// silently trigger a create action; focus re-enables them for keyboard.
|
||||
revealed
|
||||
? "opacity-100"
|
||||
: "pointer-events-none opacity-0 focus-visible:pointer-events-auto focus-visible:opacity-100",
|
||||
)}
|
||||
data-testid={testId}
|
||||
onClick={onClick}
|
||||
style={{ width }}
|
||||
type="button"
|
||||
>
|
||||
<HexShell
|
||||
edgeClassName="absolute inset-0 bg-primary/30 transition-colors duration-300 ease-out group-hover:bg-primary/50 group-focus-visible:bg-primary/60"
|
||||
fillClassName="absolute inset-[1.5px] flex flex-col items-center justify-center bg-background/70 text-foreground backdrop-blur-xl transition-colors duration-300 ease-out group-hover:bg-primary/[0.06]"
|
||||
width={width}
|
||||
<HexFrame
|
||||
edgeClassName="bg-primary/25 transition-colors duration-300 ease-out group-hover:bg-primary/60 group-focus-visible:bg-primary/70"
|
||||
fillClassName="border border-dashed border-primary/25 bg-background/60 text-foreground backdrop-blur-md transition-colors duration-300 ease-out group-hover:bg-primary/[0.07]"
|
||||
>
|
||||
<span className="flex aspect-square w-[30%] items-center justify-center rounded-full bg-primary/15 text-primary ring-1 ring-primary/30 transition-all duration-300 ease-out group-hover:scale-105 group-hover:bg-primary group-hover:text-primary-foreground motion-reduce:transition-none motion-reduce:group-hover:scale-100">
|
||||
{icon}
|
||||
</span>
|
||||
<span className="mt-2.5 text-sm font-semibold">{label}</span>
|
||||
<span className="mt-1 text-2xs font-medium text-muted-foreground">
|
||||
<span className="mt-2 text-sm font-semibold">{label}</span>
|
||||
<span className="mt-0.5 text-2xs font-medium text-muted-foreground">
|
||||
{detail}
|
||||
</span>
|
||||
</HexShell>
|
||||
</HexFrame>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 transition-opacity duration-500 ease-out motion-reduce:transition-none",
|
||||
revealed ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="absolute inset-[1.5px] bg-foreground/[0.06]"
|
||||
style={{ clipPath: HEX_CLIP_PATH }}
|
||||
/>
|
||||
<span
|
||||
className="absolute inset-[3px] bg-background/40"
|
||||
style={{ clipPath: HEX_CLIP_PATH }}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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<Community | null>(null);
|
||||
const [gridRef, gridWidth] = useElementWidth<HTMLDivElement>();
|
||||
const [areaActive, setAreaActive] = React.useState(false);
|
||||
const [gridRef, gridSize] = useElementSize<HTMLDivElement>();
|
||||
|
||||
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));
|
||||
const profileName = identityQuery.data?.displayName?.trim() || "You";
|
||||
|
||||
// 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: (
|
||||
<CommunityHex
|
||||
community={community}
|
||||
iconUrl={iconsByCommunity[community.id] ?? null}
|
||||
key={community.id}
|
||||
onOpen={() => onOpenCommunity(community.id)}
|
||||
onRemove={() => setCommunityToRemove(community)}
|
||||
width={hexWidth}
|
||||
/>
|
||||
),
|
||||
})),
|
||||
{
|
||||
key: "__join",
|
||||
node: (
|
||||
<ActionHex
|
||||
detail="Use a relay URL"
|
||||
icon={<Plus className="h-6 w-6" />}
|
||||
key="__join"
|
||||
label="Join a community"
|
||||
onClick={onJoinCommunity}
|
||||
testId="community-home-join"
|
||||
width={hexWidth}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "__create",
|
||||
node: (
|
||||
<ActionHex
|
||||
detail="Start something new"
|
||||
icon={<span className="text-xl leading-none">✦</span>}
|
||||
key="__create"
|
||||
label="Create a community"
|
||||
onClick={() => void openUrl(CREATE_COMMUNITY_URL)}
|
||||
testId="community-home-create"
|
||||
width={hexWidth}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
// Order of the create-frontier actions, filling the first empty cells.
|
||||
const createActions = React.useMemo<CreateAction[]>(
|
||||
() => [
|
||||
{
|
||||
detail: "Spin up a helper",
|
||||
icon: <Sparkles className="h-5 w-5" />,
|
||||
label: "New agent",
|
||||
onClick: () => requestOpenCreateAgent(),
|
||||
testId: "community-home-create-agent",
|
||||
},
|
||||
{
|
||||
detail: "Start something new",
|
||||
icon: <Plus className="h-6 w-6" />,
|
||||
label: "New community",
|
||||
onClick: () => void openUrl(CREATE_COMMUNITY_URL),
|
||||
testId: "community-home-create",
|
||||
},
|
||||
{
|
||||
detail: "Use a relay URL",
|
||||
icon: <Link2 className="h-5 w-5" />,
|
||||
label: "Connect community",
|
||||
onClick: onJoinCommunity,
|
||||
testId: "community-home-join",
|
||||
},
|
||||
],
|
||||
[onJoinCommunity],
|
||||
);
|
||||
|
||||
const rows = chunkRows(tiles, perRow);
|
||||
const hasStagger = rows.length > 1;
|
||||
// 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 (
|
||||
<div className="absolute" key={key} style={style}>
|
||||
<ProfileHex
|
||||
avatarUrl={null}
|
||||
displayName={profileName}
|
||||
onOpenSettings={onBackToMachineConfig}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const communityIndex = index - 1;
|
||||
if (communityIndex < communities.length) {
|
||||
const community = communities[communityIndex];
|
||||
return (
|
||||
<div className="absolute" key={community.id} style={style}>
|
||||
<CommunityHex
|
||||
community={community}
|
||||
iconUrl={iconsByCommunity[community.id] ?? null}
|
||||
onOpen={() => onOpenCommunity(community.id)}
|
||||
onRemove={() => setCommunityToRemove(community)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const agentIndex = communityIndex - communities.length;
|
||||
if (agentIndex < agents.length) {
|
||||
const agent = agents[agentIndex];
|
||||
return (
|
||||
<div className="absolute" key={agent.pubkey} style={style}>
|
||||
<AgentHex agent={agent} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const createIndex = agentIndex - agents.length;
|
||||
if (createIndex < createActions.length) {
|
||||
const action = createActions[createIndex];
|
||||
return (
|
||||
<div className="absolute" key={action.testId} style={style}>
|
||||
<CreateHex
|
||||
detail={action.detail}
|
||||
icon={action.icon}
|
||||
label={action.label}
|
||||
onClick={action.onClick}
|
||||
revealed={areaActive}
|
||||
testId={action.testId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute" key={key} style={style}>
|
||||
<GhostHex revealed={areaActive} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<main
|
||||
className="relative min-h-dvh overflow-y-auto bg-background text-foreground"
|
||||
className="relative min-h-dvh overflow-hidden bg-background text-foreground"
|
||||
data-testid="community-home"
|
||||
>
|
||||
<StartupWindowDragRegion />
|
||||
<ThemeGrainientBackground />
|
||||
<div className="pointer-events-none absolute inset-0 bg-background/35 backdrop-blur-3xl" />
|
||||
<div className="relative mx-auto flex min-h-dvh w-full max-w-6xl flex-col px-8 pb-16 pt-20 sm:px-12 lg:px-16">
|
||||
<header className="flex items-start justify-between gap-8">
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-[0.22em] text-muted-foreground">
|
||||
Buzz communities
|
||||
</p>
|
||||
<h1 className="mt-3 text-balance text-title font-normal">
|
||||
Where do you want to go?
|
||||
</h1>
|
||||
<p className="mt-3 max-w-xl text-sm leading-6 text-muted-foreground">
|
||||
Your communities, all in one place. Choose one to enter or make a
|
||||
new space for your people.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
aria-label="Identity settings"
|
||||
className="mt-1 rounded-full bg-background/50 backdrop-blur-md"
|
||||
onClick={onBackToMachineConfig}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
|
||||
<Button
|
||||
aria-label="Identity settings"
|
||||
className="absolute right-6 top-6 z-10 rounded-full bg-background/50 backdrop-blur-md"
|
||||
onClick={onBackToMachineConfig}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<h1 className="sr-only">Your Buzz space</h1>
|
||||
|
||||
<section
|
||||
aria-label="Your Buzz space"
|
||||
className="relative flex min-h-dvh w-full items-center justify-center px-8 py-16"
|
||||
onFocus={() => setAreaActive(true)}
|
||||
onPointerEnter={() => setAreaActive(true)}
|
||||
onPointerLeave={() => setAreaActive(false)}
|
||||
ref={gridRef}
|
||||
>
|
||||
{gridSize.width > 0 ? (
|
||||
<div
|
||||
className="relative"
|
||||
style={{ height: boardHeight, width: boardWidth }}
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<section
|
||||
aria-label="Communities"
|
||||
className="mt-14 flex flex-col items-center"
|
||||
ref={gridRef}
|
||||
>
|
||||
{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 (
|
||||
<div
|
||||
className="flex"
|
||||
key={row.map((tile) => tile.key).join("|")}
|
||||
style={{
|
||||
marginTop:
|
||||
rowIndex === 0 ? 0 : -(hexWidth / HEX_ASPECT) * ROW_OVERLAP,
|
||||
transform: `translateX(${translateX}px)`,
|
||||
}}
|
||||
>
|
||||
{row.map((tile) => tile.node)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
|
||||
{communities.length === 0 ? (
|
||||
<p className="mx-auto mt-16 max-w-md text-center text-sm leading-6 text-muted-foreground">
|
||||
This is your community home. It stays available whenever you want a
|
||||
neutral place to join, create, or switch communities.
|
||||
</p>
|
||||
{rendered}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* A quiet, one-time nudge toward the hidden frontier — fades away the
|
||||
moment you start exploring the grid. */}
|
||||
<p
|
||||
className={cn(
|
||||
"pointer-events-none absolute bottom-10 left-1/2 -translate-x-1/2 text-center text-2xs font-medium uppercase tracking-[0.22em] text-muted-foreground transition-opacity duration-300 ease-out",
|
||||
areaActive ? "opacity-0" : "opacity-70",
|
||||
)}
|
||||
>
|
||||
Hover to build your space
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* The in-app create-agent flow normally lives in the connected shell;
|
||||
mount it here so "New agent" works before any relay is connected. */}
|
||||
<RequestedAgentCreateDialogs />
|
||||
|
||||
<AlertDialog
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setCommunityToRemove(null);
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user