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.
This commit is contained in:
@@ -164,6 +164,35 @@ describe('AvatarStack', () => {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user