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.
354 lines
14 KiB
TypeScript
354 lines
14 KiB
TypeScript
import React from 'react';
|
||
import type { User } from '@backspace/shared';
|
||
import { Avatar } from './Avatar';
|
||
import { useCanonicalUserView } from '../../utils/userViewLookup';
|
||
import { parseFederatedUsername } from '../../utils/identity';
|
||
|
||
/**
|
||
* Reusable avatar-stack widget for group-DM identity slots.
|
||
*
|
||
* Renders one of five layouts depending on `members.length`, plus an
|
||
* optional `iconUrl` override that bypasses the stack entirely:
|
||
*
|
||
* - iconUrl set → single `<img>` filling the box
|
||
* - 0 members → empty placeholder + group badge
|
||
* - 1 member → centered avatar + group badge (12×12, bottom-right)
|
||
* - 2 members → equal-size offset overlap
|
||
* - 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.
|
||
*
|
||
* Hooks-in-loop safety: each rendered slot is its own `<AvatarTile>`
|
||
* component so `useCanonicalUserView` is called once per slot, not inside
|
||
* a variable-length `.map()`.
|
||
*/
|
||
export interface AvatarStackProps {
|
||
/** "Other" members already filtered to exclude self when applicable. */
|
||
members: User[];
|
||
/** Outer box edge length in px. Common: 24, 32, 40, 56, 80. */
|
||
size: number;
|
||
/** Which surface tier this stack sits on; controls border color. */
|
||
border: 'channel' | 'chat' | 'modal';
|
||
/** When set, renders the icon and ignores the stack. Bare filename or absolute URL. */
|
||
iconUrl?: string | null;
|
||
}
|
||
|
||
const BORDER_CLASS: Record<AvatarStackProps['border'], string> = {
|
||
channel: 'border-surface-channel',
|
||
chat: 'border-surface-chat',
|
||
// No `surface-modal` token in tailwind.config.js — fall back to elevated per plan note.
|
||
modal: 'border-surface-elevated',
|
||
};
|
||
|
||
/**
|
||
* Width (in px) of the `border-2` Tailwind class applied to every tile,
|
||
* placeholder, badge, and overflow slot. Centralized as a constant because
|
||
* `box-sizing: border-box` (Tailwind preflight default) makes a wrapper with
|
||
* `width: N` + `border-2` have a content area of `(N − 2·BORDER) × (N − 2·BORDER)`.
|
||
* Inner content (e.g. the `Avatar` inside an `AvatarTile`) must be sized to
|
||
* the content area, not the outer width — otherwise it overflows the padding
|
||
* box, gets clipped off-center by `overflow-hidden + rounded-full`, and the
|
||
* visible avatar/initials end up shifted toward the upper-left of the tile.
|
||
*/
|
||
const TILE_BORDER_WIDTH = 2;
|
||
|
||
/** Resolves a bare filename to /api/uploads/, leaves absolute URLs alone. */
|
||
function resolveIconSrc(iconUrl: string): string {
|
||
if (iconUrl.startsWith('http') || iconUrl.startsWith('blob:') || iconUrl.startsWith('data:') || iconUrl.startsWith('/')) {
|
||
return iconUrl;
|
||
}
|
||
return `/api/uploads/${iconUrl}`;
|
||
}
|
||
|
||
/** Two-figure people SVG used as the group badge for 0/1-member groups. */
|
||
function GroupBadgeIcon({ size }: { size: number }) {
|
||
return (
|
||
<svg
|
||
width={size}
|
||
height={size}
|
||
viewBox="0 0 24 24"
|
||
fill="currentColor"
|
||
aria-hidden="true"
|
||
>
|
||
<path d="M12 12.75c1.63 0 3.07.39 4.24.9 1.08.48 1.76 1.56 1.76 2.73V18H6v-1.61c0-1.18.68-2.26 1.76-2.73 1.17-.52 2.61-.91 4.24-.91zM4 13c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm1.13 1.1c-.37-.06-.74-.1-1.13-.1-.99 0-1.93.21-2.78.58A2.01 2.01 0 0 0 0 16.43V18h4.5v-1.61c0-.83.23-1.61.63-2.29zM20 13c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm4 3.43c0-.81-.48-1.53-1.22-1.85A6.95 6.95 0 0 0 20 14c-.39 0-.76.04-1.13.1.4.68.63 1.46.63 2.29V18H24v-1.57zM12 6c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3z" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Single avatar slot. Extracted as a component so `useCanonicalUserView`
|
||
* is called exactly once per slot (hooks rules: no hooks inside .map()).
|
||
*/
|
||
function AvatarTile({
|
||
member,
|
||
size,
|
||
borderClass,
|
||
className = '',
|
||
style,
|
||
}: {
|
||
member: User;
|
||
size: number;
|
||
borderClass: string;
|
||
className?: string;
|
||
style?: React.CSSProperties;
|
||
}) {
|
||
const canonical = useCanonicalUserView(member);
|
||
const displayName = canonical.displayName ?? parseFederatedUsername(canonical.username).baseName;
|
||
// Two corrections on top of the previous "drop the Avatar straight in" form:
|
||
//
|
||
// 1. Box-sizing. The wrapper renders at `size × size` with a 2px border
|
||
// (border-box default), so its content area is `(size − 4) × (size − 4)`.
|
||
// An Avatar sized to `size` would overflow the padding box and get
|
||
// clipped off-center — the clip is centered on the wrapper but the
|
||
// Avatar starts at the padding-edge top-left, so its contents (image
|
||
// crop, initials gradient + letter) end up displaced toward the
|
||
// lower-right of the visible disc.
|
||
// 2. Inline-flex baseline. `Avatar`'s root is `inline-flex`, which makes
|
||
// it sit on the line box's text baseline. With any non-1 inherited
|
||
// `line-height`, the Avatar drifts vertically inside its container,
|
||
// not just horizontally. Centering it via the wrapper (`flex` +
|
||
// `items-center justify-center`) bypasses inline layout entirely so
|
||
// the Avatar is anchored geometrically, regardless of inherited type.
|
||
const innerSize = Math.max(0, size - 2 * TILE_BORDER_WIDTH);
|
||
return (
|
||
<div
|
||
data-avatar-stack-tile="true"
|
||
className={`absolute rounded-full overflow-hidden border-2 flex items-center justify-center ${borderClass} ${className}`}
|
||
style={{ width: size, height: size, ...style }}
|
||
>
|
||
<Avatar
|
||
src={canonical.avatar}
|
||
name={displayName}
|
||
size={innerSize}
|
||
userId={canonical.homeUserId ?? canonical.id}
|
||
user={canonical}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function AvatarStack({ members, size, border, iconUrl }: AvatarStackProps) {
|
||
const borderClass = BORDER_CLASS[border];
|
||
|
||
// Icon override — bypass the stack entirely.
|
||
if (iconUrl) {
|
||
return (
|
||
<div
|
||
className="relative flex-shrink-0 rounded-full overflow-hidden"
|
||
style={{ width: size, height: size }}
|
||
>
|
||
<img
|
||
src={resolveIconSrc(iconUrl)}
|
||
alt=""
|
||
loading="lazy"
|
||
className="w-full h-full rounded-full object-cover"
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Group-badge sizing: fixed 12×12 per spec.
|
||
const badgeBoxSize = 12;
|
||
const badgeIconSize = 8;
|
||
const badgeOffset = -2;
|
||
|
||
// ─── Empty group ────────────────────────────────────────────────────────
|
||
if (members.length === 0) {
|
||
return (
|
||
<div
|
||
className="relative flex-shrink-0"
|
||
style={{ width: size, height: size }}
|
||
>
|
||
<div
|
||
data-avatar-stack-placeholder="true"
|
||
className={`absolute inset-0 rounded-full bg-surface-input border-2 ${borderClass}`}
|
||
/>
|
||
<div
|
||
data-group-badge="true"
|
||
className={`absolute rounded-full bg-surface-elevated border-2 ${borderClass} flex items-center justify-center text-txt-tertiary`}
|
||
style={{
|
||
width: badgeBoxSize,
|
||
height: badgeBoxSize,
|
||
right: badgeOffset,
|
||
bottom: badgeOffset,
|
||
}}
|
||
>
|
||
<GroupBadgeIcon size={badgeIconSize} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Single member: centered avatar + group badge ───────────────────────
|
||
if (members.length === 1) {
|
||
return (
|
||
<div
|
||
className="relative flex-shrink-0"
|
||
style={{ width: size, height: size }}
|
||
>
|
||
<AvatarTile
|
||
member={members[0]!}
|
||
size={size}
|
||
borderClass="border-transparent"
|
||
style={{ left: 0, top: 0 }}
|
||
/>
|
||
<div
|
||
data-group-badge="true"
|
||
className={`absolute rounded-full bg-surface-elevated border-2 ${borderClass} flex items-center justify-center text-txt-secondary z-10`}
|
||
style={{
|
||
width: badgeBoxSize,
|
||
height: badgeBoxSize,
|
||
right: badgeOffset,
|
||
bottom: badgeOffset,
|
||
}}
|
||
>
|
||
<GroupBadgeIcon size={badgeIconSize} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Two members: equal-size offset overlap ─────────────────────────────
|
||
if (members.length === 2) {
|
||
const tileSize = Math.round(size * 0.7);
|
||
const offset = Math.round(size * 0.3);
|
||
return (
|
||
<div
|
||
data-avatar-stack-layout="overlap"
|
||
className="relative flex-shrink-0"
|
||
style={{ width: size, height: size }}
|
||
>
|
||
{members.map((m, i) => (
|
||
<AvatarTile
|
||
key={m.id}
|
||
member={m}
|
||
size={tileSize}
|
||
borderClass={borderClass}
|
||
style={{
|
||
left: i === 0 ? 0 : offset,
|
||
top: i === 0 ? 0 : offset,
|
||
zIndex: i === 0 ? 2 : 1,
|
||
}}
|
||
/>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── 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;
|
||
|
||
// 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 (
|
||
<div
|
||
data-avatar-stack-layout={isTriangle ? 'triangle' : 'diamond'}
|
||
className="relative flex-shrink-0"
|
||
style={{ width: size, height: size }}
|
||
>
|
||
{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 (
|
||
<AvatarTile
|
||
key={m.id}
|
||
member={m}
|
||
size={tileSize}
|
||
borderClass={borderClass}
|
||
style={{
|
||
left: pos.left,
|
||
top: pos.top,
|
||
zIndex: zIndexFor(slotIndex),
|
||
}}
|
||
/>
|
||
);
|
||
})}
|
||
{overflow > 0 && (
|
||
<div
|
||
data-avatar-stack-overflow="true"
|
||
className={`absolute rounded-full bg-surface-input border-2 ${borderClass} flex items-center justify-center text-txt-secondary font-semibold leading-none`}
|
||
style={{
|
||
width: tileSize,
|
||
height: tileSize,
|
||
left: positions[overflowSlot]!.left,
|
||
top: positions[overflowSlot]!.top,
|
||
fontSize: overflowFontSize,
|
||
// Overflow sits on top of all visible tiles so its `+N` text is
|
||
// always fully readable, even when neighboring slot circles
|
||
// would otherwise clip the right/left edge of the digit.
|
||
zIndex: slotCount + 1,
|
||
}}
|
||
>
|
||
{`+${overflow}`}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|