Files
backspace/docs/systems/design-system.md
T
Jannis Braun 87ecf0f4f3 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.
2026-05-10 23:18:24 +02:00

12 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().

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