Where a thing sits on the screen is usually the least reviewable fact about a system. It lives in a canvas file, in a hosted dashboard, or in whatever a force-directed graph decided this morning. RuneFort declares it instead — rooms at integer cells, the files each room owns, which room is next, and what colours it — as JSON that sits in your repository and shows up in a pull request.
The four cards below are placed at the cells they name, on a six-column floor, with plain CSS grid and no components. It is the cheapest available test of whether the vocabulary can describe an ordinary piece of a page — and if it could not, that would be worth knowing before anything else on this site.
An id, an integer position, an integer size in grid cells, and arbitrary metadata. [0,0] is top-left; size [3,2] is three columns by two rows. The renderer picks the pixel size — the protocol only specifies cell counts.
A glob and an anchor. The room owns that region of the filesystem, and activating the tile surfaces those files — inline, or handed off to your own editor through a vscode:, cursor: or zed: URL.
An adjacency between two rooms. It drives h/j/k/l navigation and focus order, and may be drawn as a line. Adjacency is declared, not inferred from pixels — which is why the keyboard path survives a re-layout.
A named runtime signal, a threshold, and the class it applies. Live state colours a tile without the layout knowing what the signal means. Unknown vocabulary degrades rather than fails.
Everything else in the protocol is a vocabulary layered on top of these four. A renderer that does not recognise a vocabulary must still draw the layout using the primitives — unknown vocabulary degrades, it does not fail.
This is the whole quick-start, and both paths in it are resolved against the tree before this page is emitted. The components are ES modules served as written — there is no bundler in this repository and no built output directory, which is worth stating plainly, because the README pointed at one that does not exist and the first line anyone copied returned 404.
<script type="module" src="/packages/core/src/index.js"></script>
<link rel="stylesheet" href="/packages/core/theme.css">
<rune-floor columns="6" rows="2" editor="vscode">
<rune-room id="memory" position="0,0" size="3,2" label="memory.ex">
<rune-claim pattern="lib/my_app/memory/**/*.ex" anchor="lib/my_app/memory.ex#L24"></rune-claim>
</rune-room>
<rune-room id="deploy" position="3,0" size="3,2" label="deploy.ex"></rune-room>
<rune-link from="memory" to="deploy" kind="adjacent" bidirectional></rune-link>
</rune-floor>
This site printed one weight, seven times, and never once said the weight of what. The build now resolves each entry point's ES module imports transitively, concatenates every file it reaches and gzips the result at level 9, and refuses to publish the page if a figure moves without the record moving with it.
| What you load | Entry point | Reached | Raw | Gzip level 9 |
|---|---|---|---|---|
| The four layout primitives What a page needs to render rooms on a floor. rune-floor pulls in the threshold parser; rune-room pulls in the editor-handoff URL builder. | rune-floor.js + rune-room.js + rune-claim.js + rune-link.js | 6 modules | 24,443 bytes | 6,794 gzipped |
| The documented entry point What import "@runefort/core" costs. It registers every element and re-exports the templates, the generation runtime and the cloud adapter. | index.js | 19 modules | 163,749 bytes | 42,065 gzipped |
| The cloud-sync adapter, inside the entry point Inside the row above. Loaded whether or not it is configured. | supabase.js | 1 module | 12,487 bytes | 3,704 gzipped |
| theme.css The stylesheet the components expect. Not JavaScript, and not counted in either figure above. | theme.css | 1 module | 22,314 bytes | 4,478 gzipped |
Read the two rows against each other: the smallest module graph that renders a floor of rooms is 6,794 bytes gzipped, and the entry point the documentation tells you to import is 42,065 — 6.2× larger, because it registers all 14 elements and re-exports the templates, the generation runtime and the cloud adapter. Both are true, and the single figure this site used to print was neither. Splitting the entry point so the four primitives can be imported alone is the obvious fix and it has not been done, so the larger figure is the honest one to quote.
The protocol's claim at this rung is narrow and checkable: what you declare in cells is what the browser lays out in tracks. So it was checked in a browser, on the deployed domain, rather than asserted.
display was grid, and grid-template-columns resolved to six tracks of 157.828px.size="6,1" measured 1017px — six tracks plus the five gaps between them.position="0,1" and position="3,1", both size="3,1", measured 502px each, at x=224 and x=740. Side by side, three tracks each, one gap between.<rune-room> in the document had been upgraded by the time the measurement ran.Everything above establishes that this renderer honours a declaration. The protocol exists so that a second one would honour the same declaration identically, and there is no second one, so that property is unmeasured rather than proven.
The protocol's reason to exist: the same runefort.json should render in Elixir/Phoenix, in TypeScript/React, or in static HTML, and the protocol is the only thing those three share. One implementation cannot demonstrate portability; it can only fail to contradict it.
A short authoring syntax that compiles to runefort.json, specified in docs/spec/runefort.dsl.md. There is no parser for it anywhere in this repository. The JSON is the only thing that exists.
Nothing in this tree defines what makes a renderer conformant. There is no test corpus of declarations with expected geometry, which is what the paragraph above would have to be checked against.
The entry point exports sign-in, push, pull, fork and public-fort loading against Supabase — twelve and a half kilobytes of it, loaded whether or not you configure it. This portfolio ruled the shared-Supabase route abandoned on 2026-07-30 and its replacement is not built, so this path has no live backend to reach.
Until this revision this site printed ~5KB in its title, in its description, twice in its social cards, in its own eyebrow, and twice more in the comparison table — seven times, and not once beside the name of the thing being weighed. The README called it a target. The landing page stated it as a fact.
Measured, by walking the ES module import graph and gzipping at level 9: the four layout primitives with their own dependencies come to 6,794 bytes gzipped, and the entry point the documentation tells you to import comes to 42,065. Neither is five kilobytes. The nearest true statement is that the four primitive files alone gzip to about five kilobytes — and they cannot be loaded alone, because the documented entry point registers all 14 elements and pulls in the templates, the generation runtime and the cloud adapter with them.
A second thing went with it. The README's quick-start pointed at /packages/core/dist/index.js, and there is no dist/ in this repository and no build step that would produce one, so the first line anyone copied returned 404. Both are now structural rather than careful: the figures are recomputed from the component files on every build and the build refuses when one moves without its record, and every documented path is resolved against the tree before the page is emitted.
renderer-portable from an intention into a measurement.Every number here is a measurement with a date on it, and measurements go stale. The form below is a real form with a real action: it posts to formspree.io when you press the button, and it works with JavaScript off. The script that upgrades it to an inline reply prints “sent” only when the endpoint actually answers 2xx — a form that says thank-you on submit and drops the message is the exact failure this page argues against. If you would rather leave a public trail, open an issue instead.