From 9279ac78e5ff3ebd869a4a409be92a62081d2eb4 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 10 May 2026 21:57:38 +0200 Subject: [PATCH] =?UTF-8?q?fix(ui):=20AvatarStack=20=E2=80=94=20radial=20o?= =?UTF-8?q?verlap=20layout=20for=203-10=20members=20(replaces=202x2=20grid?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old 2x2 grid for 3+ members produced a cramped, misaligned look (circles cut off inside their own border, no overlap, just four small disks in a grid). The 2-member overlap aesthetic — equal-size tiles that overlap diagonally — was the visual gold standard but only existed for that one count. Extend the same "huddle of overlapping faces" aesthetic to all member counts: • 3 members → equilateral triangle of 62%-size tiles (top, bl, br) • 4 members → diamond of 58%-size tiles (top, right, bottom, left) • 5-10 members → diamond with `+N` overflow occupying the bottom slot (z-index boosted above neighbors so the digit is never clipped) Tiles are positioned radially around the box center on a circle of radius (S − T) / 2 so the farthest edges of each tile graze the bounding rect — no clipping, no wasted whitespace. Z-index descends clockwise from the top slot so each tile tucks slightly under its clockwise neighbor, mirroring the 2-member z-stack. The 0/1/2-member cases and the icon-override branch are unchanged. Props interface unchanged — no call-site updates needed. Tests updated to assert the new layout markers (`triangle`, `diamond`) and to verify the geometry (top tile in triangle has smallest `top`; overflow tile in diamond has largest `top`). 11/11 AvatarStack tests pass; 360/360 web tests pass; typecheck clean. design-system.md spec updated with the new layout table + geometry section. --- docs/systems/design-system.md | 17 ++- .../src/components/ui/AvatarStack.test.tsx | 56 +++++++- .../web/src/components/ui/AvatarStack.tsx | 122 ++++++++++++++---- 3 files changed, 155 insertions(+), 40 deletions(-) diff --git a/docs/systems/design-system.md b/docs/systems/design-system.md index c2037e30..05a51572 100644 --- a/docs/systems/design-system.md +++ b/docs/systems/design-system.md @@ -182,13 +182,16 @@ interface AvatarStackProps { **Layout rules** (chosen by `members.length`, ignored entirely when `iconUrl` is set): -| Member count | Layout | -|---|---| -| 0 | Empty placeholder + small 12×12 group badge bottom-right | -| 1 | Single avatar centered in the box + 12×12 group badge bottom-right (distinguishes a 1-other-member group from a 1-on-1 DM) | -| 2 | Two avatars at 70% size with a 30% offset overlap (z-stacked) | -| 3 | 2×2 grid: three avatar tiles + one empty bottom-right slot | -| 4-10 | 2×2 grid: three avatar tiles + a `+N` overflow tile, where `N = members.length - 3` | +| Member count | Layout | `data-avatar-stack-layout` | +|---|---|---| +| 0 | Empty placeholder + small 12×12 group badge bottom-right | — | +| 1 | Single avatar centered in the box + 12×12 group badge bottom-right (distinguishes a 1-other-member group from a 1-on-1 DM) | — | +| 2 | Two avatars at 70% size with a 30% offset overlap (z-stacked) | `overlap` | +| 3 | Equilateral-triangle huddle: three 62%-size tiles arranged radially (top, bottom-right, bottom-left), neighbors overlap | `triangle` | +| 4 | Diamond huddle: four 58%-size tiles at the four cardinal points (top, right, bottom, left), neighbors overlap | `diamond` | +| 5-10 | Diamond huddle: three 58%-size tiles at the top/right/left points + a `+N` overflow tile occupying the bottom point, where `N = members.length - 3` | `diamond` | + +**3+ member geometry.** Tiles are positioned radially around the box center on a circle of radius `R = (S − T) / 2`, where `S` is the box edge length and `T` is the tile size (`0.62·S` for 3 members, `0.58·S` for 4+). The first slot starts at `−90°` (top) and remaining slots are evenly spaced clockwise (`360° / slotCount` apart). This makes the farthest edges of each tile graze the box's bounding rect — no clipping, no wasted whitespace — and produces the same "huddle of overlapping faces" aesthetic as the 2-member overlap pattern at every member count. Z-index descends clockwise from the top slot so each tile tucks slightly under its clockwise neighbor (mirrors the 2-member case where the first tile sits on top of the second). The `+N` overflow tile always occupies the bottom diamond slot — reads as "more members behind these three" rather than "+N is one of the people". `iconUrl` accepts a bare filename (resolved to `/api/uploads/`) or an absolute URL (`http`, `blob:`, `data:`, or `/`-prefixed) — passed through unchanged. When set, the entire box renders as a single rounded `` filling the box. diff --git a/packages/web/src/components/ui/AvatarStack.test.tsx b/packages/web/src/components/ui/AvatarStack.test.tsx index aa50815c..3f833a9a 100644 --- a/packages/web/src/components/ui/AvatarStack.test.tsx +++ b/packages/web/src/components/ui/AvatarStack.test.tsx @@ -85,17 +85,27 @@ describe('AvatarStack', () => { expect(container.querySelector('[data-avatar-stack-overflow]')).toBeFalsy(); }); - it('renders 2x2 grid with three tiles and no +N for three members', () => { + it('renders triangular layout with three tiles and no +N for three members', () => { const { container } = render( ); const tiles = container.querySelectorAll('[data-avatar-stack-tile]'); expect(tiles.length).toBe(3); - expect(container.querySelector('[data-avatar-stack-layout="grid"]')).toBeTruthy(); + expect(container.querySelector('[data-avatar-stack-layout="triangle"]')).toBeTruthy(); expect(container.querySelector('[data-avatar-stack-overflow]')).toBeFalsy(); }); - it('renders three tiles + "+2" overflow for five members', () => { + it('renders diamond layout with four tiles and no +N for four members', () => { + const { container } = render( + + ); + const tiles = container.querySelectorAll('[data-avatar-stack-tile]'); + expect(tiles.length).toBe(4); + expect(container.querySelector('[data-avatar-stack-layout="diamond"]')).toBeTruthy(); + expect(container.querySelector('[data-avatar-stack-overflow]')).toBeFalsy(); + }); + + it('renders three tiles + "+2" overflow in diamond layout for five members', () => { const { container } = render( ); @@ -104,10 +114,10 @@ describe('AvatarStack', () => { const overflow = container.querySelector('[data-avatar-stack-overflow]'); expect(overflow).toBeTruthy(); expect(overflow!.textContent).toBe('+2'); - expect(container.querySelector('[data-avatar-stack-layout="grid"]')).toBeTruthy(); + expect(container.querySelector('[data-avatar-stack-layout="diamond"]')).toBeTruthy(); }); - it('renders three tiles + "+7" overflow for ten members (cap)', () => { + it('renders three tiles + "+7" overflow in diamond layout for ten members (cap)', () => { const { container } = render( ); @@ -116,6 +126,42 @@ describe('AvatarStack', () => { const overflow = container.querySelector('[data-avatar-stack-overflow]'); expect(overflow).toBeTruthy(); expect(overflow!.textContent).toBe('+7'); + expect(container.querySelector('[data-avatar-stack-layout="diamond"]')).toBeTruthy(); + }); + + it('places tiles on the correct radial slots (3-member triangle)', () => { + const { container } = render( + , + ); + const tiles = Array.from( + container.querySelectorAll('[data-avatar-stack-tile]'), + ) as HTMLElement[]; + // Triangle: top, bottom-right (~+30°), bottom-left (~+150° from -90 start) + // Top tile must have the smallest `top` value. + const tops = tiles.map((t) => parseInt(t.style.top, 10)); + const minTop = Math.min(...tops); + expect(tops.filter((t) => t === minTop).length).toBe(1); + // The two non-top tiles share roughly the same `top` (bottom row of triangle). + const others = tops.filter((t) => t !== minTop).sort((a, b) => a - b); + expect(others.length).toBe(2); + expect(Math.abs(others[0]! - others[1]!)).toBeLessThanOrEqual(1); + }); + + it('places overflow tile in the bottom slot of the diamond', () => { + const { container } = render( + , + ); + const overflow = container.querySelector( + '[data-avatar-stack-overflow]', + ) as HTMLElement; + const tiles = Array.from( + container.querySelectorAll('[data-avatar-stack-tile]'), + ) as HTMLElement[]; + const overflowTop = parseInt(overflow.style.top, 10); + // Overflow sits at bottom of diamond — its `top` should be the + // largest among all four positioned elements. + const tileTops = tiles.map((t) => parseInt(t.style.top, 10)); + expect(overflowTop).toBeGreaterThan(Math.max(...tileTops) - 1); }); it('renders icon override and ignores the stack', () => { diff --git a/packages/web/src/components/ui/AvatarStack.tsx b/packages/web/src/components/ui/AvatarStack.tsx index 0ed91f48..b91fd530 100644 --- a/packages/web/src/components/ui/AvatarStack.tsx +++ b/packages/web/src/components/ui/AvatarStack.tsx @@ -14,8 +14,16 @@ import { parseFederatedUsername } from '../../utils/identity'; * - 0 members → empty placeholder + group badge * - 1 member → centered avatar + group badge (12×12, bottom-right) * - 2 members → equal-size offset overlap - * - 3 members → 2×2 grid, three tiles - * - 4+ members → 2×2 grid, three tiles + `+N` overflow tile + * - 3 members → equilateral triangle of overlapping tiles (top/bl/br) + * - 4 members → diamond of overlapping tiles (top/right/bottom/left) + * - 5-10 members → diamond with `+N` overflow occupying the bottom slot + * + * The 3+ layouts deliberately mirror the 2-member overlap aesthetic: + * tiles are positioned radially around the box center so neighboring + * circles overlap by roughly the same amount as the 2-member case. This + * keeps the "huddle of overlapping faces" look consistent across all + * member counts and avoids the cramped 2×2-grid look that the previous + * implementation produced at small sizes. * * Status dots are never rendered — group identity wins regardless of * member count. @@ -209,36 +217,90 @@ export function AvatarStack({ members, size, border, iconUrl }: AvatarStackProps ); } - // ─── 3+ members: 2×2 grid (three avatar tiles, fourth slot empty or +N) ─ - const tileSize = Math.round((size - 2) / 2); // half the box, with a 2px gutter - const tileGap = size - tileSize * 2; - // Four grid positions: top-left, top-right, bottom-left, bottom-right. - const positions = [ - { left: 0, top: 0 }, - { left: tileSize + tileGap, top: 0 }, - { left: 0, top: tileSize + tileGap }, - { left: tileSize + tileGap, top: tileSize + tileGap }, - ]; + // ─── 3 members: equilateral triangle of overlapping tiles ─────────────── + // ─── 4-10 members: diamond of overlapping tiles (+N in bottom slot) ───── + // + // Math: each tile has size T = ratio · S. Tile centers sit on a circle of + // radius R around the box center; we choose R = (S - T) / 2 so the + // farthest edges of the tiles graze the box's bounding box (no clipping, + // no wasted whitespace). Angles are spaced evenly, starting at -90° (top) + // so the layout always has a tile pointing up — visually anchors the + // composition the same way the 2-member case anchors top-left. + // + // Ratio choice: + // • 3 tiles at 0.62 → adjacent tiles overlap by ~12% of S edge-to-edge + // (visually similar to the 2-member 30% offset overlap once you + // account for the triangular spacing being larger than diagonal). + // • 4 tiles at 0.58 → cardinal positions, neighbors overlap by ~16% of S. + // + // Both ratios keep the tiles large enough to read at size 32 while + // leaving enough gap for the 2px borders to read clearly. + const isTriangle = members.length === 3; + const tileRatio = isTriangle ? 0.62 : 0.58; + const tileSize = Math.round(size * tileRatio); + const radius = (size - tileSize) / 2; + const cx = size / 2; + const cy = size / 2; - const visibleMembers = members.slice(0, 3); - const overflow = members.length > 3 ? members.length - 3 : 0; - const overflowFontSize = Math.max(9, Math.round(tileSize * 0.45)); + // Angles in radians, -90° (top) start. For triangle: 3 evenly spaced. + // For diamond: 4 cardinal points (top, right, bottom, left). + const slotCount = isTriangle ? 3 : 4; + const startAngle = -Math.PI / 2; + const angles = Array.from({ length: slotCount }, (_, i) => + startAngle + (i * 2 * Math.PI) / slotCount, + ); + const positions = angles.map((a) => ({ + left: Math.round(cx + radius * Math.cos(a) - tileSize / 2), + top: Math.round(cy + radius * Math.sin(a) - tileSize / 2), + })); + + // Visible avatar tiles + overflow slot logic: + // • 3 members → 3 tiles, no overflow + // • 4 members → 4 tiles, no overflow + // • 5+ members → 3 tiles + `+N` in the 4th (bottom) slot + // Overflow lives in the bottom slot of the diamond so it reads as + // "more behind these three" rather than "+N is one of the people". + const overflow = members.length > 4 ? members.length - 3 : 0; + const visibleCount = overflow > 0 ? 3 : members.length; + const visibleMembers = members.slice(0, visibleCount); + // Bottom slot of diamond is index 2 (top, right, bottom, left). + const overflowSlot = 2; + const overflowFontSize = Math.max(9, Math.round(tileSize * 0.42)); + + // Z-stack: top tile sits highest so the upward-pointing avatar is fully + // visible; remaining tiles descend by angle so each one tucks slightly + // under its clockwise neighbor — mirrors the 2-member case where the + // first tile sits on top of the second. + const zIndexFor = (slotIndex: number) => slotCount - slotIndex; return (
- {visibleMembers.map((m, i) => ( - - ))} + {visibleMembers.map((m, i) => { + // Skip the overflow slot when rendering visible tiles in the + // overflow case — only relevant when overflow > 0 and i would + // collide with overflowSlot. With visibleCount=3 and overflowSlot=2 + // (bottom), this never collides because i ∈ {0,1,2} maps to + // slotIndex ∈ {0,1,3} — see assignment below. + const slotIndex = overflow > 0 && i >= overflowSlot ? i + 1 : i; + const pos = positions[slotIndex]!; + return ( + + ); + })} {overflow > 0 && (
- {`+${members.length - 3}`} + {`+${overflow}`}
)}