fix(ui): AvatarStack — radial overlap layout for 3-10 members (replaces 2x2 grid)

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.
This commit is contained in:
Jannis Braun
2026-05-10 21:57:38 +02:00
parent 9f9a7c556b
commit 9279ac78e5
3 changed files with 155 additions and 40 deletions
@@ -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(
<AvatarStack members={makeUsers(3)} size={40} border="channel" />
);
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(
<AvatarStack members={makeUsers(4)} size={40} border="channel" />
);
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(
<AvatarStack members={makeUsers(5)} size={40} border="channel" />
);
@@ -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(
<AvatarStack members={makeUsers(10)} size={40} border="channel" />
);
@@ -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(
<AvatarStack members={makeUsers(3)} size={40} border="channel" />,
);
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(
<AvatarStack members={makeUsers(5)} size={40} border="channel" />,
);
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', () => {