Avatar opened the profile popout whenever it received a user prop. Since user is how every avatar gets its gradient, colour and status dot, all 22 call sites became profile triggers by accident — including the picture inside the profile card itself, which re-anchored the card to that picture on every click and walked it across the screen (120px right, 36px down, until it pinned at the viewport clamp). Avatar is now presentational. A new ProfileAvatar carries the open-the-profile behaviour at the five call sites that actually want it. The card's own picture escalates to the full profile modal instead of reopening the card. The card also places itself off its measured size via the shared computeFloatingPosition engine, replacing six call sites that each hand-computed coordinates against a guessed 460px card height. Closes #37
15 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().
Avatar vs ProfileAvatar
Two components, one deliberate split:
| Component | Role |
|---|---|
Avatar (ui/Avatar.tsx) |
Purely presentational. Takes user for the gradient, avatar colour, homeUserId and status dot. Clicking it does nothing unless the caller passes onClick. |
ProfileAvatar (ui/ProfileAvatar.tsx) |
Avatar plus the profile card. Opens UserProfilePopout anchored to its own box, stops propagation so it wins over an enclosing row handler, and stays inert while user is undefined. |
Rule: an avatar is only a profile trigger when it is a ProfileAvatar. Never re-add an implicit "open the profile if a user prop is present" branch to Avatar — passing user is how every avatar gets its colour, so that branch silently turns the picture inside the profile card, the settings preview, the avatar-upload button and every row in a modal into a trigger. It also made the card re-anchor to its own picture and walk across the screen on repeated clicks (issue #37).
Use ProfileAvatar when the avatar is the primary way to reach that person's profile and nothing else owns the click. Use Avatar when an enclosing row, button or list item already handles clicks, or when the avatar depicts the surface it already sits on.
Escalation chain. Clicking a face always moves one step deeper, never sideways and never nowhere:
| Surface | Picture click |
|---|---|
| Member tile / row / message author | Opens the preview card (UserProfilePopout) |
| Preview card | Opens the full profile modal (UserProfileModal) and closes the card |
| Full profile modal | Nothing — this is the terminus |
The middle step matters: an inert picture on the preview card is a dead end that forces the user down to the View Full Profile link. What it must never do is reopen the card itself — that is the drift bug from issue #37.
Floating placement
Every floating surface places itself with computeFloatingPosition (hooks/useFloatingPosition.ts): preferred side → flip when it would overflow → clamp into the viewport, with an 8px viewport padding.
- Components with a live anchor element use the
useFloatingPositionhook (tooltips, mention/search popovers, voice popovers). - Components opened from a store keep the anchor's rect instead of an element —
uiStore.openUserProfile(user, anchor, placement)storesAnchorRect+Placement, andUserProfilePopoutmeasures itself and places off that.pointAnchor(x, y)builds a zero-size rect for the rare caller with no anchor element. align: 'start'lines the surface's leading edge up with the anchor; the default centres it on the anchor.
Callers never compute coordinates. A surface that is handed a finished { top, left } cannot account for its own measured size, and any caller-side constant (an assumed card height, a hardcoded sidebar width) drifts the moment the content or the layout changes.
Tile geometry contract. Each AvatarTile renders at size × size with a 2px border (box-sizing: border-box from Tailwind preflight), so its content area is (size − 4) × (size − 4). The inner Avatar is sized to that content area (size − 2 · TILE_BORDER_WIDTH) and centered geometrically on the tile via flex items-center justify-center, not by inline-flow placement. Both corrections are required: sizing the Avatar to the outer dimensions overflows the padding box and gets clipped off-center (visible disc remains centered, but the avatar's contents — image crop, initials gradient + letter — anchor at the padding-edge top-left and visibly drift toward the lower-right of the visible disc); relying on Avatar's inline-flex placement makes the Avatar drift vertically by whatever the inherited line-height adds, independent of border. TILE_BORDER_WIDTH is exported from AvatarStack.tsx as the single source of truth for the border-2 width and must be updated in lockstep with any future change to that class.
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 |