Cada página, cada componente y el área de borrador del lienzo es un PageDoc: un mapa plano de nodos con enlaces a su padre y a sus hijos. Se guarda como un único valor jsonb por página, se edita como un documento Yjs y lo renderiza el mismo renderizador en todas partes.
Forma
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": {}
}
}
}
childrenordena a los hermanos;parentda búsquedas en O(1). La capa de ops mantiene ambos coherentes.- Los ids se crean en el cliente para las inserciones optimistas. Al duplicar o pegar un subárbol se reasignan todos los ids.
- Los enlaces son tipados (
{ type: 'page', pageId }sobrevive a los cambios de nombre); los recursos se referencian por id, nunca por URL. - Las props de identidad y de estructura (
componentId,variant,overrides,query,layout…) no se pueden enlazar.
Por qué un documento por página
Cargar es una fila, guardar es una escritura, y publicar, versionar y duplicar son copias del documento. Yjs se corresponde con él uno a uno. Los movimientos se validan en código, sin SQL recursivo. Una página típica tiene entre 200 y 2.000 nodos. Como el mapa plano ya tiene forma de filas, pasar más adelante a una tabla de nodos sería un script, no un rediseño.
Ops
Los paneles, los gestos del lienzo, la IA y MCP nunca tocan directamente el documento Yjs. Construyen ops tipadas, y el núcleo las valida y las aplica en una sola transacción.
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
La validación rechaza ciclos al mover, borrar la raíz, hijos en tipos hoja, y props o estilos que no cumplen los esquemas.
Mapeo a Yjs
- Cada nodo es un
Y.Map;childrenes unY.Array, así que las inserciones simultáneas se combinan. propsystylesson mapas, así que dos personas que editan propiedades distintas de un mismo nodo nunca entran en conflicto.- El gestor de deshacer registra los orígenes
user,aiymcp; cada usuario deshace solo sus propios cambios. - El estado a nivel de sitio (lista de páginas, clases, tokens, componentes, variables) vive en un documento de sitio aparte.