Every page, component and the canvas scratch area is a PageDoc: a flat map of nodes with parent and children links. It is stored as one jsonb value per page, edited as a Yjs document, and rendered by the same renderer everywhere.
Shape
type NodeId = string
interface Node {
type: 'body' | 'section' | 'frame' | 'text' | 'link' | 'image' | 'button'
| 'richText' | 'collectionList' | 'form' | 'input' | 'textarea' | 'select'
| 'checkbox' | 'instance' | …
parent: NodeId | null
children: NodeId[]
props: Record<string, unknown> // literals, { $expr }, or { $model }
styles: Styles // { base: {…}, 'base:hover': {…}, tablet: {…} }
}
interface PageDoc {
schema: 1
root: NodeId
nodes: Record<NodeId, Node>
variables?: Record<string, VariableDef>
queries?: Record<string, QueryDef>
workflows?: Record<string, WorkflowDef> // client workflows only
interactions?: Record<string, Interaction>
}
{
"schema": 1,
"root": "n_root",
"nodes": {
"n_root": { "type": "body", "parent": null, "children": ["n_hero"], "props": {}, "styles": {} },
"n_hero": {
"type": "section", "parent": "n_root", "children": ["n_h1"],
"props": { "classes": ["cls_hero"] },
"styles": { "base": { "paddingBlock": "120px" }, "mobile": { "paddingBlock": "64px" } }
},
"n_h1": {
"type": "text", "parent": "n_hero", "children": [],
"props": { "tag": "h1", "text": { "$expr": "item.title" } }, "styles": {}
}
}
}
childrenorders siblings;parentgives O(1) lookups. The op layer keeps both consistent.- Ids are created on the client for optimistic inserts. Duplicating or pasting a subtree remaps every id.
- Links are typed (
{ type: 'page', pageId }survives renames); assets are referenced by id, never by URL. - Identity and structure props (
componentId,variant,overrides,query,layout…) cannot be bound.
Why one document per page
Loading is one row, saving is one write, and publishing, versioning and duplicating are document copies. Yjs maps onto it one-to-one. Moves are validated in code without recursive SQL. Typical pages are 200–2,000 nodes. Because the flat map has the shape of rows, moving to a nodes table later would be a script, not a redesign.
Ops
Panels, canvas gestures, the AI and MCP never touch the Yjs document directly. They build typed ops, and the core validates and applies them in one transaction.
type Op =
| { t: 'setProp'; id: NodeId; path: string[]; value: unknown }
| { t: 'setStyle'; id: NodeId; key: StyleKey; prop: string; value: string | null }
| { t: 'setStyleBinding'; id: NodeId; key: StyleKey; prop: string; value: { $expr: string } | null }
| { t: 'setMeta'; id: NodeId; name?: string; hidden?: boolean; locked?: boolean }
| { t: 'insert'; id: NodeId; node: Omit<Node, 'parent' | 'children'>; parent: NodeId; index: number }
| { t: 'insertSubtree'; root: NodeId; nodes: Record<NodeId, Node>; parent: NodeId; index: number }
| { t: 'move'; id: NodeId; parent: NodeId; index: number }
| { t: 'delete'; id: NodeId }
| { t: 'setInteraction'; id: string; interaction: Interaction | null }
| { t: 'setVariable'; id: string; variable: VariableDef | null }
| { t: 'setQuery'; id: string; query: QueryDef | null }
| { t: 'setWorkflow'; id: string; workflow: WorkflowDef | null }
editor.document.transact(ops, { mergeKey }) // one undo step; mergeKey joins a drag
Validation rejects cycles on move, deleting the root, children on leaf types, and props or styles that fail the schemas.
Yjs mapping
- Each node is a
Y.Map;childrenis aY.Arrayso concurrent inserts merge. propsandstylesare maps, so two people editing different properties of one node never conflict.- The undo manager tracks the origins
user,aiandmcp; every user undoes only their own changes. - Site-level state (pages list, classes, tokens, components, variables) lives in a separate site document.