A CRDT at the core, UI at the edges
OpenBook keeps state in a Yjs document and treats the editor as pure UI over it. That one decision makes collaboration, offline and undo fall out for free.
The document is a Y.Doc
Every page is a Y.Doc. Blocks live in a top-level Y.Array of Y.Maps; rich text is Y.Text; containers (columns, tables, groups) nest through a child Y.Array. Because all state is CRDT state, two tabs, two people, or one device coming back online merge without conflicts.
Y.Doc
└─ "blocks": Y.Array<Y.Map> // ordered block tree
├─ Y.Map { id, type, props, text: Y.Text, children: Y.Array }
└─ … // containers nest via childrenThe editor is pure UI
BlockEditor renders the tree and owns only transient UI — selection, the slash menu, drag state. It holds no document state of its own. That keeps the render path simple and makes the same component work for the live app, a read-only export, and present mode.
Reactivity is a scope, not a graph
Named inputs (sliders, steppers, text fields…) publish name → value into a per-document scope. Formulas and charts evaluate plain JavaScript expressions over that scope and recompute whenever any input changes. Values are ordinary block props, so they sync like everything else.
Persistence is thin
Snapshots encode/decode the doc to JSON for storage; the desktop app persists locally and the optional sync layer ships CRDT updates. The page store is a small Postgres-backed service with a documented HTTP API — see self-hosting.