config.ts requires JWT_SECRET at module-load time. Since `.env` is gitignored
it doesn't propagate to git worktrees, fresh clones, or CI without secrets —
producing 200+ confusing cascade failures (vitest module-mock errors that
look unrelated to the real cause).
Add a vitest setupFile that sets JWT_SECRET to a fixed test-only value via
`??=`, so a real .env still wins where one exists. Tests are now hermetic:
clone the repo, `pnpm install`, `pnpm --filter server test` → 477/477 pass.
Multi-pass mobile chat polish landed across this session.
- MessageInput is now a floating glass-bubble (`position: absolute`) on both desktop and mobile — last messages scroll *behind* the translucent bubble. MobileChatScreen wraps MessageList + MessageInput in a `relative` parent so absolute positioning resolves. Removed the prior in-flow mobile branch that clipped message-list bottom against an invisible barrier.
- iOS PWA keyboard handling: new `useVisualViewportInset` hook subscribes to `visualViewport.resize/scroll` AND polls `vv.height` for ~600ms after focusin (iOS PWA standalone often fails to dispatch resize for keyboard transitions). MobileShell sizes its container to `vv.height` when keyboard is open — `bottom: 0` on the composer naturally lands flush with the keyboard top, regardless of how reliably resize events fire. Composer uses 6px gap above home indicator (keyboard closed) and 0px gap above keyboard (keyboard open). Added `interactive-widget=resizes-content` viewport meta as the cleaner native equivalent for Chrome/Android.
- MessageList bottom padding is dynamic via `--composer-clearance` CSS variable. MessageInput writes `composerHeight + bottomOffset + 12px` to its parent via ResizeObserver — re-fires on textarea autosize, reply banner, attachment tile growth, parent resize. Last message always has 12px breathing room above the bubble regardless of composer state.
- AttachmentRenderer generic file chip: `max-w-full sm:max-w-[400px]` on outer + `min-w-0` + `flex-shrink-0` on icon + `flex-1` on text + `flex-wrap` on badge row. Long filenames now ellipsize cleanly on narrow viewports instead of pushing the chip off-screen.
- New `useDragToClose` hook: shared bottom-sheet drag-down-to-dismiss gesture. Spread on handle/header only (body scrolling unaffected). 6px deadzone, 100px or 0.5px/ms velocity threshold, 200ms `cubic-bezier(0.22, 1, 0.36, 1)` close-out animation, rAF-staged transform for a stable from-value. `hasInteracted` latch prevents the open keyframe from re-firing mid-close (the bounce-up-then-vanish bug). Wired into InputPopover (emoji/GIF), MobileVoiceJoinSheet, MobileFolderSheet.
Specs: docs/systems/mobile-ui.md (Floating Composer + Drag-to-Close sections), docs/systems/message-list.md (--composer-clearance), docs/systems/design-system.md (glass-bubble row references).
Brings mobile to skeleton parity with desktop. Two mobile-specific renders, plus a fix for the empty-state mascot flashing on every space switch.
- MobileSpacesScreen: shimmer skeleton for the channel list while `useSpaceStore.loadingSpaceId === selectedSpaceId` (uncategorized rows + category header + categorized rows). Mirrors desktop ChannelSidebar's `showChannelSkeleton`. Gated through `useDelayedLoading` so cached/fast loads don't flash the placeholder.
- MobileMembersScreen: shimmer skeleton for the member list (two role-section headers + circular avatars + name bars). Mirrors desktop MemberSidebar's `showMemberSkeleton`.
- Empty-state "No channels yet." mascot was firing on EVERY space switch for ~50–200 ms — `state.channels` only ever holds the most-recently loaded space's channels, so `spaceChannels` filters to `[]` immediately on selection change while `loadSpaceDetail` is still in flight. New `loadedSpaceIds: Set<string>` on useSpaceStore, populated only on `loadSpaceDetail` success and pruned on space removal events. The mascot is now gated on `loadedSpaceIds.has(selectedSpaceId)` so it only fires when the space is *settled* with zero channels.
Reuses the existing `.skeleton` / `.skeleton-bar` / `.skeleton-circle` CSS primitives + `useDelayedLoading` hook (no new shared `<Skeleton />` primitive — sites need row-specific geometry, parameterizing would either over-abstract or duplicate the inline approach).
Specs: docs/systems/mobile-ui.md (Loading Skeletons inventory + per-screen skeleton bullets + settle-gate rationale); docs/systems/spaces.md (Client Load State subsection covering loadingSpaceId vs loadedSpaceIds).
Two compounding root causes produced a "PiP-style grey view" briefly visible after Join Voice on mobile:
1. MobileSpacesScreen.handleVoiceJoin pushed 'voice' onto the mobile stack. MobileShell.screenMap only has 'voice-full' — the renderer returned null for 'voice' while the root screen sat under `visibility: hidden` (stack length > 0). Corrected to 'voice-full' with an explanatory comment.
2. AppLayout mounted the desktop <PictureInPicture /> in the mobile branch too. Its visibility check `(isInServerVoice || isInDmCall) && !voiceFullscreen` is true the moment currentVoiceChannelId is set, so the desktop PiP rendered as the only visible UI on top of the hidden mobile shell. Removed from the mobile branch; kept on desktop. Added a comment to prevent regression.
Mobile owns its voice overlay chrome exclusively via MobileVoiceMiniBar + MobileVoiceFullScreen.
Spec: docs/systems/mobile-ui.md gains a "Voice Join Flow (Mobile)" subsection and a leading paragraph in "Voice Overlay" stating that PiP is desktop-only.
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).
Adds sticky `lastSelectedSpaceId` to useSpaceStore. Updated on every
setCurrentSpace(non-null) and on loadSpaceDetail success; NOT cleared by
setCurrentSpace(null), so the @me URL effect in AppLayout no longer wipes
the memory. Cleared only when the remembered space is actually removed
(deleteSpace / leaveSpace / removeSpace / removeInstanceSpaces / reset).
Ephemeral — URL drives initial state on reload.
MobileBottomNav.handleTab('spaces') and MobileSpacesScreen's initial
useState now resolve via `currentSpaceId ?? lastSelectedSpaceId`. The
prior Object.keys(lastChannelPerSpace)[0] fallback was wrong twice over:
first-inserted ordering locked the tab to whichever space the user
opened first in their session, and any approach reading currentSpaceId
alone breaks after a /channels/@me detour.
Also fixes a pre-existing render loop in MobileSpacesScreen's
currentSpaceId sync effect — `selectedSpaceId` removed from deps,
functional setState makes the update idempotent.
Spec: docs/systems/mobile-ui.md updates Tab Tap Behavior, MobileScreenHeader,
and LocalStorage Persistence sections.
iOS Safari auto-zooms (and shifts the viewport right) on focus when the
computed font-size is <16px. Single @media (max-width: 767px) block in
globals.css covers all four input tiers, every relevant bare <input>
type, <textarea>, <select>, and contenteditable surfaces. `!important`
is required because Tailwind utilities (text-[15px] on the chat composer,
text-sm on form fields) emit after @layer components and would otherwise
override the input-tier rules.
Replaces ad-hoc per-input `text-base md:text-sm` overrides with a single
global truth. New inputs/contenteditables get the fix for free.
Spec: docs/systems/design-system.md gains an "iOS Auto-Zoom Suppression"
section under Input Tiers.
Commit 7309f44 removed the friend-add trigger because the server now
handles all routing/peering/lookup; the commit message reserved the
modal for 'Connections settings and space-join flows' but neither flow
ever wired it back in. grep across packages/web/src finds only
self-references — pure dead code.
Removing 162 lines of UI plus the stale vi.mock in FriendsPage.test.tsx.
If a per-instance password-re-entry consent UX is ever needed, the
useInstanceConnect hook is the actual abstraction and the modal can be
rebuilt cleanly with current primitives (Modal mobileStyle="fullscreen",
ContextMenuRenderer's bottom-sheet, etc.).
Upload Limit, Stale Uploads, and Media Retention rows now use
flex-wrap with gap-x-3 gap-y-2; input + unit pairs that must stay
together are wrapped in a nested flex group so they migrate as a unit.
Desktop spacing identical (gap-x-3 ≡ original gap-3 when content fits
on one line).
MobileChatScreen DM header now applies parseFederatedUsername and
useCanonicalUserView, matching MobileDmsScreen so federated users render
as 'realname' rather than 'realname@domain'.
Mobile Voice & Audio renamed to Voice & Video across MobileSettingsScreen
and MobileYouScreen — parity with desktop's VoicePanel title.
AudioInputSection and VideoSection picker click-outside now listens for
touchstart alongside mousedown so a single tap dismisses on iOS Safari
(which doesn't synthesize mousedown reliably from touch).
AudioOutputSection feature-detects HTMLMediaElement.setSinkId at module
load and returns null on unsupported platforms (notably iOS Safari).
Split into outer gate + inner body to keep Rules of Hooks intact;
VoicePanel's space-y-5 collapses cleanly with no visible hole.
VideoSection 'Stop preview' CTA gets responsive sizing (mobile:
always-visible 44 px tap target; desktop: original hover-reveal pill via
md: prefixes). The preview <video> gains autoPlay so iOS Safari starts
the stream when srcObject is assigned — manual videoEl.play() after an
awaited getUserMedia loses the user-gesture context on iOS and was
silently rejected by .catch(()=>{}), causing the black-preview bug.
voice.md updated for the iOS hide-when-unsupported policy and the
touch-close contract on device pickers.
AppLayout's auto-channel-redirect (turning /channels/<spaceId> into
/channels/<spaceId>/<firstChannelId>) is desktop-correct but on mobile
catapulted users past the channel sidebar straight into a chat — Join
from a SpaceInviteCard or Spaces-tab tap both inherited this. Guard
the effect with isMobile so /channels/<spaceId> settles at the channel
sidebar overview on mobile.
SpaceInviteCard's join handler now switches to the Spaces tab and seeds
spaceStore.currentSpaceId on mobile before navigating — clears the chat
stack the invite was tapped from and lands the user at the joined space's
channel sidebar instead of stuck behind the originating DM.
MobileSpacesScreen syncs its local selectedSpaceId from currentSpaceId
when external code (the Join handler) seeds the store — covers any future
programmatic space switch too.
MobileInstancePanel gains Registration and Federation entries (parity with
desktop's six sub-tabs). MobileShell registers the matching screenMap
wrappers; the Federation entry surfaces the live approval-count badge via
a new uiStore slot and a wrapper that forwards FederationPanel's
onApprovalCountChange.
MobileShell's deep-link reconstruction now reacts to post-mount pathname
changes (was [] / mount-only) with an idempotency guard that skips the
push when the topmost stack entry already represents the new URL —
prevents the pushMobileScreen → history.pushState → location-effect
double-push.
RegistrationPanel's four portaled modals (CreateInvite, EditInvite,
ReinstateInvite, Redemptions) now render through the shared <Modal>
with mobileStyle="fullscreen", portaled to document.body to escape the
parent settings dialog's backdrop-filter containing block. Desktop
appearance preserved (same maxWidth, same sticky action bar).
useWebSocket's ready handler calls loadMessages(channelId, true) from
two adjacent code paths in the same handler invocation (remote-space
refresh + per-origin cache clearing). Both fired in parallel because
force=true bypassed dedup, producing the duplicate /messages requests
visible in playwright network traces even after the parallel-mount
dedup landed.
Coalescing force=true with an in-flight call is safe: the requests hit
the same endpoint with the same params and would return the same data,
and the force caller still gets a fresh result via the shared Promise.
Live network trace via playwright showed every channel mount firing TWO
parallel GET /channels/:id/messages requests (AppLayout + MessageList
both call loadMessages on mount; neither can be removed because
MessageList is also rendered by VoiceChatPanel which doesn't call it),
and every fast-scroll burst firing N parallel loadMoreMessages calls
(handleScroll closure shares !isLoadingMore=true across the burst).
The hasMore-based guard only deduplicates calls AFTER the first
completes — it does not collapse parallel callers. On a NAT hairpin'd
LAN where some federated TCP connections hang, having N hung parallel
fetches keeps the pagination skeleton stuck while any one is still
waiting on the 30 s api-client timeout.
Fix: in-flight Promise dedup at the chatStore level. Module-level Maps
keyed on channelId; concurrent callers receive the same Promise.
Cleared in finally with self-check so a force-reload mid-flight isn't
dropped. force=true bypasses dedup (WS reconnect intent).
Two cooperating bugs let the pagination skeleton stick at the top of the
chat across channel switches: (1) the browser's post-clamp scroll event
on channel switch fired the load-more block on the new channel, and
(2) useDelayedLoading's threshold timer refreshed displayStart on every
fire, extending the minDisplay window unboundedly when isLoading cycled.
- useDelayedLoading: stamp displayStart only on the false→true show
transition (functional setShow form to avoid stale closures).
- MessageList: suppressNextLoadMoreRef armed in Effect 3, consumed by
the load-more block on the next scroll event, with a 250 ms fallback
disarm so legitimate user scrolls aren't silently dropped. Suppression
scoped to the load-more block only.
- Regression test for the displayStart refresh bug.
- Updated docs/systems/message-list.md history.
S2S federated-friends username + presence relay. Closes the original repro
(youruser on nova saw pbtest3/pbtest4 from orbit as snowflake-ID with stale
offline status when not client-federated to orbit).
Phase A — username:
- Stub creation uses realname@domain when wire snapshot carries a username hint
- profile_update payload extended with username, receiver applies displayName
?? username fallback so null-displayName home users don't clobber stubs
- New /api/federation/users/by-home-id reverse-lookup endpoint + helper
- Stub backfill worker heals legacy snowflake-named stubs (per-peer, on
activation + startup pass)
Phase B — presence:
- New S2S presence_update relay event + sender helper + 5 call sites
- Receiver updates stub status + broadcasts WS to friends/DM/spaces
- Local broadcast scope widened to friends + DM + spaces (matches user_updated)
- Peer-activation snapshot (relationship-scoped) + deactivation mark-stubs-offline
- Outbox-only delivery (no mutation log) — presence is ephemeral
Follow-on fixes after live verification:
- Outbox-to-relay event rebuild was missing presenceUpdate (allowlist gap)
- Profile snapshot now carries status — freshly-friended online remotes show
online immediately, not stuck at offline until next transition
- spaceStore.updateMemberPresence patches userViews so the avatar green dot
re-renders on WS presence_update (was reading from a different cache than
the 'Online' text label)
Docs: federation.md, activity-presence.md, social.md, websocket.md updated.
Verified live on Pi + VM (nova + orbit).
Two follow-on bugs from the initial S2S presence rollout:
(1) New friend stuck offline until they reload: presence_update fires only on
transitions, so a remote user already online when their stub is created
locally never receives a relay event seeding their actual status. The
stub defaulted to 'offline' at creation and stayed there until the next
transition. Fix: extend FederationRelayProfileSnapshot +
FederationUserLookupProfile with status. Sender-side buildProfileSnapshot,
getDmParticipants, and lookup endpoint responses populate it for native
users only (replicated stubs hold stale status owned elsewhere).
resolveOrCreateReplicatedUser uses hints.status to seed the new row's
status column. Threaded through every call site (DM participants, group
bootstrap, friend events, ownership transfer). Stub backfill worker also
heals existing rows whose status was stuck at 'offline' from creation.
(2) 'Online' text updates but green avatar dot stays grey on the same page:
spaceStore.updateMemberPresence patches members[] (which feeds space UIs)
but never patches userViews — the cache useCanonicalUserView reads from.
The Avatar in FriendItem reads canonical.status; the text reads
friend.status (socialStore). Two sources, one stale until full
user_updated arrives. Fix: updateMemberPresence now mirrors status into
matching userViews entries, so canonical-view consumers re-render with
fresh status the moment the WS event lands.
The outbox worker rebuilds FederationRelayEvent objects from stored JSON via
an allowlist of known fields. presenceUpdate was missed when presence_update
events shipped, so peers received events with eventType='presence_update' but
no payload — rejected with missing_presence_update_payload on every tick.
onPeerActivated re-emits a presence_update for every relationship-related
online native to the activating peer (relationship = friend with peer-stub /
DM-mate with peer-stub / replicatedInstances opt-in for peer origin). Scope
bounded by relationship count, not native count — flap recovery cost stays
proportional to actual interaction surface.
onPeerDeactivated flips every replicated stub from that peer to offline and
broadcasts a local presence_update so connected friends/DM-mates/space-co-members
see them disappear immediately, instead of seeing stale online until next signal.
Re-snapshot on every activation (incl. health-check unreachable→active flap)
is load-bearing for correctness — markPeerStubsOffline ran on the prior
deactivation and presence is not in the mutation log.
Six WS sites that previously broadcast presence_update to spaces only now use
collectProfileBroadcastTargetIds (the same recipient set as user_updated):
- ws/handler.ts finalizeDisconnect (offline)
- ws/handler.ts auth path (online)
- ws/events.ts handlePresenceUpdate (manual idle/dnd/online)
- ws/events.ts handleActivityUpdate (rich activity changes)
- routes/users.ts showActivity-toggle clear
- routes/users.ts status PATCH
Friends with no shared space + DM-only co-members now see each other's
online/offline transitions live, matching user_updated semantics. Federated
stub presence broadcasts (Task B3) use the same helper, so cross-instance
recipients are uniform.
Updates one assertion in social.federated.test.ts that asserted the old
snowflake-style stub username (now realname-based per A1).
processPresenceUpdateEvent updates the local stub's status and broadcasts a
WS presence_update to friends + DM members + space co-members of that stub
via collectProfileBroadcastTargetIds. Closes the doc/code drift in
activity-presence.md:147 — federated stubs now have their status projected
by the home instance as documented.
Strict attribution: payload.homeInstance domain must equal source peer
domain. Silently no-ops when no local replica exists (peer broadcast fanout
covers all peers, not all hold a stub).
New FederationPresenceUpdatePayload + queuePresenceRelay() helper. Five WS
sites now project the native user's status (and optional activities) to all
active peers via the outbox: WS auth-success, finalizeDisconnect,
manual presence_update, activity_update, showActivity-toggle clear.
Outbox-only (no mutation-log entry) — presence is ephemeral; the upcoming
peer-activation hook re-emits a fresh snapshot so peers recovering from
unreachable converge without history replay. No-op for replicated users.
Heals existing legacy stubs (created when resolveOrCreateReplicatedUser used
homeUserId@domain) by asking the peer for the canonical username via
lookupRemoteUserByHomeId and rewriting the local row. Idempotent and
collision-safe.
Wired into onPeerActivated (per-origin) so future peer flaps re-attempt for
stubs whose home was unreachable on a prior pass, and into a one-shot pass at
startupBootstrapSync for all currently-active peers (not just first-time
lastSyncedAt=0 peers).
HMAC-authenticated, rate-limited (60/min/peer) endpoint that resolves a
homeUserId on this instance to its canonical username + profile snapshot.
Native non-deleted users only. Mirrors /users/lookup's auth shape.
Adds lookupRemoteUserByHomeId to federationLookup.ts as the client-side
helper. Used by the upcoming stub-backfill worker on peers that hold legacy
snowflake-named replicas of users now visible by their real handle.
FederationProfileUpdatePayload gains `username`: the home user's canonical
handle. Receiver applies displayName ?? username so stubs whose home user has
no displayName show the real handle instead of getting clobbered to null.
Mirrors the existing fallback in hydrateReplicatedUserProfile. Username itself
is immutable on the home instance, so the receiver does not rewrite the stub's
username column on profile_update.
When friend_request_create / friend_add / DM relay carries a profile snapshot,
the canonical-username hint is now used as the stub's local-part. Stubs created
purely from S2S (no client-federation) now display the human-readable handle,
not the homeUserId snowflake. Falls back to the snowflake-id scheme only when
no hint is available.
RegisterPage step 2 writes the new token to localStorage but defers
initSession (and thus the authStore.token write) until after the avatar
upload, to keep AuthRedirect from yanking the user off /register
mid-upload. The home-origin branch of setTokenForOriginResolver was
reading authStore.token, so getTokenForOrigin('') returned null during
that window — transferStore.startUpload threw "not authenticated" and
the surrounding catch-and-ignore silently dropped the avatar. Align the
resolver with the home api client and read localStorage so both paths
share one source of truth for the home JWT.
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.
Without a sync-ready gate, a transient GET failure during autoConnectAll
left the local registry Map empty/incomplete while `set()` still computed
`registryUpdatedAt = Date.now()`. The trailing `syncRegistry()` would then
PUT the empty payload with a fresh timestamp; the server's LWW guard
accepted it and legitimate registry rows were wiped — including
remote-instance entries the user never explicitly removed.
Add `_registrySyncReady` flag, set true only after a successful initial
GET. `syncRegistry()` short-circuits while false, so the degraded mode
(GET failed) is display-only: the Map is still populated locally from
\`replicatedInstances\` synthesis (status \`auth_expired\`) so the
Connections UI shows the user's known remotes, but mutations don't push.
On the next session where GET succeeds, localStorage cached tokens
reseed the registry and \`syncRegistry()\` pushes the merged authoritative
state — no data lost, sync deferred until we have a complete picture.
\`reset()\` clears the flag alongside the registry. Spec updated with the
sync-ready gate and degraded-mode behavior. Unit tests cover the gate
on initial fail, mutations during degraded mode, recovery on next
successful GET, and the synthesis fallback for empty replicatedInstances.
The initial-load skeleton was rendered as an early return that replaced
the JSX containing `containerRef` / `contentRef`. On slow loads, the
200ms threshold flipped the skeleton on before messages arrived, so when
messages did arrive every scroll-affecting effect (Effect A, the
ResizeObserver, the load handler, scrollend) re-fired exactly once
against null refs and bailed — and never re-attached because no dep
changed when the skeleton finally cleared. Net: chat opened scrolled to
the top instead of the bottom; saved-anchor restore was equally broken.
Render the skeleton as an absolutely-positioned overlay alongside the
(always-mounted) scroll container so all refs stay live across the
loading transition. Encode the constraint in the spec as the
"ContainerRef invariant" so future loading/empty/error UI doesn't
reintroduce the early-return pattern.
Convenience wrapper around `pnpm build:all` in packages/desktop that
builds installers for mac/win/linux × arm64/x64 and collects the
distributable artifacts into packages/desktop/installers/. Excluded
from rsync deploy by deploy.sh.
Replaces desktop and web icon binaries with the new brand mark, ships
PNG raster exports under assets/brand/ alongside the existing SVG
sources, adds Alternative Styles artwork variants, and updates the
icon generation script and System Prompt doc. deploy.sh excludes
the new local-only artifacts (*.rtfd, assets/brand, electronbuild.sh,
multi-platform-roadmap.md) so they don't get rsync'd to the live
boxes. Drops ARCHITECTURE_AUDIT.md (obsolete).
spawnInstance now sets DISABLE_RATE_LIMITS=1 by default so unrelated tests
don't exhaust the shared 127.0.0.1 per-IP bucket. bootHomePlusRemotes/
bootTwoInstances accept an optional { enableRateLimits } that omits the env
for tests that need real enforcement, exposed via the explicit
bootTwoInstancesWithRateLimits() helper for Test #15 (rate-limit assertion).