Files
backspace/docs/systems/design-system.md
T
cnrd 8456b8f976 fix(web): stop the profile card re-anchoring to its own avatar (#39)
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
2026-08-25 15:51:04 +02:00

15 KiB
Raw Blame History

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-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 useFloatingPosition hook (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) stores AnchorRect + Placement, and UserProfilePopout measures 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). channelborder-surface-channel (sidebar); chatborder-surface-chat (chat area / welcome header / chat header); modalborder-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