Files
backspace/packages/web/src/components/ui/AvatarStack.test.tsx
T
Jannis Braun 87ecf0f4f3 fix(ui): AvatarStack — center inner Avatar in each tile (off-center clipping)
Each AvatarTile rendered at `size × size` with a 2px border under
`box-sizing: border-box`, giving a content area of `(size − 4) × (size − 4)`.
The inner `<Avatar size={size}>` exceeded the padding box, and the
`overflow-hidden + rounded-full` clip — centered on the wrapper — combined
with the avatar contents anchored at the padding-edge top-left to displace
photos and especially the centered initials gradient + letter toward the
lower-right of the visible disc, leaving a sliver of background opposite.
Compounding this, `Avatar`'s `inline-flex` root sat on the line-box text
baseline, so any inherited `line-height ≠ 1` (the DM list inherits the
row's line-height) drifted the avatar a further several px vertically.

Two corrections:
  • Size the inner Avatar to `size − 2·TILE_BORDER_WIDTH` (matches the
    padding box) — extracted as a constant so the dependency between
    `border-2` and the inner size is visible.
  • Center geometrically via `flex items-center justify-center` on the
    wrapper, bypassing inline-flow placement so the Avatar is anchored
    regardless of inherited type metrics.

Verified in the live app (DM sidebar, chat header, welcome header) and
with a 4-tile diamond at sizes 32 and 80: visible offset is now exactly
the border width on every tile, letters/photos sit dead-center.

Adds a regression test that pins both invariants (flex centering classes
present + inner Avatar style.width === tileSize − 4) so a future change
that re-introduces the bug fails fast. 12/12 AvatarStack tests, 365/365
web tests, typecheck clean.

design-system.md spec updated with the AvatarTile geometry contract.
2026-05-10 23:18:24 +02:00

230 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi } from 'vitest';
import { render } from '@testing-library/react';
import type { User } from '@backspace/shared';
// AvatarStack pulls in `useCanonicalUserView` → spaceStore, which transitively
// imports AudioManager. Stub it out so jsdom doesn't choke on AudioWorkletNode.
vi.mock('../../audio/AudioManager', () => ({
AudioManager: {
getInstance: vi.fn().mockReturnValue({
setOutputDevice: vi.fn(),
setVolume: vi.fn(),
}),
},
}));
import { AvatarStack } from './AvatarStack';
function makeUser(n: number): User {
return {
id: `u-${n}`,
username: `user${n}`,
displayName: `User ${n}`,
avatar: null,
banner: null,
accentColor: null,
avatarColor: null,
bio: null,
status: 'offline',
customStatus: null,
isAdmin: false,
createdAt: 0,
homeInstance: null,
homeUserId: null,
replicatedInstances: [],
};
}
function makeUsers(count: number): User[] {
return Array.from({ length: count }, (_, i) => makeUser(i + 1));
}
describe('AvatarStack', () => {
it('renders empty placeholder + group badge for an empty group', () => {
const { container } = render(
<AvatarStack members={[]} size={40} border="channel" />
);
// Placeholder slot present
expect(container.querySelector('[data-avatar-stack-placeholder]')).toBeTruthy();
// Group badge present
expect(container.querySelector('[data-group-badge]')).toBeTruthy();
// No avatar tiles rendered
expect(container.querySelectorAll('[data-avatar-stack-tile]').length).toBe(0);
// No +N tile
expect(container.querySelector('[data-avatar-stack-overflow]')).toBeFalsy();
});
it('renders centered avatar + 12x12 group badge for a single member', () => {
const { container } = render(
<AvatarStack members={makeUsers(1)} size={40} border="channel" />
);
const tiles = container.querySelectorAll('[data-avatar-stack-tile]');
expect(tiles.length).toBe(1);
const badge = container.querySelector('[data-group-badge]') as HTMLElement | null;
expect(badge).toBeTruthy();
expect(badge!.getAttribute('data-group-badge')).toBe('true');
// Badge is sized 12x12
const badgeStyle = badge!.style;
expect(badgeStyle.width).toBe('12px');
expect(badgeStyle.height).toBe('12px');
// No +N
expect(container.querySelector('[data-avatar-stack-overflow]')).toBeFalsy();
});
it('renders two avatars in offset overlap for two members', () => {
const { container } = render(
<AvatarStack members={makeUsers(2)} size={40} border="channel" />
);
const tiles = container.querySelectorAll('[data-avatar-stack-tile]');
expect(tiles.length).toBe(2);
// No group badge in multi-member case
expect(container.querySelector('[data-group-badge]')).toBeFalsy();
// Layout marker for overlap
expect(container.querySelector('[data-avatar-stack-layout="overlap"]')).toBeTruthy();
// No +N
expect(container.querySelector('[data-avatar-stack-overflow]')).toBeFalsy();
});
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="triangle"]')).toBeTruthy();
expect(container.querySelector('[data-avatar-stack-overflow]')).toBeFalsy();
});
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" />
);
const tiles = container.querySelectorAll('[data-avatar-stack-tile]');
expect(tiles.length).toBe(3);
const overflow = container.querySelector('[data-avatar-stack-overflow]');
expect(overflow).toBeTruthy();
expect(overflow!.textContent).toBe('+2');
expect(container.querySelector('[data-avatar-stack-layout="diamond"]')).toBeTruthy();
});
it('renders three tiles + "+7" overflow in diamond layout for ten members (cap)', () => {
const { container } = render(
<AvatarStack members={makeUsers(10)} size={40} border="channel" />
);
const tiles = container.querySelectorAll('[data-avatar-stack-tile]');
expect(tiles.length).toBe(3);
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('sizes the inner Avatar to the tile content area so contents stay centered', () => {
// Regression for the off-center clipping bug: when the inner Avatar was
// sized to the outer tile dimensions, `box-sizing: border-box` + 2px border
// pushed its contents (image crop, initials gradient + letter) toward the
// lower-right of the visible disc. The fix is to size the inner Avatar to
// (tileSize 2·border) and center it geometrically with flex.
const { container } = render(
<AvatarStack members={makeUsers(4)} size={80} border="chat" />,
);
const tiles = Array.from(
container.querySelectorAll('[data-avatar-stack-tile]'),
) as HTMLElement[];
expect(tiles.length).toBe(4);
for (const tile of tiles) {
// Tile is centered as a flex container so the Avatar bypasses inline-flow
// baseline drift entirely.
expect(tile.className).toMatch(/\bflex\b/);
expect(tile.className).toMatch(/items-center/);
expect(tile.className).toMatch(/justify-center/);
// Inner Avatar is sized to (tileSize 2·border) so it fits the padding
// box exactly. With size=80 and tileRatio=0.58, tileSize=46 → inner=42.
const tileSize = parseInt(tile.style.width, 10);
const inner = tile.querySelector('[data-avatar]') as HTMLElement | null;
expect(inner).toBeTruthy();
expect(inner!.style.width).toBe(`${tileSize - 4}px`);
expect(inner!.style.height).toBe(`${tileSize - 4}px`);
}
});
it('renders icon override and ignores the stack', () => {
const { container } = render(
<AvatarStack
members={makeUsers(3)}
size={40}
border="channel"
iconUrl="abc.png"
/>
);
// Single img filling the box
const imgs = container.querySelectorAll('img');
expect(imgs.length).toBe(1);
const img = imgs[0] as HTMLImageElement;
// Bare filename → /api/uploads/ prefix
expect(img.getAttribute('src')).toBe('/api/uploads/abc.png');
// No tiles, no badge, no overflow
expect(container.querySelectorAll('[data-avatar-stack-tile]').length).toBe(0);
expect(container.querySelector('[data-group-badge]')).toBeFalsy();
expect(container.querySelector('[data-avatar-stack-overflow]')).toBeFalsy();
});
it('uses absolute icon URL as-is for icon override', () => {
const { container } = render(
<AvatarStack
members={[]}
size={40}
border="channel"
iconUrl="https://example.com/icon.png"
/>
);
const img = container.querySelector('img') as HTMLImageElement;
expect(img.getAttribute('src')).toBe('https://example.com/icon.png');
});
});