← All posts

One renderer, four readers - the scene format behind Rezee's canvas

A design canvas whose documents are a typed scene graph rather than pixels - why the write model is a CRDT of named nodes, why scene to SVG is a pure function with exactly one implementation, and what that buys an agent that draws.

Aug 25, 2026 · 5 min read · Kash Gohil

Rezee's design surface had one bet behind it: the interesting part of a design tool is the representation, not the canvas UI. Get the format right and the editor is a view over it, the server can render it, and an agent can author it through the same door a person drags through. Get it wrong and you have a drawing app with an export button.

This post is the format, and the two rules that fall out of it.

A canvas is a document, not a new noun

The first decision was to not add a table. A canvas is a docs row with kind = 'canvas' - the same move we made when an agent became a users row with kind = 'agent'.

That is not tidiness, it is the entire feature list arriving for free. Everything keyed on a doc row applies to a board without a line of new code: the tree and its per-workspace number, the /{ws}/docs/{number} URL, the permission gate, presence, search, comment threads, review status and sign-off, version history, restore. A board is reviewable because a document is reviewable. Decks are the same enum one step further (kind = 'deck'), and a design system is the same enum again (kind = 'system').

Only one thing differs: what is inside the document.

The write model is ops on named nodes

Under a page, the CRDT holds prose. Under a board it holds a scene graph - nodes in a map, each with a caller-chosen id, a type, a geometry and props.

Caller-chosen ids are the agent-native part. A node called sealed-box can be addressed tomorrow by the tool call that made it, so a batch of ops is a set of statements about named things rather than a diff against coordinates. Connectors bind node ids, not points, so moving a box drags its arrows with it and an agent never does the arithmetic. Frames can lay out their own children, so a position is computed rather than guessed - which matters because pixel arithmetic is the thing machine authors are worst at.

Ops merge like edits rather than clobbering. Two people, or a person and an agent, can write into the same board in the same second and both writes survive - the property you get from a CRDT and cannot get from read-modify-write over JSON.

The client and the server plan a batch with the same two functions, imported from the same package:

import { applyChangesInto, planOps, readSceneFrom } from "@rezee/himeno";

The editor calls them on a drag. The API calls them on a tool call. A client that guessed differently about which key a width lives under would produce a board the server could not read, so neither side is allowed its own opinion.

There is no REST call in the editor's write path and no debounce. An op goes into the Y.Doc, the provider puts it on the wire, the server logs and projects it - the same shape as a keystroke in a page. The ops endpoint exists for callers with no socket: an agent, a script, CI.

The read model is one pure function

renderScene turns a scene into SVG, and it is the only implementation of "what this canvas looks like" in the codebase. Four readers call it:

  • the editor, drawing the board you are dragging on
  • the API, serving render.svg
  • embeds and thumbnails, where a frame appears inside a spec
  • an agent, rasterising to a PNG to look at its own work

That last one is why the rule is load-bearing rather than tasteful. An agent iterating on a drawing is only trustworthy if it is shown exactly what the human sees. With two renderers, "these two boxes overlap" is a fact about one of them. With one, it is a fact about the picture.

It also makes a server-side render a function call. No headless browser, no screenshot service, no second definition of a rounded corner.

Two properties hold it in place:

It is pure. Same scene, same string, on either side. Anything the renderer would need to fetch - a component library, a design system's tokens - is resolved by the caller and injected, which also keeps the cross-document read behind the caller's own authorisation rather than the renderer's.

It never throws. What it is handed may be a merge nobody authored. A connector pointing at a deleted node is skipped. A node of an unknown type is drawn as a labelled placeholder. An empty scene is an empty board. Refusing a write happens in validate, before anything reaches the renderer; by the time a scene is being drawn, refusing is not on the table.

Growing the vocabulary without a migration

The format shipped able to say what a diagram needs and not what a design needs - a rounded card with a real border was not sayable. M22 added radius, stroke width and alignment, dashes, gradient paints and an effects list.

The rule that made it additive: absent always means the type's old default, never zero. Every board drawn before the change renders byte-identically, which we assert by keeping the pre-existing renderer tests untouched and green rather than by promising it in a changelog.

What it cost

The honest costs, since a format post that lists only wins is a brochure:

  • The scene graph is a second thing to learn. Prose has one shape; a board has nodes, frames, connectors, instances and now effects. The tool vocabulary is the documentation, so a badly worded refusal is a badly documented API.
  • Purity means the caller does the fetching. Resolving a component library or a token map before calling the renderer is real work pushed onto four call sites instead of hidden in one.
  • One renderer is one bottleneck. Every medium - diagrams, decks, UI design - has to be expressible in it. That is the point, and it is also why each medium shipped as its own milestone rather than all three at once.

The bet we would make again: the artefact, the spec beside it, the issues that came out of that spec, and the agent that drew it share one workspace, one permission model and one review loop. Not because the canvas is better than a dedicated design tool at drawing, but because none of that sharing is possible when the design lives a tab away.