refine(desktop): interlocking honeycomb for Community Home

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) <noreply@anthropic.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
This commit is contained in:
Bradley Axen
2026-07-18 23:23:14 -07:00
parent a3a0324592
commit f98532f0a0

View File

@@ -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<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));
}
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 (
<>
<span
aria-hidden="true"
className={edgeClassName}
style={{ clipPath: HEX_CLIP_PATH }}
/>
<span
className={fillClassName}
style={{ clipPath: HEX_CLIP_PATH, padding: `0 ${width * 0.1}px` }}
>
{children}
</span>
</>
);
}
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-[1.06] w-full outline-hidden transition-transform duration-300 hover:-translate-y-1 focus-visible:-translate-y-1"
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"
data-testid={`community-home-community-${community.id}`}
onClick={onOpen}
style={{ width }}
type="button"
>
<span
aria-hidden="true"
className="absolute inset-0 bg-foreground/12 transition-colors group-hover:bg-foreground/25 group-focus-visible:bg-foreground/30"
style={{ clipPath: HEX_CLIP_PATH }}
/>
<span
className="absolute inset-px flex flex-col items-center justify-center overflow-hidden bg-card/92 px-[14%] text-card-foreground shadow-xl backdrop-blur-md transition-colors group-hover:bg-card"
style={{ clipPath: HEX_CLIP_PATH }}
<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}
>
<span className="absolute inset-0 bg-[radial-gradient(circle_at_36%_24%,hsl(var(--primary)/0.18),transparent_58%)]" />
<span className="relative flex h-[38%] w-[38%] items-center justify-center overflow-hidden rounded-[28%] bg-primary/12 text-title font-semibold text-primary shadow-sm ring-1 ring-primary/15 transition-transform duration-300 group-hover:scale-105">
<span className="absolute inset-0 bg-[radial-gradient(circle_at_38%_20%,hsl(var(--primary)/0.14),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
alt=""
@@ -74,13 +131,13 @@ function CommunityHex({
getInitials(community.name) || "🐝"
)}
</span>
<span className="relative mt-3 max-w-full truncate text-sm font-medium">
<span className="relative mt-2.5 max-w-full truncate text-sm font-semibold">
{community.name}
</span>
<span className="relative mt-1 flex items-center gap-1 text-xs text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100">
<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>
</span>
</HexShell>
</button>
</ContextMenuTrigger>
<ContextMenuContent>
@@ -110,34 +167,36 @@ function ActionHex({
icon,
onClick,
testId,
width,
}: {
label: string;
detail: string;
icon: React.ReactNode;
onClick: () => void;
testId: string;
width: number;
}) {
return (
<button
className="group relative aspect-[1.06] w-full outline-hidden transition-transform duration-300 hover:-translate-y-1 focus-visible:-translate-y-1"
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"
data-testid={testId}
onClick={onClick}
style={{ width }}
type="button"
>
<span
className="absolute inset-0 bg-primary/35 transition-colors group-hover:bg-primary/65"
style={{ clipPath: HEX_CLIP_PATH }}
/>
<span
className="absolute inset-px flex flex-col items-center justify-center bg-primary/8 px-[14%] text-foreground backdrop-blur-md transition-colors group-hover:bg-primary/14"
style={{ clipPath: HEX_CLIP_PATH }}
<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}
>
<span className="flex h-[34%] w-[34%] items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-transform duration-300 group-hover:scale-105">
<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-3 text-sm font-medium">{label}</span>
<span className="mt-1 text-xs text-muted-foreground">{detail}</span>
</span>
<span className="mt-2.5 text-sm font-semibold">{label}</span>
<span className="mt-1 text-2xs font-medium text-muted-foreground">
{detail}
</span>
</HexShell>
</button>
);
}
@@ -158,6 +217,68 @@ export function CommunityHome({
const iconsByCommunity = useCommunityIcons(communities);
const [communityToRemove, setCommunityToRemove] =
React.useState<Community | null>(null);
const [gridRef, gridWidth] = useElementWidth<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));
// 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}
/>
),
},
];
const rows = chunkRows(tiles, perRow);
const hasStagger = rows.length > 1;
return (
<main
@@ -173,7 +294,7 @@ export function CommunityHome({
<p className="text-xs font-medium uppercase tracking-[0.22em] text-muted-foreground">
Buzz communities
</p>
<h1 className="mt-3 text-title font-normal">
<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">
@@ -195,42 +316,38 @@ export function CommunityHome({
<section
aria-label="Communities"
className={cn(
"mx-auto mt-12 grid w-full max-w-4xl grid-cols-2 gap-x-3 gap-y-0 sm:grid-cols-3 md:grid-cols-4",
"[&>*: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) => (
<CommunityHex
community={community}
iconUrl={iconsByCommunity[community.id] ?? null}
key={community.id}
onOpen={() => onOpenCommunity(community.id)}
onRemove={() => setCommunityToRemove(community)}
/>
))}
<ActionHex
detail="Use a relay URL"
icon={<Plus className="h-6 w-6" />}
label="Join a community"
onClick={onJoinCommunity}
testId="community-home-join"
/>
<ActionHex
detail="Start something new"
icon={<span className="text-xl">✦</span>}
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 (
<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-24 max-w-md text-center text-sm leading-6 text-muted-foreground sm:mt-28">
This is your community home. It will stay available whenever you
want a neutral place to join, create, or switch communities.
<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>
) : null}
</div>