feat: the layout sidecar — a hand layout that leaves the browser (#228) - #257
Merged
Conversation
Closes #228. The client tier shipped in #245; this is the other half: the project's own `.behold/layout.json`, behind GET/POST /api/layout, and the server baking the same deltas into a rendered SVG so `behold export` and the static snapshots honour a hand layout. This is the first time behold writes into a served project, so the boundary is drawn narrowly and stated three times (src/layout.ts's header, the route comment, AGENTS.md's invariant section): ONE file, at `cfg.projectDir` + two constants, with nothing from the request reaching the filesystem — a lens key off the wire is slugged and used only as a JSON object key. No `.ts`, no `chant.config.ts`, no `.behold.json`. The invariant stands: behold still never mutates the cloud and never mutates your source. A layout sidecar is workspace metadata about how you want the picture arranged. Four polite refusals: preview mode, a static-export capture (runExport builds its app with `layoutWrites: false` rather than trusting that nothing calls it), a project directory that isn't writable, and the size caps — 64KB body, 256KB file, 2000 ids per lens, 64 lenses, all enforced before anything is parsed or written. Writes go through a sibling `.tmp` and a rename; an empty map drops the lens, the last lens leaving drops the file. Merge: local over server, per id. You are looking at this browser's picture, and a sidecar someone else committed must not argue with the drag you can see; an id only the server has still comes through, which is what makes a shared layout worth having. The reverse would mean a `git pull` silently undoing a placement on your screen. Reset clears both tiers, or the next merge would pull the sidecar's deltas straight back in. Server-side bake is opt-in per request (`?layout=1`), not always-on: the live SPA owns the interactive layer and needs dagre's own coordinates as the base its drags are deltas FROM, so baking by default would land every offset twice. The SPA never asks; runExport always does (`canonicalKey` whitelists the six params that select a distinct snapshot, so `layout` never reaches the key the frontend looks up). It rides as middleware over /api/graph and /api/overlay, so the six render paths that return an `svg` don't each grow a branch. Node translates and their edges' re-anchoring are baked; a box's {dw,dh} is not — pinhole's containment boxes carry no id to bake against, and that stays a browser-side effect until pinhole stamps a `data-group-id`. The delta→SVG math is now factored (`nodeTransform`, `pathAnchors`, `straightEdge`) and exists in both web/layout-store.js and src/layout.ts, with web/layout-store.test.js importing BOTH and checking them against one table — the same discipline `canonicalKey` already gets, and the one file that can hold both copies (tsconfig excludes web/). One behaviour found on the way: a reload inside the debounce window dropped the last placement from the sidecar while localStorage kept it. The push now flushes on `pagehide` and rides `keepalive`. Tests: src/layout.test.ts (28 — lens normalization including path- and prototype-shaped keys, the file's semantics and caps, the bake against a pinhole-shaped SVG); src/server.test.ts (10 route tests — write and read-back through the file, lens independence, the 403s for preview/export/unwritable, 413 on an oversized body, 400/415 on a bad one, "touches nothing else in the project", and a stored delta landing in the SVG a capture would take, edge re-anchoring included); web/layout-store.test.js (+20 — merge precedence, the two fetch helpers, debounce and its flush, and the parity table); smoke/ui-smoke.mjs (+8 through real pointer input — the drag reaching the sidecar, then localStorage cleared and the position coming back off the server, then a conflicting id resolving local-over-server, then reset clearing both). `.behold/` is per-user state, unlike the tracked `.behold.json` config, so the README and the docs site both say to gitignore it. No example project's .gitignore is touched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lex00
added a commit
that referenced
this pull request
Aug 10, 2026
Conflicts in src/server.ts, both from the hand-layout sidecar (#228/#257) landing beside carve mode: two new `ServerOptions` fields, and two additions to the `/api` route index. Both sides kept. Carve mode now also declines the sidecar. #228's write surface is `cfg.projectDir` + `.behold/layout.json`, and carve mode's `projectDir` is wherever the report file happens to sit — a Downloads folder, someone else's Terraform repo. There is no project there to keep a hand layout in, so `layoutWriteBlock` names carve mode as its own reason rather than letting behold drop a `.behold/` into a stranger's directory. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #228. #245 shipped the client tier — drag a card, resize a box, it survives a reload in this browser. This is the other half: the layout leaves the browser.
What you can do now
Arrange the graph by hand on a project behold can write to, and it lands in
.behold/layout.jsonnext to the project — shareable, reviewable in a diff, and baked into whatbehold exportcaptures. Open the same project in another browser (or on another machine, after agit pull) and the arrangement is already there.{ "version": 1, "lenses": { "components": { "src/api#Component": { "dx": 40, "dy": -25 } } } }The lens key is the client's own (
components,resources+radial,logical+stack-edge); the project half of the localStorage key is implicit here, because the file lives in the project.The write boundary
This is the first time behold writes into a served project, so the claim is deliberately much smaller than "behold writes now", and it's stated in three places —
src/layout.ts's header, the route comment, and a new section under AGENTS.md's Invariant:<projectDir>/.behold/layout.json, path built fromcfg.projectDirplus two constants. Nothing from the request reaches the filesystem: a lens key off the wire is slugged to[a-z0-9+-]and used only as a JSON object key. No.ts, nochant.config.ts, no.behold.json. A route test asserts the project directory gains.behold/and nothing else..behold.json(config, tracked) is deliberately not in.runExportbuilds its app withlayoutWrites: falserather than trusting that nothing happens to call the route); a project directory that isn't writable; and the caps — 64KB body, 256KB file, 2000 ids per lens, 64 lenses, checked before anything is parsed or written.GET /api/layoutreportswritableand thereason, so the client knows without trying..tmpand a rename. An empty map drops the lens; the last lens leaving drops the file./api/project/opentakes a JSON body: cross-origin JSON POSTs preflight, so a hostile page can't blind-fire a write at localhost.How the two tiers resolve
Local wins, per id. You are looking at this browser's picture, and a sidecar someone else committed must not argue with the drag you can see. An id only the server has still comes through — that is what makes a shared layout worth having — so the merge adds without ever overwriting. The reverse ordering would mean a
git pullsilently undoing a placement on your screen.Reset clears both tiers. Clearing only localStorage would let the next merge pull the sidecar's deltas straight back in, which is not what "↺ layout" says it does.
Everything about the server tier is best-effort: no server, an older behold with no such route, a static export, preview mode, a read-only project — all resolve to "localStorage-only", silently, exactly as it worked before this PR.
Server-side bake: opt-in, and why
?layout=1on/api/graph//api/overlaybakes the sidecar's translate deltas into the SVG. It is opt-in rather than always-on, which is the one design decision here worth arguing about: the live SPA owns the interactive layer and needs dagre's own coordinates as the base its drags are deltas from. Bake by default and the client's own pass would land the same offset a second time. So the SPA never asks,runExportalways does, and a scriptedcurldecides.canonicalKeywhitelists the six params that select a distinct snapshot and drops everything else, so appendinglayout=1to a capture request leaves the captured key exactly what the frontend will look up. There's a test pinning that.It rides as middleware over both routes, so the six render paths that return an
svg(source, components, logical, estate, overlay, runtime) don't each grow a branch.meta.layout: {lens, applied}says what it did.What is not baked: a box's
{dw,dh}. pinhole's containment boxes are a bare<rect rx=…>plus a title<text>with no id at all — the client synthesizesbox:<title>ids from live DOM structure, and reconstructing that by string surgery would be guessing. Same upstream ask as #245: pinhole should stamp adata-group-idon group boxes, and boxes join the bake the day it does. Edge labels keep their original midpoints, as on the client.One shared copy of the math
nodeTransform,pathAnchorsandstraightEdgeare now factored out ofrenderLayoutand exist in bothweb/layout-store.jsandsrc/layout.ts— same disciplinecanonicalKeyalready gets (mirrored in app.js and export.ts).web/layout-store.test.jsimports both modules and checks them against one table, so a drift is a failing test rather than an export that silently disagrees with the screen it was taken from. That file is the only place both copies can meet: tsconfig excludesweb/, so asrc/*.test.tscouldn't import the browser one.The same test also pins that
lensFromQuery(server, from query params) lands on exactly whatlensKeyOf(client, from view state) stored under — including that the env is in neither, since an overlay recolours the same nodes rather than re-placing them.Found on the way
A reload inside the debounce window dropped the last placement from the sidecar while localStorage kept it — the smoke caught it. The push now flushes on
pagehideand rideskeepalive, so a tab closing right after a drag still lands the write.Also: real pinhole cards carry no
transformof their own (they're positioned by absolute child coordinates), unlike the smoke stub's. Both shapes are covered.Tests
src/layout.test.ts— 28: lens normalization (path-shaped and prototype-shaped keys included), the file's semantics and every cap, and the bake against a pinhole-shaped SVG (rides on an existing transform, creates one where there is none, leaves nested marks alone, re-anchors every path of a touched edge, leaves an untouched edge byte-identical, matches ids the painter escaped).src/server.test.ts— 10 route tests: write and read-back through the file with a fresh app, lens independence, the empty-map reset, "touches nothing else in the project" (with a.behold.jsonsitting beside it, unharmed), the three 403s, 413 on an oversized body, 400/415 on a malformed one, and a stored delta landing in the SVG a capture would take, with the edge re-anchoring asserted end for end.web/layout-store.test.js— +20: merge precedence both ways, the two fetch helpers and their silent failure modes, debounce + flush, and the parity table.smoke/ui-smoke.mjs— +8, all through real pointer input: the finished drag and the box resize reaching the sidecar, then localStorage wiped and the position coming back off the server after a reload (Draggable and resizable nodes, persisted to a sidecar — no database #228's acceptance for this half), then a conflicting id resolving local-over-server while a server-only id still applies, then reset clearing the sidecar too.smoke/stub.mjsserves the same wire contract in memory.just checkandnpm run smoke:uiare green.Follow-up note
.behold/is per-user state, unlike the tracked.behold.jsonconfig, so the README and the docs site both recommend gitignoring it (commit it only if you actually want everyone looking at the same arrangement). No example project's.gitignoreis touched by this PR — that's the served project's call, not behold's.🤖 Generated with Claude Code