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.
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.
Closes the mobile parity push.
- MobileVoiceJoinSheet: full pre-join camera preview. Dormant-by-default (never auto-fires getUserMedia), explicit user gesture to arm, hard-bound disarm on sheet close (any path). Camera picker popup portaled to document.body so a long device list stays scrollable above the aspect-video preview tile (max-height min(50vh,320px), iOS scroll momentum). iOS Safari-safe: autoPlay playsInline muted + post-await play().
- MobileSettingsScreen: Keybinds + Desktop sections gated on isElectron(); they reuse the existing KeybindsPanel/DesktopPanel which are already mobile-fit. screenMap entries added in MobileShell. (Verify on Electron desktop build at narrow viewport.)
- ToastContainer: real overlap was hiding the voice-fullscreen control bar. Mobile branch now reads isMobile/mobileStack/currentVoiceChannelId and computes the bottom offset across five mobile states (voice-full / pushed+voice / pushed / root+voice / root), all with safe-area-inset-bottom; left-3 right-3 + items-center keeps toasts in the safe-tap zone. Desktop bottom-6 right-6 unchanged.
Specs: docs/systems/mobile-ui.md (Toast Positioning section, screenMap rows, Electron-entry rationale, z-index row); docs/systems/voice.md (Mobile pre-join preview subsection covering lifecycle + camera picker portal).
Fixes a render bug where a federated user (e.g. axel@nova) appeared with
the federation globe icon and a broken avatar when viewed on his own home
instance. Root cause: `populateFromReady` is first-wins by federatedId and
discards the entire skipped DM payload — including its `members` array —
so when a sibling instance's ready arrived first, the home instance's view
of every shared user was dropped on the floor.
Adds a render-only `userViews` cache that mirrors the `dmAlternatives`
philosophy: information from skipped ready payloads is preserved for
rendering. Every wire surface that delivers a User upserts into the cache
regardless of dedup outcome; render sites read through a Zustand selector
hook to surface the home view when one is loaded. The DM channel ingestion
race is left untouched — the existing no-flapping invariant on origin
reconnect is intentional and load-bearing for failover.
Layered changes:
- `identity.ts`: `normalizeOriginToHost`, `canonicalUserKey`,
`isDeliveryFromHome`, `isFederationGlobeApplicable` — single helpers
for origin/host normalization and the home/stub tier decision.
- `spaceStore.ts`: `userViews` Map, `UserViewEntry` type, `upsertUserView`
action with the home-wins preference rule, prune by `deliveredBy` in
`removeInstanceSpaces` (mirrors `dmAlternatives` cleanup), `reset`
clears.
- `userViewLookup.ts`: `useCanonicalUserView` (Zustand selector hook for
React) + `getCanonicalUserView` (sync getter for non-React paths).
Render reactivity is structural via the selector, not coincidence on
legacy update paths.
- `populateFromReady` upsert pass runs BEFORE the federatedId dedup so
members of skipped DMs still reach the cache.
- WS handlers (dm_message_*, message_*, user_updated, member_joined,
friend_request_*, dm_channel_created, dm_member_added) and REST
hydrators (socialStore, discoverStore, mutuals) feed the cache with
their delivering origin.
- Render-site routing through `useCanonicalUserView` at every audited
user-rendering site (sidebar, header, search, message bubble, reply
chips, profile popout/modal, group settings, voice tiles, mention
chips, member lists, friends, invites). Self-rendering sites compose
alongside via existing `isSelf`/`resolveDisplayIdentity`.
- Globe predicate hoisted to `isFederationGlobeApplicable` and applied
at three sites, gating on `domain !== window.location.host` so we
never show the globe for users whose home IS our own.
Tests: 31 new unit tests across `identity`, `userViews` store, and
`userViewLookup`. Full suite 276/276.
Docs: `client-federation.md` §3 gains a "User View Cache" section
parallel to "DM Origin Failover"; `dm-system.md` notes the new store
action and WS handler upserts.
Bug 3 (federation profile-sync gap — orbit's stale profile data on
nova-Axel after a clear/color-change on nova never propagated)
remains open. The user-view cache routes around it for the common case
(home instance is connected), but the underlying S2S relay gap is its
own diagnosis and follows in a separate branch.
The fallback <div>'s inline style only carried `background` when src was
falsy, so an <img> that 404'd flipped to display:flex via onError but with
no gradient applied — surfacing a colorless letter for any broken avatar
URL. Always paint the gradient + fontSize; keep `display:none` as the only
src-conditional override.
requestFullscreen() on the voice container puts only its descendants in the
browser's top layer; overlays portaled to document.body were rendered outside
that layer and stayed invisible — most visibly the right-click context menu on
stream tiles and voice user panels.
Add usePortalContainer() hook returning document.fullscreenElement ?? document.body
and re-rendering on fullscreenchange. Migrate every overlay reachable during a
call: ContextMenuRenderer (desktop, submenu, mobile sheet), Tooltip,
ConfirmDialog, ConnectionInfoPopover, ScreenShareSettingsPopover, and
ScreenSharePicker (which previously rendered inline at App root).
- AutoLaunchSettings: remove pre-await state writes; backend response is
now the sole writer to openAtLogin/startMinimized state, eliminating
the flicker caused when optimistic values diverged from OS truth
- Add busy flag across IPC round-trip; both Toggles receive disabled={busy}
- Start-minimized Toggle additionally receives disabled={!openAtLogin},
preventing interaction when it would have no effect
- Toggle.tsx: add optional disabled prop (forwarded to <button disabled>)
with cursor-not-allowed opacity-50 visual feedback; non-breaking for
all existing callers
- Remove tinted description box, use plain text
- Buttons are now flex-1 equal width side by side
- Cancel gets visible bg-interactive-hover background
- Larger padding, rounded-xl, wider max-w
All DM creation and add-member call sites now use the home api client
and pass homeUserId/homeInstance instead of routing to the remote instance.
Also updates addMember in the API client to accept AddDmMemberRequest.
Use parseFederatedUsername() consistently to show base username instead
of raw user@domain format. Replace inline domain text in Username
component with compact globe icon + tooltip. Add globe icons to DM
header and sidebar for federated users.
Calling e.preventDefault() on touchstart inside [data-context-menu]
elements prevented the browser from synthesizing click events
(touchstart → touchend → click chain). This broke tapping on spaces,
channels, DMs, and every other surface with data-context-menu.
Text selection prevention is already handled by CSS user-select: none
on [data-context-menu] elements — the JS preventDefault was redundant
and destructive. Reverted touchstart listener back to passive.
iOS triggered both the app context menu and native text selection overlay
on long-press. Three-layer fix: CSS user-select:none on buttons/[role=button]/
[data-context-menu] elements; non-passive touchstart listener with conditional
preventDefault on context-menu targets; removeAllRanges() at timer fire to
clear any residual selection. Added data-context-menu to all mobile surfaces
that use onContextMenu handlers.
Add -webkit-touch-callout: none on mobile viewports to prevent the
native iOS copy/lookup popup from racing with our context menu.
Clear any text selection when the long-press timer fires.
Add useGlobalLongPress to ContextMenuRenderer that detects 500ms hold
with <10px movement and dispatches a synthetic contextmenu event on the
touched element. All existing onContextMenu handlers (spaces, channels,
voice users, messages, etc.) now work on touch devices automatically.
Remove per-message useLongPress integration from Message.tsx since the
global handler covers it.
The svgRef was created but never passed to the sub-component SVG
elements, meaning it would always be null when the animation hook
tries to use it. Now passed via SvgProps interface.
Implement the <Mascot> component that renders Nori in four emotional
states (idle, sleeping, excited, lonely) with distinct SVG geometry,
color palettes, and facial expressions. Includes useMascotAnimation
hook stub for Task 2 and full test coverage (15 tests).
Replace bg-surface-elevated cards and inline text badges with glass-pill
rows featuring colored left borders (mint=playing, sky=listening,
lavender=watching, rose=streaming). Identical treatment in both
ActivityPanel and MemberSidebar — no more compact/full distinction.
Members with rich activities get the frosted glass row; others stay plain.
- Task 7: Create activityStore (Zustand) with Map<userId, Activity[]>, showActivity toggle,
debounced pushActivities, and reset on logout
- Task 8: Wire WS integration — presence_update propagates activities to store,
ready payload initializes userActivities and showActivity (via setState to avoid side effects)
- Task 9: Create ActivityCard component with compact/full modes, type-colored labels,
elapsed time display, asset images, and fallback to customStatus
- Task 10: Upgrade ActivityPanel to three groups — active friends (full cards, no header),
online friends (compact), offline friends — using getPrimaryActivity for grouping
- Task 11: Upgrade MemberSidebar to show compact ActivityCards instead of raw customStatus
- Task 12: Add showActivity toggle in AccountPanel settings (Activity Status section)
with API persistence and store sync
- Custom screen share picker for Electron (ScreenSharePicker.tsx) with
Aether Drift design: glass-bubble footer, adaptive grid, pill tabs,
border-based selection (avoids overflow clipping), hover brightness
- Instance URL picker so Electron connects to any Backspace server
- System audio loopback on Windows/Linux via desktopCapturer callback
- macOS: video-only callback (OS blocks system audio capture)
- IPC bridge for screen source enumeration and selection
- Purge stale service worker caches on Electron launch
- Dev/prod URL auto-detection (Vite 5173 in dev, server 3000 in prod)
- Typed IPC bridge via preload (notifications, badge, window controls, updates, deep links)
- Native OS notifications via NotificationController with window focus suppression
- Auto-update via electron-updater with UpdateToast UI
- Deep linking (backspace:// protocol) for macOS and Windows/Linux
- Window state persistence (position, size, maximize across restarts)
- Tray icon with graceful fallback when icon asset missing
- Suppress PWA service worker polling/reloads inside Electron
- Platform detection layer (isElectron, getElectronAPI)
- Root workspace scripts (dev:desktop, build:desktop)
- Document BACKSPACE_URL and BACKSPACE_UPDATE_URL env vars
- Resize avatars/icons to 256px and banners to 1280px (client crop + server safety net)
- Add server-side resizeProfileImage() for federation/API uploads without crop modal
- Fix unconstrained crop on RegisterPage and CreateSpace (was missing maxOutputDimension)
- PWA: switch to autoUpdate with skipWaiting/clientsClaim for seamless deploys
- Storage janitor: exclude profile images from unlinked cleanup, delete stale thumbnails
- Add deleteAttachmentByFilename() to clean orphaned attachment records for profile images
- Migration: one-time cleanup of stale profile image attachment records
- GeneralPanel: wrap in <form> to prevent implicit submission
- SSRF protection: DNS resolution + private IP blocking on metadata fetcher
- Upload security: CSP/X-Frame-Options headers, SVG forced download, nosniff
- Auth hardening: JWT secret min length, password min 8 chars, token revocation via password_changed_at
- Attachment ownership verification before linking to messages
- Message length limit (4000 chars) enforced on client and server
- Asset URL validation on avatar/banner updates
- Federation instance validation (domain regex, origin scheme, length limits)
- DB indexes on all FK columns for query performance
- Migrations: nullable moderator columns, dm_messages reply_to FK constraint
- File cleanup on avatar/banner replacement and space deletion
- Fastify trustProxy, AbortController on fetches, typing map size cap
Avatars/banners now resize to max 512px/1920px and convert to WebP before
upload (zero server cost). Chat image uploads generate an 800px-wide WebP
thumbnail via Sharp; the feed shows the thumbnail, click opens the full-res
original. Adds lazy loading to avatars. Federation-compatible: remote
instances without this feature fall back gracefully.
- Rewrite ChannelSettingsModal with full tri-state permission override UI
for roles and members (allow/neutral/deny per permission bit)
- Switch font from Inter to self-hosted DM Sans (woff2 variable fonts)
- Add client-side VIEW_CHANNEL filtering in ChannelSidebar for private channels
- Broadcast isPrivate flag on channel override changes
- Fix voice permission bit migration: gate behind persistent flag to prevent
repeated re-runs that stripped STREAM from @everyone roles
- Add speakingUserIds set to voice store for efficient user-level lookups
- Clear current channel view when a channel is deleted
- Move .glass-strip to @layer utilities for proper CSS specificity
- Simplify avatar initials font size to proportional formula
Context menu insertion before floatingPanel shifted its fragment index,
causing React to unmount/recreate the DOM element on DM↔space transitions.
The ResizeObserver was left observing the old detached element, so
paddingBottom never adjusted and channels scrolled behind the panel.
- Reorder fragment children so floatingPanel is at index 1 in both views
- Replace useRef with callback ref so ResizeObserver reattaches on remount
- Add stopPropagation to ContextMenu to prevent sidebar menu conflicts
- Restyle sidebar create channel/category buttons (smaller, separated)
Add .glass-modal CSS class (0.82 opacity) for dialogs and apply
consistent glass tiers across all floating UI: modals, context menus,
tooltips, popovers, and call cards. Replaces ad-hoc bg-surface-elevated
and inline styles with the documented 5-tier glass hierarchy.
Broadcast user_updated events over WebSocket when profile fields change,
updating members, DM participants, friends, and cached messages in real time.
Widen useVoiceParticipantMeta to return the full user object and add a
standalone avatarColor prop to Avatar so all ~16 callsites now resolve
the user's chosen gradient color instead of falling back to hash-based colors.
Add showAt prop to Username component and use it in both UserProfilePopout
and UserProfileModal to consistently display @username for all users.
Also move avatar ring styling into Avatar component's ring prop.
Replace element-level opacity on banner fallback divs with rgba-based
gradient color stops via new mutedGradient() utility. This prevents
compositing layer artifacts that caused visible color seams at the
banner boundary in AccountPanel, UserProfilePopout, and UserProfileModal.