Skip to content

feat: the layout sidecar — a hand layout that leaves the browser (#228) - #257

Merged
lex00 merged 1 commit into
mainfrom
feat/228-layout-sidecar
Aug 10, 2026
Merged

feat: the layout sidecar — a hand layout that leaves the browser (#228)#257
lex00 merged 1 commit into
mainfrom
feat/228-layout-sidecar

Conversation

@lex00

@lex00 lex00 commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

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.json next to the project — shareable, reviewable in a diff, and baked into what behold export captures. Open the same project in another browser (or on another machine, after a git 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:

  • One file. <projectDir>/.behold/layout.json, path built from cfg.projectDir plus 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, no chant.config.ts, no .behold.json. A route test asserts the project directory gains .behold/ and nothing else.
  • The invariant is untouched. behold still never mutates the cloud and never mutates your source; authority stays in the committed source and the executor. A layout sidecar is workspace metadata about how you want the picture arranged — the category .behold.json (config, tracked) is deliberately not in.
  • Four polite refusals. Preview mode; a static-export capture (runExport builds its app with layoutWrites: false rather 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/layout reports writable and the reason, so the client knows without trying.
  • Writes go through a sibling .tmp and a rename. An empty map drops the lens; the last lens leaving drops the file.
  • A JSON content-type is required, for the same reason /api/project/open takes 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 pull silently 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=1 on /api/graph / /api/overlay bakes 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, runExport always does, and a scripted curl decides.

canonicalKey whitelists the six params that select a distinct snapshot and drops everything else, so appending layout=1 to 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 synthesizes box:<title> ids from live DOM structure, and reconstructing that by string surgery would be guessing. Same upstream ask as #245: pinhole should stamp a data-group-id on 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, pathAnchors and straightEdge are now factored out of renderLayout and exist in both web/layout-store.js and src/layout.ts — same discipline canonicalKey already gets (mirrored in app.js and export.ts). web/layout-store.test.js imports 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 excludes web/, so a src/*.test.ts couldn't import the browser one.

The same test also pins that lensFromQuery (server, from query params) lands on exactly what lensKeyOf (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 pagehide and rides keepalive, so a tab closing right after a drag still lands the write.

Also: real pinhole cards carry no transform of 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.json sitting 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.mjs serves the same wire contract in memory.

just check and npm run smoke:ui are green.

Follow-up note

.behold/ is per-user state, unlike the tracked .behold.json config, 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 .gitignore is touched by this PR — that's the served project's call, not behold's.

🤖 Generated with Claude Code

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
lex00 merged commit 2890d36 into main Aug 10, 2026
3 checks passed
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Draggable and resizable nodes, persisted to a sidecar — no database

1 participant