Collaboration components (presence)
The presence UI for pages and databases (TASK-0093). Presence is ephemeral
(never persisted, no acks — the realtime doc §5.2) and room-scoped (a
non-member can't join the room, so the presence set is exactly the permitted
viewers). The client never sends its own identity: the server stamps
user {id, name, color} from the handshake + the displayName ?? name
resolver + the shared presenceColor(userId) (a client can't impersonate a
name or a color).
Files
PresenceBar.tsx— the "who's here" avatar bar in the page/database header. Shows the other peers (self is excluded — you're not "someone else"), each as a colored circle (the peer's palette color) +initialsfrom@factory/ui,role="img"with the accessible name"{name} is viewing this page". Max 5 visible + a+noverflow popover (click / Escape / outside-click to close) listing every peer with their "editing <block type>" or "viewing" state. The bar is a plain list (not a live region — it churns too fast for screen readers); the a11y announcement is the separaterole=statusregion. Empty state (0 others) renders nothing (an empty bar is noise).CursorLayer.tsx— the live-cursor overlay (an absolutely-positionedpointer-events: none,aria-hiddenlayer over the block list). For each remote peer with acursor: a caret in the peer's color + the mandatory name-label pill (color is never the sole identifier — the a11y rule); remote selections render as translucent (25 %) per-user highlights, multi-block selections span. Position is resolved via thepresence-editor-views.tsregistry (each per-block Tiptap editor self-registers its view; the layer re-resolves on scroll / resize + a 400 ms tick). A cursor whose block was deleted/moved concurrently is dropped silently (no orphan node — the peer's next caret move re-places it). Reduced motion: no CSS transition on the caret (instant).
Wire (the (b) decision)
Presence rides the JSON control frames, not the binary 0x02 awareness relay (reserved for TASK-0136's Yjs provider):
- Client → server:
presence {cursor?, viewing?}+presence-leave(identity is stripped — the server stamps it). - Server → client (per peer):
presence {pageId, user, cursor?, viewing?, lastSeen}+presence-leave.
Caret moves coalesce at 120 ms (trailing flush — the final position is
never dropped); caret clears and viewing changes are sent immediately.
The server re-broadcasts the current peer set on each join (deterministic
catch-up); beyond 50 peers it strips cursor/viewing (avatars-only, the
doc §5.1 rule). Presence frames carry the room key (page:<id> /
database:<id>) in pageId; the web frame handler stripRoomKey() normalizes
to the bare id (block frames already carry the bare id).
A11y (the R5 contract)
- The cursor overlay is
aria-hidden(decorative — the data is in the live region). - A polite
role=statusvisually-hidden live region announces join/leave and editing state changes only (a peer starts/stops editing a block), rate-limited to ≤ 1/s by the purePresenceAnnouncer(join > edit-startedit-stop > leave; a queued change flushes on a 1 s idle tick).
- The toast toggle ("Show join/leave notifications", default on) is a
ui-storesetting (presenceToastsEnabled, persistedfactory-presence-toasts) — the brief's "optional" resolved to on-by-default with the toggle.
Tests
- Unit (Docker-free-green):
lib/presence-emitter.test.ts(the throttle +assert()+ the wire payload),lib/presence-announcer.test.ts(the ≤ 1/s state machine),lib/presence-cursor.test.ts(the geometry: single/multi/ reversed selections, the stale-drop),lib/presence-color.test.ts(the hue map),lib/realtime-frame-handler.test.ts(the room-key strip), andpackages/shared/src/presence.test.ts(the deterministic color hash + the 1,000-user distribution gate). - Integration:
tests/integration/realtime/presence.test.ts(the join re-broadcast, the < 500 ms cursor move, the 50/51-peer cap, the leave, the 90 s prune). - E2E (Docker-gated, committed CI-ready):
tests/e2e/specs/presence-cursors.spec.ts(the 3-client presence + the cursor suite — the join→avatar delta is measured against the 500 ms metric).