Content and code
Keeps 5 promises
Changing an interface today means writing JS, and one bad line breaks the page or the whole OS. An agent restyling it is a stranger's code running in your shell. So the two are split: content (the page tree, tokens, classes, the layout, settings) lives in Loro docs and is edited live; code (components and their schemas, islands, migrations) lives in git and runs only after the build gate. The worst a prompt-injected agent can write is data.
Code is not a CRDT as well, because a concurrent edit to code merges into a bug that compiles, and nothing records that a merge happened; git stops and asks (Files). A page that needs its own code drafts it as a text node; the merge writes the built module's hash back into the doc, and only that hash runs. An app with real behaviour is a registry component, so it passes the same gate, and keeps its state in docs of its own. Our apps share the shell's realm and are reviewed like it; anyone else's runs in a frame of its own. A migration in git is applied as edits: expand, migrate, contract.
The schema is ours: what a node is, which styles exist, how a component declares its props. The engines are not: the CRDT, the text editor and the components are mature open source. No existing format is all of these at once:
- Data, never code. HTML carries scripts and handlers; MDX carries imports and expressions; the AI builders' format is code.
- A tree whose nodes keep their identity through moves. HTML and markdown have no ids; ProseMirror's JSON and Puck's are trees, but their collaborative bindings store children in lists, so a move is a delete and an insert, and a concurrent edit to the moved block is lost.
- Styles as data with a fixed list of properties. Webstudio and GrapesJS store full CSS, which is the right power for one designer and the wrong one for a stranger's edit.
- Components from the app's own source. Puck gets this right, and its data model is the nearest to ours: a component name and its props.
What a format of our own costs: we write its migrations, its text form's parser and its binding to the editor, and nobody else's tools read it. The text form is markdown so that people and git can, and the schema is small so that the rest stays cheap.
A page in three forms
Keeps 8 promises
- A page is readable text in git
- Save a page as a file, and open it anywhere
- One button flips between the page and its source
- The source shows everyone's edits as they happen
- Editing the source edits the page
- Any page renders, however edits merged
- History is kept outside the doc too
- An edit merged in git comes back as edits
- The doc (Loro) is the truth: the tree, every edit, who made it, and every version. It merges in any order.
- The text form is what people, git and the source view read: markdown for prose, tags for components, with literal props and a stable id on each node; no imports, no expressions, nothing that runs. These pages are written in it.
- Node JSON is a flat snapshot of one version, defaults stripped, checked against each component's schema; it is all a renderer reads, live or published.
A node is one LoroTree node with a map: type (a component's name),
props (checked against the component's schema), style (property to
value, from the allowed list) and, for nodes holding words, text, a
LoroText with marks for bold, links and the like.
The text form is markdown for headings and paragraphs and a tag for every
other component, with an id on every block: a trailing {#12@345}, or an
id on the tag. Marks are toggles (**, _, a backtick, […](…)), so
any overlap of them prints and parses back as it was. An id on every
line costs some reading; matching blocks by their words instead would
turn a move made in git into a delete and an insert, losing what
move/keeps-edits keeps.
The source view shows the text form, printed from the doc as edits arrive. Editing it is harder than showing it: a changed line is diffed against the text it was printed from, and the difference becomes the edits a person would have made, so it merges with everyone else's. An edit made in git needs the same diff, taken from the version the doc last mirrored there, so the two are built once. Checking each operation is no gate for a valid tree: two valid operations can merge into an invalid one, so a renderer draws what it can't as a placeholder, and a repair peer writes the fix as operations.
The technologies
Loro, for the doc
Keeps 6 promises
Loro is a CRDT in Rust with a WebAssembly build. It has the one thing this needs that the others don't: a movable tree, where moving a node is one operation, so a section dragged while someone types in it keeps their typing. It also has rich text with marks (Peritext's rules), undo that undoes only your own edits, presence, shallow snapshots, version history and forks, and it can read an update's authors before importing it, so an update can be refused whole. A Linux node runs it natively, a browser in WebAssembly.
Chosen over:
- Yjs, the most used CRDT, with the most editor bindings. It has no move: a move is a delete and an insert.
- Automerge. Well designed, with Ink & Switch's Keyhive work on encryption and permissions, but no movable tree yet.
- Our own CRDT. Years of work that Loro has done.
Its cost is size: 3.4 MB of WebAssembly, 790 KB over the wire (loro-crdt 1.16.4, measured 2026-10-17). So a page paints first as static HTML, readable at once, and the editor loads behind it.
ProseMirror, for typing
Keeps 1 promise
Typing is where an editor feels right or broken: selection, input methods, spellcheck, paste, and undo all have to behave as they do everywhere else. ProseMirror handles them, and its schema is a list of what a document may contain, which suits a format that may contain only data.
We write its binding to the doc ourselves. The existing one, loro-prosemirror (0.4.4), stores children in lists, which would lose the movable tree. Its cursor and undo plugins are the model for ours.
Chosen over:
- Tiptap, ProseMirror with a friendlier API and extensions. Its collaboration is built on Yjs, and its hosted parts are paid; we'd use ProseMirror underneath anyway.
- Lexical. Fast and small, but its collaboration is Yjs too, and it assumes React.
- contenteditable from scratch. Every editor that tried this rebuilt ProseMirror, slower.
Preact, for components
Keeps 6 promises
A component is a function from props to markup, built from the app's source, with a schema of its props beside it. Preact is 4 KB and runs React's components, so most of the React world's components can be registered.
Chosen over:
- React, ten times the size for the same model.
- Web components. No framework, but their shadow DOM hides elements from the style pencil and from the page's tokens.
- Svelte or Solid. Smaller output, smaller ecosystem to register from.
shadcn's components on Radix, for the chrome
Keeps 3 promises
Every surface (the shell, the editor, this site) draws its buttons, menus,
dialogs and tabs from one library, ui/: shadcn/ui's components, copied
in and owned, on Radix's primitives, which keep focus, keys, dismissal and
placement right. The build aliases React to preact/compat, so Radix runs
unchanged; node ui/check.mjs drives each one in a real browser, rendered
ahead and hydrated. Their look is ui.css: shadcn's token names, a class per
component and its variants as data attributes, so a theme is tokens and
the pencil reaches every component through them. The markup-only parts
cost 12 KB gzipped with Preact; the overlays bring Radix's 40 more, so a
surface loads them when it first needs one.
Chosen over:
- shadcn as shipped, with Tailwind. Copy-paste parity, but the look lives in class strings: the pencil would edit utilities instead of tokens, and a component built live would need Tailwind in the browser. A site made here may still use it; it's that site's build.
- shadcn-svelte. A second component model beside Preact, and two renderers for an app's screens.
- Our own primitives. Focus traps, roving focus and collision-aware placement are where hand-made menus break.
CSS tokens and one stylesheet, for styles
Keeps 4 promises
Tokens are CSS custom properties (--accent, --space-2). A node's style
map becomes rules in one constructed stylesheet, keyed by the node's id,
rebuilt as edits arrive. Nothing is written into style attributes, so the
page's CSP can forbid inline styles, and a value is checked against the
allowed list (no url() to elsewhere, no expression, no @import, no
position: fixed over the editor's own controls) before it reaches the
sheet.
The browser's own channel and storage, for sync
Keeps 3 promises
Each tab is a peer with its own copy of the doc. Tabs send each other updates over a BroadcastChannel, which the browser delivers between tabs of one site and nowhere else, and the doc is kept in IndexedDB, so a page outlives its tabs. A tab that opens late, or was cut off, sends its version and gets back only what it is missing. One tab at a time, chosen by a Web Lock, writes the compacted snapshot.
The editor sees none of that: it hands a transport bytes to send and takes the bytes it receives. The browser's channel is the first transport; your devices, through sealed sessions, are the next.
Chosen over:
- A relay in the cloud (a Durable Object per page). It would reach other devices today, but it means storing everyone's pages and running a service that holds them; sessions through the relay carry the same bytes sealed.
- One shared copy that tabs take turns writing. Simpler, but then two tabs aren't two peers, and two tabs are the cheapest real test of the merging everything else depends on.
TypeScript and esbuild, for the editor and the build
Keeps 1 promise
The editor is large enough that types pay for themselves; esbuild builds it in milliseconds with no configuration. The build is also the gate for code: a component reaches the library only through it.
Plain files: the doc is the truth, the file a projection
Keeps 7 promises
- A markdown file opens and saves back byte for byte
- An edit changes only its own lines
- A file another program changed comes in as edits
- Saving never overwrites a change made on disk
- Tags the editor can't draw are kept, and edited as text
- A file on any of your machines opens in the editor and saves there
- A file changed on its machine comes into the open editor
A markdown file opened in the editor is a page like any other: a tree of blocks in a Loro doc, which every edit changes, whether it is typing, a move, an AI's suggestion or another device's edit. The file is printed from the doc, in the file's own format, and holds no ids: each block keeps them in the doc, beside the lines it was read from and the blank lines before it. The editor's own text form, with an id on every block, stays for pages that aren't files.
Printing is what keeps a diff small. A block whose words are as they were read prints its lines exactly, escapes, wrapping, list marker and all. A changed block reprints only the span between its first and last change, inside the lines it was read from, and is read back to check it says the same; failing that, it is one line. A new block is written as its neighbours are: a list item takes their marker and numbering, a table row their pipes and padding, from the line under the table's header, which the table keeps. Headings, paragraphs, lists, tables, code and the site's tags are blocks; anything else (front matter, a quote, HTML) is a block holding its lines as text. If a file ever printed back other than it was read, it would open as one such block, so nothing is lost.
The other way, a file another program wrote (git, vim, a formatter) is
read and matched to the blocks the doc held when the file was last read or
written: the same words in the same order keep their ids, and a changed
block between two that match keeps the id of the one it replaced. The
difference becomes edits on a copy of the doc at that version, merged with
whatever was done since, as the source view's edits are; the file printed
from the result is what was written. A host reads the file with a version
and writes only over the version it was given, so a change on disk is
taken in before anything is written over it. This is files/disk-truth
and files/both-ways for a page: in Files a text file is Loro
text, character by character; a markdown page is the same file seen as
blocks.
On a machine, the page is the host. The editor runs in a frame of the
page's own origin, and reads and writes the file through the page's
session to the machine, sealed, over whichever pipe that session is on. A
file's version is the first 8 bytes of its SHA-256, and a save carries the
version it was made over: the files service compares it with the file as
it is just before renaming the new one into place, and otherwise refuses
with changed, so the check is the machine's, not a race in the browser.
The editor then reads the file, takes the change in and saves again. A
change made on the machine is noticed by its size and time, checked every
few seconds while the pane shows and whenever it gains focus; until the
files service can say when a file changes, that is the cost of a pane
left open.
No server: privacy, sharing, comments and AI
There is no server for pages. The site serves the editor's files, the same to everyone, and after they load, a page is edited, stored and shared on your devices.
What anyone else sees
Keeps 2 promises
The site sees that someone loaded the editor, from which IP address, as any website does. It never sees a page, a comment or an edit, because none is ever sent; a test proves no request is made while editing. Between devices, edits ride the same sealed sessions as everything else, and a relay forwards them unread.
Sharing: tabs first, then your devices
Keeps 3 promises
Two tabs in one browser need no coordination beyond the browser: no accounts, no addresses, no network. That is enough to build and prove all of the editing, because two tabs are two peers, merging the same way two devices would.
Between devices, a doc is the unit of sharing, of permission and of damage: a grant names it, and whatever must be shared, granted or contained separately is a doc of its own. Part of a doc is never replicated alone; one doc for the whole UI would make every grant all or nothing. Each Loro peer id is bound to a node key, and an update is imported only over a session whose caller may write the doc, and only if every change in it is from a writer. Revoking someone ends their writes, not what they read.
Comments
Keeps 2 promises
A comment is part of the page's data, in the same doc. It is anchored to a node id, or to a stable cursor into a node's text (Loro keeps these through edits), so it follows what it's about. A comment from someone on another device arrives the way their edits do.
AI
Keeps 5 promises
With no server, an AI runs from your browser, as you: when you ask, it sends the part you selected to the provider you chose (Anthropic or OpenAI), with your own key. The provider reads what you sent, the way a person you showed it to would, and nothing else leaves. The editor's CSP lets it reach those two and nowhere else.
The AI doesn't get to write code, or even text the page would parse: it gets tools for the same moves a person makes (insert a component, set a prop, set a style from the allowed list, move a block, change text), and its edits land on a copy of the page as a suggestion. Someone accepts them, and they merge as the AI's own edits, under its name, undoable on their own. An agent working on its own does the same on a fork, and merging the fork is a call that policy can make wait for a person. Edits that are data are what make an AI safe to let in: the worst it can do is a bad edit you can see and refuse.
A model running inside the browser (WebGPU) would let the page never leave at all. Today such models are small enough to rename and restyle, not to build a page; it's an option for later, not the default.
Publishing
Keeps 1 promise
Publishing pins a version, validates it and renders static HTML and CSS; only islands hydrate. It is the one real gate. The renderer is a service, so any static renderer could do it.
Containment and recovery
Four rings
Keeps 3 promises
| ring | what | who changes it |
|---|---|---|
| Shell | window manager, chrome round every pane, asks, devices, grants, pairing, unlock | nobody live: code, through the gate |
| Shell data | layout, theme, keybindings, settings | a person; an agent by fork and ask |
| A pane | a note, a page, a pane's content | whoever holds its doc's grant |
| Unvouched code | drafts, other people's apps, sites | its author, inside its own frame |
Each layer alone leaks, so each holds the line for a pane:
- Writes: its editors hold a grant on its doc only; a pane may ask for a size, and the window manager decides.
- Styles: a pane's content is drawn in a document frame that runs no
script and fetches nothing (
build/document-frame), so its CSS can be anything and still can't leave its rectangle or send what's on screen; what is drawn in the shell's own realm takes allowlisted styles only. - Top layer:
popover, modal<dialog>and fullscreen escapecontain, so pane data can never set them; the shell draws one on request. - Code: registry components share the shell's realm, so they are
reviewed like the shell and each pane has an error boundary. A boundary
catches a throw, not a loop, so unreviewed code runs in a frame on its
own origin and process, speaking only by message checked by
event.source. - Input: the shell sees every key first; a pane gets input only while focused. The chrome names the pane and the keys editing it.
Escapes
Keeps 5 promises
A UI change can pass every check and still lose you the screen, and no single escape is unbreakable. A pane has its own version navigator; these are for the shell.
| escape | survives | fails when |
|---|---|---|
| a reserved chord → the version popover | any shell-data change | a frame has focus; the page is hung |
| back and forward | anything that leaves the page alive | a frame adds its own history entries |
?ui=<checkpoint>, ?ui=safe |
a hung page, bad data | the shell build is broken |
| boot falls back to the last good build | a bad shell release | — |
| another of your nodes | everything in this browser | — |
The address is ?ui=<checkpoint>&shell=<build>, a checkpoint per editing
session, agent merge or release. Back previews a version in this tab, with
a banner to keep it or return; it never rewinds the synced doc. The chord
is reserved in the keybinding schema and opens a popover in the top layer,
which no pane may use. The shell keeps a contrast floor of its own. A boot
loader that does not change loads a new build and waits for its
self-check. A change to shell data is checked against the schema, reserved
chords and the chrome's sizes, then rendered offscreen and probed before it
becomes a trial.
Costs, risks and open questions
What it costs to run
Nothing, except the AI. Cloudflare's prices, checked 2026-10-17:
- The site, the editor and its WebAssembly are static files, and requests to static assets are "free and unlimited" on any plan. There is no other service to run, and no pages to store.
- The AI is the one real cost. On Cloudflare's own models, a mid-size model (Llama 3.3 70B) is $0.29 per million tokens read and $2.25 per million written, after 10,000 free "neurons" a day for the whole account, which is a few dozen edits. A free tier for everyone would be paid by us, so it is bring your own key.
Risks
What could go wrong, how likely it is and what it would cost, what would tell us early, and what we're doing about it.
- Nobody wants it the way the stories say. Every story is a guess until someone does it, and none has been seen working. Early sign: the first people given a story don't finish it, or don't care that they could. Doing: watch people do the stories before building more.
- Loro is too big or too slow on a large page. A page of thousands of blocks hasn't been tried. Early sign: a fixture of 5,000 blocks takes over a second to open, or a keystroke on it over 16 ms. Doing: the page paints as static HTML first; measure a large fixture before promising one.
- One ProseMirror per block is the wrong call. If people expect to select across blocks, moving to one ProseMirror is weeks of rework. Early sign: people doing the stories try to select and delete across blocks.
- The browser clears a page. Browsers evict a site's storage under pressure unless asked to keep it. Early sign: a page that was there is gone after a week away. Doing: ask the browser to persist storage, and make saving a file easy.
Open questions
- Undo for a source edit made while someone else edited. It is made on a copy of the page as it was printed and merged, so it arrives as another peer's edit, and Ctrl+Z doesn't take it back.
- A version that brings back a deleted block brings it back with a new id: Loro can't undelete a tree node. A comment on the old block stays with the old id.
- A selection across blocks. It is one ProseMirror per block for now: components stay out of ProseMirror's way, Enter splits a block and Backspace joins two.
- How hard to nudge toward saving a file, given storage the browser may clear.
- Which Loro API shows each change's peer id before import, so an update can be refused whole.
- Whether panes render in a shadow root or under
@scope. - The text form's grammar, shared with this site.