flowss grew as a constellation of workstations: Studio, Study (NEXUS), Atlas, Lab, LucidFlow, Science, BPMN, Sketch. Each is strong alone, and the platform already shares a spine — the workstation registry (lib/studio-handoff.ts), the ⌘K palette (lib/command-registry.ts + GlobalCommandPalette), and the dashboard's work index (lib/dashboard/work-index.ts).
Continuum closes the remaining gaps that make the studios feel like separate apps rather than rooms of one building:
| Gap | Before | With Continuum |
|---|---|---|
| Memory | Studio has a real figure library; every canvas studio (LucidFlow, Science, BPMN, Lab, Sketch) remembers exactly ONE document | The Continuum library (lib/continuum/store.ts) — every studio's documents live in one canonical, multi-document library with rename/duplicate/soft-delete, surfaced at /library and on the dashboard. The Vault layers explicit checkpoints on top: a frozen copy of a studio's live keys you can restore on demand (think local history), which also powers cross-device sync pings |
| Continuity | Leaving a studio means losing the thread; the only cross-studio view is /dashboard | Pulse — a cross-studio recents feed, surfaced by the Flow Dock (⌘J) that floats in every workstation, and a "Continue" group at the top of ⌘K |
| Lineage | Handoffs (Send to studio…) deliver the work but forget the journey | Lineage — every handoff is recorded; the dashboard shows how work flowed Sketch → Studio → Atlas → Study |
| Search | ⌘K knows nav/engines/templates/Studio figures — not the rest of your work | Workspace — one enumerator over the Continuum library (every studio's documents) + vault checkpoints + NEXUS courses, searchable from ⌘K everywhere |
| Study | Diagrams and NEXUS courses never meet | Study this diagram — any diagram can be handed to NEXUS as course notes; the course creator picks it up and builds a diagram-first course from it |
Architecture
All of it lives in lib/platform/ (pure, unit-tested, node-safe) plus one shell component:
lib/platform/types.ts— shared types.lib/platform/vault.ts— snapshot/restore named CHECKPOINTS. A snapshot is a copy of the studio's live localStorage keys (the exact same channel disciplinestudio-handoff.tsestablished); restore writes them back and navigates.glyph:vault:v1. The living document list is the Continuum library (lib/continuum/store.ts) — the vault never replaces it.lib/platform/pulse.ts— bounded cross-studio recents ledger, written by the shell on studio entry and by handoffs/vault restores.glyph:pulse:v1.lib/platform/lineage.ts— bounded handoff ledger.glyph:lineage:v1.lib/platform/workspace.ts— merges the dashboard work index, the vault and the pulse into one searchableWorkspaceItem[]for ⌘K and the dock.components/platform/FlowDock.tsx— the continuity surface. Mounted fromcomponents/platform/PlatformBridges.tsx(the root layout's one client host) on workstation routes **and on/library,/flowand/dashboard** — the two cross-studio surfaces are about continuity, so a dock that was absent from exactly them made ⌘J read as unreliable. Without a studio it drops the studio-specific bands (checkpoint save, "Take it further") and keeps Continue, the Vault list, Journey and the phase reading.components/platform/ContinuumBoard.tsx— the same three bands laid out horizontally above the Library board. It shares the dock's vault-delete path (lib/platform/vault-delete.ts) and the platform's one "how long ago" (lib/platform/time-ago.ts); it still has its own markup, which is the remaining duplication in this layer.
Every module uses an injectable KV seam (defaults to window.localStorage) so the logic is fully testable under the node vitest environment, and every write is guarded — storage failures degrade to no-ops, never to crashes.
Why localStorage-key snapshots (and not a rewrite)
Each canvas studio already treats its localStorage key as its document of record (verified in lib/dashboard/work-index.ts). Copying those keys is therefore a complete snapshot by construction, and restoring them is exactly what the studio does on mount. The vault gets multi-document behaviour platform-wide without touching any studio's persistence code — the same "deliver through the channel the studio already understands" principle the handoff module proved out.
Live across devices
components/platform/ContinuumSync.tsx joins the per-user Realtime channel continuum:<userId> (same infra as CollabBridge): Pulse and Lineage ledgers broadcast whole (tiny, bounded) and union-merge idempotently on arrival; Vault changes send a metadata ping and receivers pull /api/cloud-sync after the sender's fast-debounce push lands. Merges write + notify only on real change, so echo loops converge by construction. Outbound broadcasts are throttled per event with BOTH edges — leading-edge-only dropped the tail of a burst, and a send that writes lineage and then pulse a millisecond later is a burst by construction, so the last state never reached the other device.
The library across devices
The cloud payload (components/CloudSyncBridge.tsx → /api/cloud-sync) carries four things: Studio's figure workspace, every document-keeping studio's live keys, the Vault, and — since the gap below was found — the Continuum library itself.
That last one was missing entirely. The blob had no glyph:continuum:v1 in it, so a second device signed into the same account got one canvas per studio, re-adopted by the legacy sweep under brand-new ids, and a Library that had never heard of the user's titles, tags, stars, provenance or trash — under a product that advertises whole-platform sync.
It cannot ride as another opaque canvas key, because those merge as one value each (a three-way merge per key against the copy both sides last agreed on, with a conflicting second copy set aside in the Vault), and a library is a set: a whole-value rule leaves one device's documents out. So it merges, and the rule is pure and lives in lib/sync/reconcile.ts (mergeLibraryIndexes):
- union by id — a document either device knows about survives;
- last-write-wins per document on
updatedAt, ties to local (which keepschanged: false, so nothing is re-persisted or re-broadcast — that answer is what stops two idle laptops becoming a broadcast loop); - soft deletes need no special case:
deletedAttravels on the document and trashing bumpsupdatedAt. The one addition is an exact-tie rule, shared with the cross-tab merge: on equal stamps the trashing wins, but never over an edit made after it; - hard deletes DO need one, because the document is gone and only the purge ledger remembers. A tombstone suppresses the document on both sides — unless it has been edited since the purge, in which case it was re-created and the tombstone goes instead (the store's own invariant: a document and its tombstone never coexist).
lib/continuum/store.ts owns the two ends: librarySnapshotForSync() never speaks for a library it has not loaded (an unhydrated route returns null, because a POST replaces the blob and an empty push would erase the account's library), and mergeRemoteLibrary() merges, commits and flushes.
It rides on a budget, and the budget is not optional. The library carries every document's source and data, and it goes up alongside the canvas bag (1.2 MB of the route's 2 MB cap) and the figure workspace. Sent whole, a real library takes the payload over the cap — and decidePush then blocks the entire push, so a user who previously had their figures and Vault backed up would end with nothing backed up at all. planLibraryDocs (lib/sync/reconcile.ts) therefore keeps the newest documents that fit LIBRARY_DOCS_MAX_BYTES and reports the rest through the same skipped-document channel a canvas doc uses. Whole documents only: a hollowed-out copy would land under a live id on the other device and win the merge, so a document either crosses intact or does not cross — and because the merge is a union, not crossing loses nothing on either side.
The realtime channel map
Every Supabase Realtime channel the platform opens, in one place. Each namespace prefix is distinct, so no two features can ever land on the same channel — new features must pick a fresh prefix and add a row here.
| Channel | Opened by | Carries |
|---|---|---|
sketch-live:<sessionId> | Sketch "Share live" (lib/live/useLiveSession) | Element sync (LWW items) · cursors · comments · voice |
lucidflow-live:<sessionId> | LucidFlow "Share live" | Node/edge sync · cursors · comments · voice |
science-live:<sessionId> | Science "Share live" | Node/edge sync · cursors · comments · voice |
bpmn-live:<sessionId> | BPMN "Share live" (lib/live/useLiveDoc) | Whole-doc sync (pen-coordinated) · pen · comments · voice |
lab-live:<sessionId> | Lab "Share live" (lib/live/useLiveDoc) | Whole-doc sync (pen-coordinated) · pen · comments · voice |
studio-live:<sessionId> | Studio ad-hoc "Share live" (lib/live/useLiveDoc) | {engine, code} sync (pen-coordinated) · pen · preview cursors · voice |
project-voice-live:<projectId> | Retired | Private presence/voice temporarily unavailable |
atlas-live:<projectId> | Retired | Private presence/voice temporarily unavailable |
project-sync:<projectId> | CollabBridge (cloud projects) | Empty change hints; documents use authorized HTTP |
room:proj:<projectId> | Retired | Private project cursors temporarily unavailable |
figure-presence:<projectId> | Retired | Private figure presence temporarily unavailable |
project-comments:<projectId> | lib/hooks/useProjectComments | Empty hints; comments use authorized HTTP |
continuum:<userId> | ContinuumSync | Pulse/Lineage merge + Vault change pings |
org-continuum:<orgId> | Retired | Team presence/source handoffs temporarily unavailable |
The <studio>-live:* rooms are the shareable multiplayer sessions — lib/live/session.ts derives the name from the studio slug, and lib/platform/live-now.ts announces whichever one this page is running so the Flow Dock shows a "Live" row (peer count · copy link · leave). Private project rooms are no longer auto-joined.
The team layer
Team presence and person-to-person source handoffs are temporarily unavailable. TeamPulse clears the roster and refuses handoffs without opening a channel. Saved team projects, permissions and component libraries remain available. Cross-studio handoffs within the user's own workspace are unchanged. See private collaboration cutover (launch/PRIVATE_REALTIME_CONTAINMENT.md).
Context verbs
The Flow Dock's "Take it further" chips read the current studio's live document and hand it onward through performSend — Research it (Atlas), Study it (NEXUS), or a canvas/code hop — the exact same pipeline as the Send-to-studio menus, so conversion and lineage recording ride along.
Event flow
studio entry ──────────────► pulse.touch(studio, title?, engine?) (the dock's route effect)
Send to studio… ───────────► lineage.recordHandoff(from → to) (only when `from` is a studio)
…then the ARRIVAL is what touches the Pulse,
via the dock's studio-entry effect above
Vault save ────────────────► vault.checkpointStudio(studio, title)
Vault restore ─────────────► write keys + detachActive + pulse.touch(restored studio) + navigate
Vault delete ──────────────► vault-delete.deleteCheckpoint(id) — one click, undoable for 6s
library write lost ────────► store PERSIST_FAILED_EVENT → sticky `local` sync status + one toast
library write recovers ────► store PERSIST_RECOVERED_EVENT → the banner comes down
⌘K search ─────────────────► workspace.listWorkspace() (figures + docs + vault + courses)
⌘K on a document ──────────► deliver.openInHomeStudio(doc) — one hop, not via /library
⌘J ────────────────────────► Flow Dock (recents · vault · lineage)
⇧⌘F on /study ─────────────► Study's cross-course concept search (⌘K is the platform palette
everywhere now, Study included)
Study this diagram ────────► localStorage glyph:nexus:handoff:v1 → /study
CourseCreator prefills notes from the payload
Two honest notes about that first pair, because the previous version of this document claimed more than the code does:
recordHandoffneeds a studio of origin.inferSourceStudio()resolves the current URL throughlib/platform/routes.ts(so/scholarand/biologyresolve, which a private copy of the table here used to get wrong for/scholar), and returnsnulloff a workstation — so a send launched from/library,/flowor a dashboard card records no lineage row. Giving those an origin means wideningLineageEvent.frombeyond the workstation registry, which is a change to the ledger's shape rather than a fix, and is not made here.- Nothing calls
pulse.touch(to)at send time. The touch happens on ARRIVAL, from the dock's studio-entry effect, which reads the target's live keys — so the row carries the delivered document's own title and engine instead of the sender's guess at them. The old wording described a call that does not exist.
Placement
Bottom-right is the Flow Dock's pill (draggable, position persisted per browser; the panel is capped at min(21rem, 100vw − 2rem) so it cannot hang off a narrow screen). Bottom-centre is the Toaster. Bottom-left is the sync-health banner (components/platform/SyncHealthBanner.tsx), which reports cloud failures AND the sticky local state for a browser that has stopped saving — it is mounted beside the storage alarm rather than inside CloudSyncBridge, because a signed-out visitor whose storage is full needs it just as much.
