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.
11 KiB
Design System — "Aether Drift"
Prototype (source of truth): Backspace-design-prototype.html (open in browser)
Styles: packages/web/src/styles/globals.css
Theme: packages/web/tailwind.config.js
Font: DM Sans (primary) with system fallbacks
Principles
- Calm over flashy. Warm over cool.
- Quiet glass (felt, not seen). No decorative gradients. Minimal shadows.
- Two-material system: solid matte panels for content (75%), frosted glass bubbles for persistent controls (25%)
prefers-reduced-transparency→ fall back to solid surfaces- NOT a Discord clone — Backspace has its own visual identity
Color Palette
Matte Surfaces (CSS vars, RGB channels)
| Var | Role |
|---|---|
--bg-base |
App background |
--bg-channel |
Channel sidebar (#1a1a23) |
--bg-chat |
Chat area (#13131a) |
--bg-members |
Member list |
--bg-elevated |
Static structural panels only |
--bg-input |
Input backgrounds (sunken) |
--bg-overlay |
Overlay backgrounds |
Pastel Accents
--accent-mint, --accent-peach, --accent-lavender, --accent-sky, --accent-amber, --accent-rose, --accent-coral
Primary Action
--accent-primary, --accent-primary-hover, --accent-primary-active
Text Hierarchy
--text-primary, --text-secondary, --text-tertiary, --text-category, --text-message, --text-link, --text-positive, --text-warning, --text-danger
Interactive States
--interactive-hover, --interactive-active, --interactive-selected, --interactive-muted
Status
--status-online, --status-idle, --status-dnd, --status-offline
Surface Material Tiers
| Tier | Class | When to Use |
|---|---|---|
| Structural | bg-surface-* |
Permanent layout (sidebars, chat, member list) |
| Strip | .glass-strip |
Persistent edge chrome (space sidebar) |
| Bubble | .glass-bubble |
Persistent floating controls (voice bar, chat composer, voice mini-bar). Chat composer is a floating bubble on both desktop and mobile (position: absolute, sits above the message-list scroll area; messages scroll behind it). On mobile, its bottom value is driven by useVisualViewportInset() so it lifts above the iOS soft keyboard when one is open. See docs/systems/mobile-ui.md "Floating Composer" for the full pattern. |
| Popover | .glass |
Small floating surfaces (context menus, popovers, tooltips) |
| Tray dropdown | .glass (popover) |
TransferIndicator global panel — anchored under the channel-header icon. |
| Modal | .glass-modal |
Large center-screen dialogs |
| Pill | .glass-pill |
Inline decorations (reactions, tags) |
| Pill (own) | .glass-pill-mine |
User's own reaction (mint-tinted) |
Rule: If it floats above the content plane, it's glass. Never use bg-surface-elevated for floating/overlay elements.
Modal backdrops: bg-black/50 — light enough for glass blur to show through.
Portal target — usePortalContainer(): Every overlay (context menu, tooltip, popover, modal, screen-share picker) MUST portal through usePortalContainer() (packages/web/src/hooks/usePortalContainer.ts) instead of hard-coding document.body. The hook returns document.fullscreenElement ?? document.body and re-renders subscribers on fullscreenchange. Without this, anything portaled while an element (e.g. the voice container in fullscreen mode) is in the browser's Fullscreen API top-layer is rendered outside that layer and is invisible. Components mounted at App root that render with fixed inset-0 (not just portals) must also portal through this hook for the same reason.
Glass Material Properties
.glass {
backdrop-filter: blur(20px) saturate(120%);
background: rgba(20, 20, 26, 0.52); /* --glass-bg */
border: 1px solid rgba(255, 255, 255, 0.07); /* --glass-border */
}
.glass-modal {
/* Higher opacity: 82%, stronger shadow */
}
.glass-pill {
backdrop-filter: blur(12px) saturate(110%);
}
Input Tiers
All defined in globals.css. No resting border — sunken surface-input background provides differentiation.
| Tier | Class | When to Use | Focus |
|---|---|---|---|
| Standard | .input-standard |
Form fields in modals, settings, auth | ring-2 primary |
| Search | .input-search |
Search bars, filter inputs | ring-1 primary |
| Embedded | .input-embedded |
Inside glass (chat input, search popover) | none |
| Danger | .input-danger |
Destructive confirmations | ring-2 rose |
Override padding/size with utilities: input-standard w-full py-2.5
iOS Auto-Zoom Suppression
iOS Safari auto-zooms (and shifts the viewport right) on input focus when the computed font-size is below 16px. The @media (max-width: 767px) block in globals.css bumps every input tier — and bare <input>/<textarea>/<select> plus [contenteditable] — to font-size: 16px !important.
!important is required because Tailwind utilities like text-[15px] (used on the chat composer textarea) and text-sm (used on form fields) are emitted in the @layer utilities block, which comes after @layer components in the cascade and would otherwise override the input-tier rules. There is no legitimate reason to use a <16px font-size on a mobile input, so the override is universally correct.
When introducing a new input or contenteditable surface, no extra work is needed — the global rule covers it.
Layout
3-column grid: 312px channel sidebar | main content | 240px members sidebar Glass server strip overlays left 72px of channel sidebar. Channel sidebar fully opaque with gradient at left edge feeding glass.
Shadows
| Name | Use |
|---|---|
header |
Top bars |
elevation-low |
Subtle lift |
elevation-high |
Dropdowns, popovers |
glass |
Glass surfaces |
input |
Input fields |
Animations
Core
fadeIn, slideUp, slideDown, typingFadeIn, gradientPulse, shimmer (skeleton loading)
Search
search-flash, stepForward, stepBack
Call
callRippleLiquid, callGlowSoft, callRefraction, callButtonBreath
Mobile
mobile-screen-enter, mobile-screen-enter-active, mobile-screen-exit-active, slide-up-sheet
Skeleton Loading
.skeleton, .skeleton-circle, .skeleton-bar, .skeleton-block
Utility Classes
.no-scrollbar— Hides scrollbars.scrollbar-thin— 4px thin scrollbars.rounded-inherit— Inherits border radius.titlebar-drag/.titlebar-no-drag— Electron window drag.call-refraction— Light shimmer overlay for call UI
Primitives
Radial Progress Ring
Used in AttachmentProgress overlays inside the optimistic bubble and the staged-files row. Implementation:
background: conic-gradient(rgba(180, 220, 200, .85) <pct>%, rgba(255, 255, 255, .15) <pct>%);
Inner disk uses bg-surface-overlay to sit visually above the underlying tile thumbnail. Failed-state ring uses bg-accent-rose/30.
AvatarStack
Reusable group-DM identity widget at packages/web/src/components/ui/AvatarStack.tsx. Replaces the bespoke inline avatar logic that previously lived in DmListItem. Single source of truth for any rendered group-DM identity slot — sidebar rows, chat header, welcome header, settings modal hero, and mobile equivalents all consume it.
interface AvatarStackProps {
members: User[]; // already filtered to "other" members
size: number; // outer box edge length in px (24, 32, 40, 56, 80)
border: 'channel' | 'chat' | 'modal'; // surface tier the stack sits on; controls tile border color
iconUrl?: string | null; // when set, renders the icon as a single image and ignores the stack
}
Layout rules (chosen by members.length, ignored entirely when iconUrl is set):
| 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/<filename>) or an absolute URL (http, blob:, data:, or /-prefixed) — passed through unchanged. When set, the entire box renders as a single rounded <img> filling the box.
Status dots are deliberately omitted regardless of member count — a group is a group. The 1-on-1 path keeps its presence dot via the direct Avatar component.
Hooks-in-loop safety: each rendered slot is its own <AvatarTile> component so useCanonicalUserView is called exactly once per slot, never inside a variable-length .map().
Border tiers: the surface tier the stack sits on determines the tile border color (so the tiles cleanly separate from the panel they overlap). channel → border-surface-channel (sidebar); chat → border-surface-chat (chat area / welcome header / chat header); modal → border-surface-elevated (modal hero, mobile info-screen hero — there is no surface-modal token in tailwind.config.js).
Usage sites (all six call sites in the codebase):
| Site | Size | Border | Notes |
|---|---|---|---|
DmListItem.tsx (sidebar row) |
32 | channel |
Pinned to the DM sidebar; iconUrl={dm.icon} |
MessageList.tsx WelcomeHeader (group-DM branch) |
80 | chat |
Large hero on the empty-state header |
MainContent.tsx chat header (group-DM branch) |
32 | chat |
Replaces the people-icon for group DMs |
GroupDmSettings.tsx (modal hero + member-row previews) |
varies | modal |
Modal Overview tab |
MobileGroupDmInfo.tsx (pushed-screen hero) |
80 | modal |
Mobile info screen hero |
MobileDmsScreen.tsx (DM list rows) |
40 | channel |
Mobile sidebar parity with desktop DmListItem |