mirror of
https://github.com/block/buzz.git
synced 2026-11-01 23:11:58 +00:00
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user