From 2deaf01ed900ea880a8da4e3df3a84a50ba26f9f Mon Sep 17 00:00:00 2001 From: lex00 <121451605+lex00@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:09:13 -0600 Subject: [PATCH] =?UTF-8?q?feat:=20the=20layout=20sidecar=20=E2=80=94=20a?= =?UTF-8?q?=20hand=20layout=20that=20leaves=20the=20browser=20(#228)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- AGENTS.md | 22 ++ README.md | 28 ++ docs/src/content/docs/start/your-project.mdx | 6 + smoke/stub.mjs | 22 ++ smoke/ui-smoke.mjs | 33 ++ src/export.test.ts | 9 + src/export.ts | 13 +- src/layout.test.ts | Bin 0 -> 11857 bytes src/layout.ts | 341 +++++++++++++++++++ src/server.test.ts | 166 ++++++++- src/server.ts | 132 ++++++- web/app.js | 106 +++++- web/layout-store.js | 123 ++++++- web/layout-store.test.js | 176 +++++++++- 14 files changed, 1150 insertions(+), 27 deletions(-) create mode 100644 src/layout.test.ts create mode 100644 src/layout.ts diff --git a/AGENTS.md b/AGENTS.md index 620b356..5de86d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,3 +69,25 @@ and saga rollback. There is no behold endpoint that mutates the cloud. If a request would have behold write to a cloud or to source directly, it's wrong. behold shows truth and triggers Ops. Authority stays in the committed source and the executor. + +### The one exception, and its exact size + +`POST /api/layout` (#228) writes **one** file in the served project: +`.behold/layout.json` — the hand-layout sidecar, `{version, lenses: {: +{: {dx,dy,dw,dh}}}}`. That is the whole of behold's write surface +inside a project, and it does not weaken the invariant above: + +- It is **workspace metadata**, not estate truth. Deltas describe how *you* want + the picture arranged on top of dagre's layout; the graph underneath stays + chant's, and a delta for a node that left the estate is dropped on read. +- It **never touches the cloud and never touches your source**. No `.ts`, no + `chant.config.ts`, no `.behold.json`. The path is `cfg.projectDir` + two + constants — nothing from the request reaches the filesystem. +- It refuses politely when it shouldn't write: preview mode, a static-export + capture, a read-only project directory, an oversized or malformed body. +- It is **per-user state**, unlike `.behold.json` (config, meant to be tracked). + Projects should gitignore `.behold/`. + +`GET /api/layout` reads it back; `GET /api/graph?layout=1` (and `/api/overlay`) +render with the deltas baked into the SVG, which is how `behold export` and +static snapshots honour a hand layout. diff --git a/README.md b/README.md index 1ad4bae..1959bf4 100644 --- a/README.md +++ b/README.md @@ -294,6 +294,34 @@ render and the graph loads with no tier selected — the default for any project that doesn't opt in. There's no other tier config surface (not `chant.config.ts`, not an env var behold guesses the name of). +### The hand-layout sidecar — `.behold/layout.json` + +dagre places your nodes; you can move them. Drag a card, resize a containment +box, and the offsets are remembered per project + lens — in `localStorage` +first, and (when the served project is writable) in a `.behold/layout.json` +sidecar beside it, so a layout is shareable, reviewable in a diff, and honoured +by `behold export`: + +```json +{ "version": 1, "lenses": { "components": { "src/api#Component": { "dx": 40, "dy": -25 } } } } +``` + +This is the **only** file behold writes inside a served project. It stores +deltas, never absolute positions — the graph stays chant's and your layout sits +on top of it — and a delta whose node has left the estate is dropped silently. +`POST /api/layout` refuses politely in preview mode, during a static-export +capture, on a read-only directory, and above its size caps. `↺ layout` in the +graph clears the current lens on both tiers. + +**Gitignore it.** `.behold.json` (above) is config and belongs in the repo; +`.behold/` is per-user state — one person's arrangement of the picture — so add +it to the served project's `.gitignore` unless you actually want to share and +review a layout: + +```gitignore +.behold/ +``` + ## Layout ``` diff --git a/docs/src/content/docs/start/your-project.mdx b/docs/src/content/docs/start/your-project.mdx index 8e231f4..b0f532f 100644 --- a/docs/src/content/docs/start/your-project.mdx +++ b/docs/src/content/docs/start/your-project.mdx @@ -78,6 +78,12 @@ If your project's source branches on a build parameter — a deployment tier, sa This is behold's own config, deliberately separate from `chant.config.ts` so a viewer's concerns stay out of the compiler's. +## Optional: a shared hand layout + +Drag a card or resize a containment box and behold remembers the offset — in your browser, and (if the project directory is writable) in a `.behold/layout.json` sidecar next to it, per lens. That sidecar is the one file behold writes inside your project, it stores offsets rather than positions so the graph underneath stays chant's, and `behold export` bakes it into the exported SVGs. + +Add `.behold/` to the project's `.gitignore`: unlike `.behold.json` above, which is config worth tracking, a layout is per-user state. Commit it only if you actually want everyone looking at the same arrangement. + diff --git a/smoke/stub.mjs b/smoke/stub.mjs index f283959..9e5e4a1 100644 --- a/smoke/stub.mjs +++ b/smoke/stub.mjs @@ -92,9 +92,30 @@ const MIME = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css /** Start the stub on `port`; resolves to the http.Server (close() to stop). */ export function startStub(port) { + // #228: the hand-layout sidecar, in memory instead of `.behold/layout.json` + // — the SAME wire contract src/server.ts serves (lens-keyed deltas, a + // `writable` flag on the read), so the smoke drives the client's whole sync + // layer without a project on disk. `server.layout` lets the test read and + // seed it as if it were the file. + const layout = new Map(); const server = http.createServer(async (req, res) => { const url = new URL(req.url, "http://x"); const path = url.pathname; + if (path === "/api/layout") { + res.writeHead(200, { "content-type": "application/json" }); + if (req.method === "POST") { + const body = JSON.parse(await new Promise((r) => { + let s = ""; + req.on("data", (c) => (s += c)); + req.on("end", () => r(s || "{}")); + })); + if (Object.keys(body.deltas || {}).length) layout.set(body.lens, body.deltas); + else layout.delete(body.lens); + return res.end(JSON.stringify({ ok: true, lens: body.lens, deltas: body.deltas || {} })); + } + const lens = url.searchParams.get("lens"); + return res.end(JSON.stringify({ lens, writable: true, deltas: layout.get(lens) || {} })); + } if (path === "/api/events") { res.writeHead(200, { "content-type": "text/event-stream" }); return; // held open — the SPA's EventSource stays quiet @@ -130,5 +151,6 @@ export function startStub(port) { res.end("not found: " + path); } }); + server.layout = layout; // the sidecar, for the smoke to read and seed (#228) return new Promise((resolve) => server.listen(port, () => resolve(server))); } diff --git a/smoke/ui-smoke.mjs b/smoke/ui-smoke.mjs index 30a9aad..9765097 100644 --- a/smoke/ui-smoke.mjs +++ b/smoke/ui-smoke.mjs @@ -87,8 +87,10 @@ const page = await browser.newPage({ viewport: { width: 1400, height: 900 } }); const pageErrors = []; page.on("pageerror", (e) => pageErrors.push(String(e))); page.on("console", (m) => { + if (process.env.SMOKE_DEBUG) console.log("CONSOLE", m.type(), m.text()); if (m.type() === "error") pageErrors.push(m.text()); }); +if (process.env.SMOKE_DEBUG) page.on("request", (r) => r.url().includes("/api/layout") && console.log("REQ", r.method(), r.url(), r.postData())); try { await page.goto(`http://localhost:${PORT}/`); @@ -300,6 +302,13 @@ try { check("a box stores {dw,dh} under its own id", afterBox["box:wave-1"] && Math.abs(afterBox["box:wave-1"].dw + 80 / scale) < 2); await page.screenshot({ path: join(SHOTS, "7-layout.png") }); + // …and both went to the sidecar too (the stub holds it in memory; the real + // server writes `.behold/layout.json`). Debounced, so a drag is one write. + const sidecar = () => server.layout.get("components") || {}; + await page.waitForTimeout(800); + check("the finished drag reached the sidecar", Math.abs((sidecar().api || {}).dx - wantDx) < 2); + check("the box's resize reached it too", Math.abs((sidecar()["box:wave-1"] || {}).dw + 80 / scale) < 2); + // exportSvg() blobs `#graph svg`'s own outerHTML (no server round-trip), so // the displaced positions come along by construction — and the resize handles // do not, because their `opacity="0"` is an attribute, not a CSS rule. @@ -333,6 +342,28 @@ try { (await page.locator("#graph [data-node-id]").count()) === 3 && (await transformOf('#graph [data-node-id="api"]')) === cardTf, ); + // ---- #228, the server tier: the sidecar the SPA shares a layout through --- + // THE acceptance for this half: wipe this browser's tier entirely, reload, + // and the placement is still there — it came off the server. + await page.evaluate((p) => Object.keys(localStorage).filter((k) => k.startsWith(p)).forEach((k) => localStorage.removeItem(k)), LAYOUT_PREFIX); + await page.reload(); + await page.waitForSelector("#graph svg [data-node-id]", { timeout: 20000 }); + await page.waitForFunction((want) => document.querySelector('#graph [data-node-id="api"]').getAttribute("transform") === want, cardTf, { timeout: 10000 }); + check("with localStorage cleared, the position comes from the server", (await transformOf('#graph [data-node-id="api"]')) === cardTf); + check("so does the box's size", Math.abs((await boxWidth()) - boxW1) < 0.5); + check("nothing was written back to localStorage just by reading the server", (await layoutKeys()).length === 0); + + // Merge: local wins where both have an id, the server fills in the rest. + // (Someone else committed a layout that moves `worker`; you have your own + // idea about `api`.) + server.layout.set("components", { ...sidecar(), api: { dx: -300, dy: -300 }, worker: { dx: 15, dy: 25 } }); + await page.evaluate(([k]) => localStorage.setItem(k, JSON.stringify({ api: { dx: 60, dy: 30 } })), [key]); + await page.reload(); + await page.waitForSelector("#graph svg [data-node-id]", { timeout: 20000 }); + await page.waitForFunction(() => document.querySelector('#graph [data-node-id="worker"]').getAttribute("transform") !== "translate(230, 80)", null, { timeout: 10000 }); + check("a conflicting id takes the LOCAL delta, not the server's", /translate\(\s*60,\s*30\)/.test(await transformOf('#graph [data-node-id="api"]'))); + check("an id only the server has is applied", /translate\(\s*15,\s*25\)/.test(await transformOf('#graph [data-node-id="worker"]'))); + // Reset: back to dagre's placement, and the key goes with it. await page.click("#layout-reset"); await page.waitForTimeout(150); @@ -341,6 +372,8 @@ try { check("reset restores the edge's original curve", (await edgePath()) === "M 115 112 C 115 112, 305 112, 305 112"); check("reset clears this lens's key", (await layoutKeys()).length === 0); check("reset hides itself again", !(await page.locator("#layout-reset").isVisible())); + await page.waitForTimeout(800); + check("reset clears the sidecar too — or the next merge would pull it back", !server.layout.has("components")); // …and the two gestures that were there before still are. await page.click('#graph [data-node-id="api"]'); diff --git a/src/export.test.ts b/src/export.test.ts index fc1dee0..97fced0 100644 --- a/src/export.test.ts +++ b/src/export.test.ts @@ -18,6 +18,15 @@ describe("canonicalKey", () => { expect(canonicalKey("/api/project", new URLSearchParams())).toBe("/api/project"); }); + // #228: runExport appends `layout=1` to every capture request so the graph + // routes bake the hand-layout sidecar into the snapshot SVGs. It is not a + // lens (it doesn't select a distinct snapshot — it's how the ONE snapshot is + // rendered), so it must not reach the key the frontend will look up. + it("drops layout=1 — the bake changes the SVG, not which snapshot you want", () => { + expect(canonicalKey("/api/graph", new URLSearchParams("components=1&layout=1"))).toBe("/api/graph?components=1"); + expect(canonicalKey("/api/project", new URLSearchParams("layout=1"))).toBe("/api/project"); + }); + it("drops detail/radial for the components view (the frontend appends them, the DAG ignores them)", () => { // load() always sends the current detail even in the components view. const k = canonicalKey("/api/graph", new URLSearchParams("components=1&detail=3&env=local&radial=1")); diff --git a/src/export.ts b/src/export.ts index 82ad446..2215db8 100644 --- a/src/export.ts +++ b/src/export.ts @@ -97,7 +97,11 @@ function workerName(project: string, override?: string): string { /** Capture the estate `cfg` observes into a static bundle at `outDir`. */ export async function runExport(cfg: ServerOptions, outDir: string, opts: { name?: string } = {}): Promise { - const app = createApp(cfg); + // A capture reads the project; it never writes to it (#228). The layout + // sidecar is the one thing behold can write, and an export is exactly the + // wrong moment for it — so the app built here refuses that write outright + // rather than relying on nothing happening to call it. + const app = createApp({ ...cfg, layoutWrites: false }); const proj = (await (await app.request("/api/project")).json()) as { environments?: string[]; tiers?: string[] }; const axes: ExportAxes = { environments: proj.environments ?? [], tiers: proj.tiers ?? [] }; @@ -109,7 +113,12 @@ export async function runExport(cfg: ServerOptions, outDir: string, opts: { name let ok = 0; let failed = 0; for (const key of captureKeys(axes)) { - const res = await app.request(key); // key is already `path?sortedLensParams` + // `layout=1` asks the graph/overlay routes to bake the hand-layout sidecar's + // deltas into the SVG (#228), so a bundle shows the estate arranged the way + // it was arranged by hand. It is NOT a lens param — `canonicalKey` whitelists + // the six that select a distinct snapshot and drops everything else — so the + // captured key stays exactly what the frontend will ask for. + const res = await app.request(`${key}${key.includes("?") ? "&" : "?"}layout=1`); // key is already `path?sortedLensParams` const body = await res.text(); const file = slug(key); writeFileSync(join(snapDir, file), body); diff --git a/src/layout.test.ts b/src/layout.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..8ac5959980fca8af7862e5a5878099eb138b83e3 GIT binary patch literal 11857 zcmcIqYjWF065iiB#T1f?p_p$;jw6ezauRtfTk>NqZECj^MH&)A0u~4$n4u_I#j5<; z18nUX_Fy^5emye)h9D`^PMnHN5|5tlo_>7YJ?%F8qSt%cU{d6_B4;6wM~6&>f?c-; zB243;9rJlQRjqe2O|F@Y0x{${<4K^O3Ph~<@BjYK*-w9E1AZe~JMA_*$Ll%X5zScK zikOY3JP#!Evs~OpV#X$EAR3G_skn-UOx$H@t{R%&APweh$TPXicrI9!5Iva>+lIfE z5>mC4e8y%WS0E9KM6w$(XA?eW15nl1iT_a)3w@P@z-nNa?h!q@P0X?&6_TkGbaKIC zmPH937EKxQOazQ%A|^tSYq<*13Yk^T6FExr36@bIizIt~diI(PLovL;>wL_kL@G8D z1FOTPR4GWklaC}DrrEqf6r*JI2`%M2$$tOszna-JvGORAddL$NO-N^KkgBk?V}IPU zK*(Vpks2cC6M7Xxd=3Pd6eoNbLJcD&a{9^^Y?P-H=G{iLn)j@tGPwzqm}F=3WY{3Z z1TUaU^J|vl>FOt*jB;~F+(lAJ`_80#*FI1#mtgM?N77wSO)@;S{6xC+#Jdz)Dj3!j zGHBePvBMb8vUq-~ZS_1oyB#-h&6w^RJga}fR$f5nzfVOD-7}V8?|2-&7e9~{%cV=F zc$hAb{DvxtU0$(wwLXeRi^bDZo{#mTFQ5PO>c=0>uHKxyy*fR4bw;n#wHH60o?LzR zuk#aqWttEfK8VFz!Qo|%okG-B;e3kJu@ei$NK<|0;b*n2zQ4=*$Lt=|&247~ebSw8 zRP66Im}k7YX;jnnc6XocEbvzvh9#EkJ@i4Z!S11j;WU@gtw3zZr?4+r)X@H53!<@8 z*q-l|Y9+-uHXP4E=hrE$t?)1vM#&f!4x@&i;W+R{>$+ZpRZ>OD_ilv9%z_Jv%Rj21HPgSbs znb^wWuCDa+S69W#zNar>`x(IiSoj!;6~-*ni7<8^B_dbaL-EAl&R)N2AqYrBs3g@k zfqa$o_>iE2`HHx4T`qJ;QA3%qQu10*Bgl{dz~soF2of+iB;ItEkhlO$Dcp`}fk_jT zM0N1eU`IaEFiX0&zOMsV?*ui$@!b)1)rn zQrFvj^6Qkx{yj@?K#>J^hpdO>Nqgr6ve$!m00*tHP;ZJodOfN_W1XxGjP|WkRi$5Q z%cxQSMezbE5)fP3eu`E)aY{sFK2*pH1;7D7QS1ohiMb!5{J6aag)R|etjxhCS-J6? zQWwvnLJi$sg9Y}X~DK5lc62ww|H3arv3{o1HiwlJ?6bJJ#F@)HVzBxtHULKdg(Cd!{}Ckc71nu9CxW(i80 zh6*a+VH5$h9w4aVpG1|&h6*UgheMGmo(vIs(gZk6-h8@rj(OItJ5I;Z5J1B%u<(R5|xv#0IHH8 z(A)c7G%3gHnCn44G`)d)`4q-dnPn>;6H3k(Yy#+`pSR@hYY|P ziL@Tb%~~xZfaB6j#5ZiLu9hfJ=)lu5Hty1>_N?agdqmN);%GA$ll0a>@c?Kd(P=T} z$d9xDLusiZAyHZ);vu;LI3&2kZ>bizzF%A;-J&iCK8yQQ0OT8$|D|2 zu^e_k#&R7^k-$(?k^(tQGSjX26XkZ?x5#R15{jDx)}<(iUlJ3Jz$THD*r*2bL7q=D zv=`DGW}YVFqS~N*&$!v=x%MJpFKuZBngXpGqLou1&1?JYaDMtB`y~D-OO<_yby*Wd z?@9q%I+1qJ2U6ud!ANR-)e089>)W#TfkkxU2Iz)bqtP6|&G@0?@M|pY^Q?W*Z7_hO zy~`T5_y}>=*bE#zKuHVy9T-DbA<)1$)PX@#2~m{f=m#RM!gx4)^ZM+(W*VPun^$(g z0Yc?&+%xa{*Dv3^es%Kd{EV=<8)3Zz1m=cL5>DW@L2}LZeH1=9SQl!kNvahX!7mFy zr5m2;&*t-*>P%jmpgnNN-lCexgCk>cJROBx{pn20!tBX{?rUoOjI@6uQKWDxPzvrpx zXZ}$mcJ<@L_y=OIDN~k=;S|lQbA%Irxr_P%lc4qlYIWPPD4M?5aH){hSUSRl0)z>F zz>#VeV0m4yJL>beNF0u2$_Wh6BtRr}P0f=uSfRNOZ7KC4ZX+3? zzm6UdD$mCz9w0>M_t7t+YA%eF?o66d5eL3OJcyDB?h&;?ddGZ@u@NE#?GmU&)HsS1 z$kLn!>WZ{2(Pc14qo@e#!&r!Rm$;yr6VbBR46sJ`Kf$T&kp_#GPWezlZyvgOB4GfN-;W)TUd-a$^>7#dRCJNn@(MhlYda zTvTb_o2B^;;N$TTHI&&96$Ug#tdkuyX{+%@-+Rfr-2?pf*!RUZAe@fARDP*(Ye;VY zGIA+;o_)_TC=cUJy4bXO>h41eNA2-u5v@EmDItEM0F<|o(+UMSOY!!LR6?y^1h<2iS5hIN^M_6xRBRXKLrSud zdQH-I^zxuO3Tr7a|AFb^JaMp~#Sb48x*+9MJh~~1!W2bEx)w@|@1Wh1ME!`rIh!6*Q_9e3las9nT{$j7!LOWB!Yf5)F_T;P|pjO;HNT-Mhakr z+z&@HP*Bkk6JWVdR4qKA&}$b#kcJmkF<7`S>OVk+R;vZcTR4g`44-3|+15Gi;+Nyg zuYVpOBKjAIg&{kQ*7bR$Xy{4x+PG9$f;TF;@jO`0uml%xxQ=xML)L51{)zwTBjFyerEskLVcdS@Zjx6~hV zLI<<~E#VjufI9rjc$7rNZ#@k`8OIPXM%An7qLdCDj=*PVyWpfz9?z&50317zv7eZ+2D(FI!3kuH&H{IN)i_aARh&jinh;TxR5n@INnUB7FO z8W0q4#&6m*3w2S$h{0#rqE+efb}SV!JIRT~0r z!L(&{MF$eY5c%=s9Er;Vh96JGGS&Oa99o3z`dnfEg!*T%fDgb~G4b@ub?gzx$JH4` Moz<-rWQ_Fpe{;CEGXMYp literal 0 HcmV?d00001 diff --git a/src/layout.ts b/src/layout.ts new file mode 100644 index 0000000..f2afcae --- /dev/null +++ b/src/layout.ts @@ -0,0 +1,341 @@ +/** + * The hand-layout sidecar (#228, server half) — `.behold/layout.json` inside + * the served project, and the delta→SVG math that makes an export honour it. + * + * THE WRITE BOUNDARY. This module is the ONLY place behold writes into a + * served project, and it writes exactly one file: `/.behold/ + * layout.json` (through a sibling `.tmp` it renames over). No path here comes + * from a request — the directory is `cfg.projectDir`, the basename is a + * constant, and a lens key from the wire is slugged to `[a-z0-9+-]` before it + * is ever used, and even then only as a JSON object key, never as a path + * segment. Nothing else in the project is read for writing, created, or + * removed. + * + * That is deliberately a much smaller claim than "behold writes now". The + * invariant stands: behold never mutates the cloud, and never mutates your + * chant source. A layout sidecar is workspace metadata about how YOU want the + * picture arranged — the same category as an editor's fold state — and it is + * per-user, so a project that tracks `.behold.json` (config, shared) should + * still ignore `.behold/` (state, yours). See README "Configuration". + * + * The file: + * { "version": 1, "lenses": { "": { "": {dx,dy,dw,dh} } } } + * + * Deltas, never absolute positions — see web/layout-store.js for why that + * ordering matters. `` is the same key shape the client's `lensKeyOf` + * builds (zoom stop + `+radial` / `+stack-`); the project half of the + * client's localStorage key is implicit here, because the file lives in the + * project. + */ +import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, accessSync, constants } from "node:fs"; +import { join } from "node:path"; + +export interface LayoutDelta { + dx?: number; + dy?: number; + dw?: number; + dh?: number; +} +export type LayoutDeltas = Record; +export interface LayoutFile { + version: 1; + lenses: Record; +} + +/** The sidecar's directory name inside the served project. */ +export const LAYOUT_DIR = ".behold"; +export const LAYOUT_FILE = "layout.json"; + +/** Caps. A hand layout is a few dozen deltas; everything above is a mistake or + * an attack, and either way the answer is a polite refusal, not a 40MB file in + * someone's repo. Enforced on the request body, on what a request may add, and + * again on the serialized file before it is written. */ +export const MAX_BODY_BYTES = 64 * 1024; +export const MAX_FILE_BYTES = 256 * 1024; +export const MAX_IDS_PER_LENS = 2000; +export const MAX_LENSES = 64; +const MAX_ID_LENGTH = 512; +const MAX_LENS_LENGTH = 128; + +const NUM = ["dx", "dy", "dw", "dh"] as const; + +/** MUST stay identical to `slug` in web/layout-store.js. */ +export function slug(s: unknown): string { + return ( + String(s ?? "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") || "unknown" + ); +} + +/** + * A lens key off the wire → the one the file is keyed by, or null if it is not + * a lens key at all. The `+` separators the client's `lensKeyOf` joins with + * survive; everything else is slugged away, so nothing path-shaped (or + * prototype-shaped) can reach the file's key space. + */ +export function normalizeLens(raw: unknown): string | null { + if (typeof raw !== "string" || !raw.trim() || raw.length > MAX_LENS_LENGTH) return null; + const parts = raw.split("+").map((p) => slug(p)).filter((p) => p && p !== "unknown"); + if (!parts.length) return null; + const key = parts.join("+"); + return key === "__proto__" || key === "constructor" || key === "prototype" ? null : key; +} + +/** MUST stay identical to `normalize` in web/layout-store.js, plus a cap on + * how many ids one lens may carry. */ +export function normalizeDeltas(raw: unknown): LayoutDeltas { + const out: LayoutDeltas = {}; + if (!raw || typeof raw !== "object") return out; + for (const [id, d] of Object.entries(raw as Record)) { + if (!id || id.length > MAX_ID_LENGTH || id === "__proto__" || !d || typeof d !== "object") continue; + const clean: LayoutDelta = {}; + for (const k of NUM) { + const v = Number((d as Record)[k]); + if (Number.isFinite(v) && v !== 0) clean[k] = v; + } + if (Object.keys(clean).length) out[id] = clean; + if (Object.keys(out).length >= MAX_IDS_PER_LENS) break; + } + return out; +} + +/** + * The lens a graph/overlay request is asking for, from its query params — + * the server-side mirror of web/app.js's `zoomValue()` fed through + * web/layout-store.js's `lensKeyOf()`. The two MUST agree: a delta the client + * stored under `components` has to be the one a `?components=1` export reads + * back. The env is deliberately not part of it (an overlay recolours the same + * nodes, it does not re-place them). + */ +export function lensFromQuery(params: URLSearchParams): string { + const zoom = + params.get("components") === "1" + ? "components" + : params.get("logical") === "1" + ? "logical" + : params.get("runtime") === "1" + ? "runtime" + : ({ 1: "composites", 2: "resources", 3: "attributes" }[Number(params.get("detail") ?? 2)] ?? "resources"); + const stack = params.get("stack"); + return [slug(zoom), params.get("radial") === "1" ? "radial" : null, stack ? slug(`stack-${stack}`) : null].filter(Boolean).join("+"); +} + +export function layoutPath(projectDir: string): string { + return join(projectDir, LAYOUT_DIR, LAYOUT_FILE); +} + +/** Why this project can't take a layout write, or null when it can. The + * preview/static gates live in server.ts (they're about the MODE, not the + * directory); this is the directory's own answer. */ +export function unwritableReason(projectDir: string): string | null { + if (!projectDir || !existsSync(projectDir)) return `no such project directory: ${projectDir || "(none)"}`; + try { + accessSync(projectDir, constants.W_OK); + } catch { + return "the served project directory is read-only"; + } + return null; +} + +/** Read the whole sidecar. Missing, oversized, unparseable, or the wrong shape + * all read as empty — a layout is a nicety and must never break a render. */ +export function readLayoutFile(projectDir: string): LayoutFile { + const empty: LayoutFile = { version: 1, lenses: {} }; + const file = layoutPath(projectDir); + try { + if (!existsSync(file)) return empty; + const raw = readFileSync(file, "utf8"); + if (raw.length > MAX_FILE_BYTES) return empty; + const parsed = JSON.parse(raw) as { lenses?: unknown }; + if (!parsed || typeof parsed !== "object" || !parsed.lenses || typeof parsed.lenses !== "object") return empty; + const lenses: Record = {}; + for (const [lens, deltas] of Object.entries(parsed.lenses as Record)) { + const key = normalizeLens(lens); + if (!key) continue; + const clean = normalizeDeltas(deltas); + if (Object.keys(clean).length) lenses[key] = clean; + if (Object.keys(lenses).length >= MAX_LENSES) break; + } + return { version: 1, lenses }; + } catch { + return empty; + } +} + +/** One lens's deltas — `{}` when the file, the lens, or the disk isn't there. */ +export function readLens(projectDir: string, lens: string): LayoutDeltas { + const key = normalizeLens(lens); + if (!key) return {}; + return readLayoutFile(projectDir).lenses[key] ?? {}; +} + +export class LayoutTooLarge extends Error {} + +/** + * Replace one lens's deltas and persist. An empty map drops the lens; the last + * lens leaving drops the file. Writes through a sibling `.tmp` + rename so a + * crash mid-write can't leave a half-written sidecar behind. + * + * Returns what was actually stored (normalized), so the caller echoes truth + * rather than the request. + */ +export function writeLens(projectDir: string, lens: string, deltas: unknown): { lens: string; deltas: LayoutDeltas; bytes: number } { + const key = normalizeLens(lens); + if (!key) throw new Error(`not a lens key: ${String(lens)}`); + const clean = normalizeDeltas(deltas); + const current = readLayoutFile(projectDir); + const lenses = { ...current.lenses }; + if (Object.keys(clean).length) lenses[key] = clean; + else delete lenses[key]; + if (Object.keys(lenses).length > MAX_LENSES) throw new LayoutTooLarge(`a layout sidecar holds at most ${MAX_LENSES} lenses`); + + const file = layoutPath(projectDir); + if (!Object.keys(lenses).length) { + rmSync(file, { force: true }); + return { lens: key, deltas: clean, bytes: 0 }; + } + const body = JSON.stringify({ version: 1, lenses }, null, 2) + "\n"; + if (body.length > MAX_FILE_BYTES) throw new LayoutTooLarge(`a layout sidecar is capped at ${MAX_FILE_BYTES} bytes`); + mkdirSync(join(projectDir, LAYOUT_DIR), { recursive: true }); + const tmp = `${file}.tmp`; + writeFileSync(tmp, body, "utf8"); + renameSync(tmp, file); + return { lens: key, deltas: clean, bytes: body.length }; +} + +// --- The delta → SVG math --------------------------------------------------- +// The three functions below MUST stay identical to their copies in +// web/layout-store.js — same shapes, same number formatting, byte for byte. +// web/layout-store.test.js asserts that across a table of cases, importing +// both modules, so a drift is a failing test rather than an export that +// disagrees with the screen it was taken from. (Same discipline as +// src/export.ts's `canonicalKey`, which mirrors web/app.js's.) + +/** A node group's transform with its delta ridden on top of dagre's own. */ +export function nodeTransform(base: string, d: LayoutDelta): string { + const dx = d.dx || 0; + const dy = d.dy || 0; + if (!dx && !dy) return base; + return `translate(${dx}, ${dy}) ${base}`.trim(); +} + +/** First and last coordinate pair of a path `d` — pinhole's own edge anchors. */ +export function pathAnchors(d: string | null | undefined): { sx: number; sy: number; ex: number; ey: number } | null { + const n = String(d || "").match(/-?\d*\.?\d+(?:e[-+]?\d+)?/gi); + if (!n || n.length < 4) return null; + return { sx: +n[0], sy: +n[1], ex: +n[n.length - 2], ey: +n[n.length - 1] }; +} + +/** An edge whose ends moved: a straight line between the original anchors, + * each shifted by ITS OWN end's delta. #228 accepts the straight-line + * fallback explicitly; spline re-routing stays pinhole's job. */ +export function straightEdge( + anchors: { sx: number; sy: number; ex: number; ey: number }, + from: LayoutDelta | undefined, + to: LayoutDelta | undefined, +): string { + const { sx, sy, ex, ey } = anchors; + return `M ${sx + ((from && from.dx) || 0)} ${sy + ((from && from.dy) || 0)} L ${ex + ((to && to.dx) || 0)} ${ey + ((to && to.dy) || 0)}`; +} + +/** XML entities in an attribute value → the raw text, so an id off the wire + * can be compared with what the painter escaped into the SVG. */ +function unescapeAttr(v: string): string { + return v + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/'/g, "'") + .replace(/&/g, "&"); +} + +function attrOf(tag: string, name: string): string | null { + const m = new RegExp(`\\b${name}="([^"]*)"`).exec(tag); + return m ? unescapeAttr(m[1]) : null; +} + +/** Index of the `` closing the group whose body starts at `from`. */ +function groupEnd(svg: string, from: number): number { + const tok = //g; + tok.lastIndex = from; + let depth = 1; + let m: RegExpExecArray | null; + while ((m = tok.exec(svg))) { + if (m[0][1] === "/") { + depth--; + if (!depth) return m.index; + } else depth++; + } + return svg.length; +} + +/** + * Bake a lens's translate deltas into a rendered SVG string, so a server-side + * export or static snapshot shows the graph the way it was arranged by hand. + * + * Deliberately the SAME transform the client applies (`nodeTransform`), and + * deliberately NOT everything the client does: + * * `{dw,dh}` box resizes are skipped. pinhole's containment boxes carry no + * identity at all (a bare `` + its title `` — see + * wrapContainmentBoxes in web/app.js), so the client's `box:` ids + * are synthesized from live DOM structure. Reconstructing that by string + * surgery would be guessing; when pinhole stamps a `data-group-id`, this + * is where boxes join. + * * edge labels keep their original midpoints, exactly as on the client. + * + * This is a string pass, not a DOM one: no XML parser rides in the server + * bundle, and the painter's output is machine-generated and regular. A node id + * containing a literal `>` would defeat the tag scan; chant ids don't, and the + * failure mode is a delta not applied, never a corrupted document. + */ +export function applyLayoutToSvg(svg: string, deltas: LayoutDeltas): { svg: string; applied: number } { + const moved: LayoutDeltas = {}; + for (const [id, d] of Object.entries(normalizeDeltas(deltas))) if (d.dx || d.dy) moved[id] = d; + if (!Object.keys(moved).length || typeof svg !== "string" || !svg) return { svg, applied: 0 }; + + const placed = new Set<string>(); + let out = svg.replace(/<g\b([^>]*?)(\/?)>/g, (tag, attrs: string, selfClose: string) => { + const id = attrOf(attrs, "data-node-id"); + const d = id ? moved[id] : undefined; + if (!id || !d) return tag; + placed.add(id); + // The base transform is spliced RAW (the delta is pure digits, so nothing + // needs escaping and the painter's own escaping is preserved verbatim) — + // and every other attribute is left exactly as it was painted. + const base = /\btransform="([^"]*)"/.exec(attrs); + const next = nodeTransform(base ? base[1] : "", d); + if (base) return `<g${attrs.replace(base[0], () => `transform="${next}"`)}${selfClose}>`; + return `<g${attrs}${/\s$/.test(attrs) ? "" : " "}transform="${next}"${selfClose}>`; + }); + + out = reanchorEdges(out, moved); + return { svg: out, applied: placed.size }; +} + +/** Re-anchor every edge with a displaced end, leaving the rest byte-identical. */ +function reanchorEdges(svg: string, moved: LayoutDeltas): string { + const open = /<g\b([^>]*\bdata-edge-from="[^"]*"[^>]*?)(\/?)>/g; + let out = ""; + let cursor = 0; + let m: RegExpExecArray | null; + while ((m = open.exec(svg))) { + const bodyStart = m.index + m[0].length; + if (m[2] === "/") continue; // self-closing: no paths to move + const bodyEnd = groupEnd(svg, bodyStart); + open.lastIndex = bodyEnd; + const from = moved[attrOf(m[1], "data-edge-from") ?? ""]; + const to = moved[attrOf(m[1], "data-edge-to") ?? ""]; + if (!from && !to) continue; + const body = svg.slice(bodyStart, bodyEnd); + const first = /<path\b[^>]*\bd="([^"]*)"/.exec(body); + const anchors = first ? pathAnchors(first[1]) : null; + if (!anchors) continue; + const d = straightEdge(anchors, from, to); + out += svg.slice(cursor, bodyStart) + body.replace(/(<path\b[^>]*\bd=")([^"]*)(")/g, (_t, a: string, _d: string, z: string) => a + d + z); + cursor = bodyEnd; + } + return out + svg.slice(cursor); +} diff --git a/src/server.test.ts b/src/server.test.ts index 4c9a23c..60bcc59 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -66,7 +66,7 @@ import { OpRunner } from "./op-runner.ts"; import { Broadcaster } from "./events.ts"; import { FrameBuffer } from "./frames.ts"; import { shortStackNames } from "@intentius/pinhole"; -import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { mkdtempSync, writeFileSync, rmSync, readFileSync, readdirSync, existsSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; @@ -458,6 +458,170 @@ describe("GET /api — the route index (#193)", () => { }); }); +// #228: the hand-layout sidecar — behold's first (and only) write into a +// served project. What may be written, what must be refused, and that a stored +// delta reaches the SVG a `behold export` would capture. +describe("GET/POST /api/layout — the hand-layout sidecar (#228)", () => { + let dirs: string[] = []; + beforeEach(() => vi.mocked(spawnMock).mockReset()); + afterEach(() => { + dirs.forEach((d) => rmSync(d, { recursive: true, force: true })); + dirs = []; + }); + const tmpProject = () => { + const dir = mkdtempSync(join(tmpdir(), "behold-layout-route-")); + writeFileSync(join(dir, "chant.config.ts"), `export default { lexicons: ["aws"] };`); + dirs.push(dir); + return dir; + }; + const appFor = (cfg: Parameters<typeof createApp>[0]) => { + const broadcaster = new Broadcaster(); + const runner = new OpRunner({ projectDir: cfg.projectDir, broadcaster, onDone: () => {} }); + return createApp(cfg, broadcaster, new FrameBuffer(), runner); + }; + const post = (app: ReturnType<typeof makeAppFor>, body: unknown, headers: Record<string, string> = { "content-type": "application/json" }) => + app.request("/api/layout", { method: "POST", headers, body: typeof body === "string" ? body : JSON.stringify(body) }); + + it("writes one lens and reads it back — through the file, not memory", async () => { + const dir = tmpProject(); + const app = appFor({ projectDir: dir, port: 0 }); + const wrote = await post(app, { lens: "components", deltas: { api: { dx: 12, dy: -4 }, ghost: { dx: 0 } } }); + expect(wrote.status).toBe(200); + expect(await wrote.json()).toMatchObject({ ok: true, lens: "components", count: 1, deltas: { api: { dx: 12, dy: -4 } } }); + + // On disk, at the one path behold is allowed to write. + expect(JSON.parse(readFileSync(join(dir, ".behold", "layout.json"), "utf8"))).toEqual({ + version: 1, + lenses: { components: { api: { dx: 12, dy: -4 } } }, + }); + + // A FRESH app (nothing cached) reads the same thing back. + const read = await appFor({ projectDir: dir, port: 0 }).request("/api/layout?lens=components"); + expect(await read.json()).toMatchObject({ lens: "components", writable: true, deltas: { api: { dx: 12, dy: -4 } } }); + }); + + it("keeps lenses independent, and lists them all with no ?lens=", async () => { + const app = appFor({ projectDir: tmpProject(), port: 0 }); + await post(app, { lens: "components", deltas: { api: { dx: 1 } } }); + await post(app, { lens: "logical", deltas: { api: { dy: 2 } } }); + const all = (await (await app.request("/api/layout")).json()) as { lenses: Record<string, unknown> }; + expect(all.lenses).toEqual({ components: { api: { dx: 1 } }, logical: { api: { dy: 2 } } }); + expect(((await (await app.request("/api/layout?lens=components")).json()) as { deltas: unknown }).deltas).toEqual({ api: { dx: 1 } }); + }); + + it("an empty map is the reset — the lens goes, and with the last lens the file", async () => { + const dir = tmpProject(); + const app = appFor({ projectDir: dir, port: 0 }); + await post(app, { lens: "components", deltas: { api: { dx: 1 } } }); + const res = await post(app, { lens: "components", deltas: {} }); + expect(res.status).toBe(200); + expect(existsSync(join(dir, ".behold", "layout.json"))).toBe(false); + }); + + it("touches nothing else in the project", async () => { + const dir = tmpProject(); + writeFileSync(join(dir, ".behold.json"), JSON.stringify({ tiers: { envVar: "T", values: ["a"] } })); + const before = readdirSync(dir).sort(); + await post(appFor({ projectDir: dir, port: 0 }), { lens: "components", deltas: { api: { dx: 1 } } }); + expect(readdirSync(dir).sort()).toEqual([...before, ".behold"].sort()); + // …and the config it sits beside is untouched (`.behold.json` is tracked + // config; `.behold/` is per-user state — two different things, #228). + expect(JSON.parse(readFileSync(join(dir, ".behold.json"), "utf8"))).toEqual({ tiers: { envVar: "T", values: ["a"] } }); + }); + + it("403s in preview mode, and says so — reads still answer", async () => { + const dir = tmpProject(); + const app = appFor({ projectDir: dir, port: 0, previewMode: true }); + const res = await post(app, { lens: "components", deltas: { api: { dx: 1 } } }); + expect(res.status).toBe(403); + expect((await res.json()) as { error: string; code: string }).toMatchObject({ code: "read-only" }); + expect(existsSync(join(dir, ".behold"))).toBe(false); + const read = (await (await app.request("/api/layout?lens=components")).json()) as { writable: boolean; reason: string }; + expect(read.writable).toBe(false); + expect(read.reason).toMatch(/preview mode/); + }); + + it("403s during a static-export capture — a snapshot reads the project, never writes it", async () => { + const dir = tmpProject(); + const app = appFor({ projectDir: dir, port: 0, layoutWrites: false }); + expect((await post(app, { lens: "components", deltas: { api: { dx: 1 } } })).status).toBe(403); + const read = (await (await app.request("/api/layout?lens=components")).json()) as { writable: boolean; reason: string }; + expect(read.reason).toMatch(/static export/); + }); + + it("403s a project directory that isn't there", async () => { + const app = appFor({ projectDir: join(tmpdir(), "behold-not-a-dir-228"), port: 0 }); + const res = await post(app, { lens: "components", deltas: { api: { dx: 1 } } }); + expect(res.status).toBe(403); + expect(((await res.json()) as { error: string }).error).toMatch(/no such project directory/); + }); + + it("rejects an oversized body before parsing it", async () => { + const dir = tmpProject(); + const app = appFor({ projectDir: dir, port: 0 }); + const deltas: Record<string, { dx: number }> = {}; + for (let i = 0; i < 5000; i++) deltas[`node-${"x".repeat(40)}-${i}`] = { dx: i }; + const res = await post(app, { lens: "components", deltas }); + expect(res.status).toBe(413); + expect(((await res.json()) as { code: string }).code).toBe("too-large"); + expect(existsSync(join(dir, ".behold"))).toBe(false); + }); + + it("400s a body that isn't a layout, and 415s one that isn't JSON at all", async () => { + const app = appFor({ projectDir: tmpProject(), port: 0 }); + expect((await post(app, "{not json")).status).toBe(400); + expect((await post(app, { deltas: {} })).status).toBe(400); // no lens + expect((await post(app, { lens: "///", deltas: {} })).status).toBe(400); // not a lens key + expect((await post(app, { lens: "components" })).status).toBe(400); // no deltas + expect((await post(app, { lens: "components", deltas: [1, 2] })).status).toBe(400); + // A JSON content-type is the CSRF guard (cross-origin JSON POSTs preflight). + expect((await post(app, { lens: "components", deltas: {} }, { "content-type": "text/plain" })).status).toBe(415); + expect((await app.request("/api/layout?lens=%2F%2F%2F")).status).toBe(400); + }); + + it("a stored delta lands in the SVG a `behold export` captures (?layout=1)", { timeout: 20_000 }, async () => { + const dir = tmpProject(); + const app = appFor({ projectDir: dir, port: 0 }); + await post(app, { lens: "components", deltas: { api: { dx: 40, dy: -25 } } }); + const IR = JSON.stringify({ + nodes: [ + { id: "api", kind: "Component", lexicon: "aws", attrs: {} }, + { id: "worker", kind: "Component", lexicon: "aws", attrs: {} }, + ], + edges: [{ from: "api", to: "worker" }], + groups: {}, + }); + vi.mocked(spawnMock).mockImplementation((() => fakeProc(0, IR)) as never); + + const plain = (await (await app.request("/api/graph?components=1")).json()) as { svg: string; meta: { layout?: unknown } }; + const baked = (await (await app.request("/api/graph?components=1&layout=1")).json()) as { svg: string; meta: { layout?: { applied: number; lens: string } } }; + + // The interactive SPA never asks, and gets dagre's own coordinates. + expect(plain.meta.layout).toBeUndefined(); + expect(plain.svg).not.toContain("translate(40, -25)"); + // An export asks, and the card carries the delta. (pinhole positions cards + // by absolute child coordinates, so a real card group usually has no + // transform of its own for the delta to ride on — both cases are covered + // in src/layout.test.ts against a fabricated SVG.) + expect(baked.meta.layout).toEqual({ lens: "components", applied: 1 }); + expect(baked.svg).toContain(`<g data-node-id="api" transform="translate(40, -25)">`); + expect(baked.svg).toContain(`<g data-node-id="worker">`); // the node that didn't move is untouched + // …and the edge between them re-anchors: the moved end shifts by the + // node's own delta, the other stays exactly where pinhole put it. + const anchors = /<path class="pin-edge-line" d="M ([-\d.]+) ([-\d.]+) L ([-\d.]+) ([-\d.]+)"/.exec(baked.svg); + const before = /<path class="pin-edge-line" d="M ([-\d.]+) ([-\d.]+)[^"]*?([-\d.]+) ([-\d.]+)"/.exec(plain.svg); + expect(anchors).not.toBeNull(); + expect(Number(anchors![1]) - Number(before![1])).toBe(40); + expect(Number(anchors![2]) - Number(before![2])).toBe(-25); + expect(anchors![3]).toBe(before![3]); + expect(anchors![4]).toBe(before![4]); + + // A different lens's request is untouched by this lens's deltas. + const other = (await (await app.request("/api/graph?detail=2&layout=1")).json()) as { meta: { layout?: unknown } }; + expect(other.meta.layout).toBeUndefined(); + }); +}); + // #195: runtime project switching + reveal-in-file-manager. The switch // mutates the shared cfg the routes read at request time; recents go through // BEHOLD_RECENTS_FILE so tests never touch the real ~/.behold/recents.json. diff --git a/src/server.ts b/src/server.ts index 218c717..2f484d8 100644 --- a/src/server.ts +++ b/src/server.ts @@ -11,7 +11,7 @@ * most one delegated action in flight at a time. See README "Read-only core, * delegated gated writes". */ -import { Hono, type Context } from "hono"; +import { Hono, type Context, type Next } from "hono"; import { resolveSubstrateTargets } from "./targets.ts"; import { loadKubeconfig, resolveK8sTarget, type K8sTarget } from "./k8s-target.ts"; import { streamSSE } from "hono/streaming"; @@ -70,6 +70,18 @@ import { Broadcaster, watchSource } from "./events.ts"; import { startDriftPoll } from "./poll.ts"; import { FrameBuffer } from "./frames.ts"; import { renderLanes } from "./lanes.ts"; +import { + applyLayoutToSvg, + layoutPath, + lensFromQuery, + normalizeLens, + readLayoutFile, + readLens, + writeLens, + LayoutTooLarge, + MAX_BODY_BYTES, + unwritableReason, +} from "./layout.ts"; import { emulatorUp, emulatorDown, mergedEnv, type EmulatorInfo } from "./emulator.ts"; const webRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "web"); @@ -103,6 +115,13 @@ export interface ServerOptions { * strip to Docker+Floci, and tells the SPA to hide those controls. Local * deploy (apply/reset/bring-up/approve) and all reads stay on. */ previewMode?: boolean; + /** #228: may this server write the hand-layout sidecar (`.behold/layout.json` + * in the served project)? Default true for a live `serve`. `runExport` sets + * it false: a static capture reads the sidecar (to bake it into the snapshot + * SVGs) and must never write one — the bundle it produces has no backend at + * all. The other two "no" answers are computed, not configured: preview mode, + * and a project directory that isn't writable. */ + layoutWrites?: boolean; /** #195: called by POST /api/project/open after the cfg has been re-pointed * at a switched project — startServer uses it to re-aim the source watcher, * stop the (launch-scoped) drift poll, and capture a fresh baseline frame. @@ -668,6 +687,113 @@ export function createApp( } }); + // --- The hand-layout sidecar (#228) ------------------------------------- + // behold's FIRST write into a served project, and the boundary is drawn + // tightly on purpose (src/layout.ts carries the full statement of it): + // + // * ONE file, `<projectDir>/.behold/layout.json`, path built from + // `cfg.projectDir` + two constants. No path, prefix or segment from the + // request reaches the filesystem — a lens key off the wire is slugged and + // used only as a JSON object key. + // * The invariant is untouched. behold still never mutates the cloud and + // never mutates your chant source; authority stays in the committed + // source and the executor (AGENTS.md). A layout sidecar is workspace + // metadata about how you want the picture arranged — per-user state, the + // category `.behold.json` (config, tracked) is deliberately NOT in. + // * Four ways to be told no, all polite: preview mode, an export capture, + // a project directory that isn't writable, and the size caps. + // + // The client keeps its own localStorage tier and merges local OVER server + // (web/layout-store.js `mergeLayouts`), so this is share-and-export, never a + // remote authority over the browser you're looking at. + const layoutWriteBlock = (): string | null => { + if (cfg.previewMode) return "the layout sidecar is read-only in preview mode"; + if (cfg.layoutWrites === false) return "a static export captures a snapshot — it doesn't write to the project"; + return unwritableReason(cfg.projectDir); + }; + + app.get("/api/layout", (c) => { + const block = layoutWriteBlock(); + const shared = { path: layoutPath(cfg.projectDir), writable: !block, ...(block ? { reason: block } : {}) }; + const raw = new URL(c.req.url).searchParams.get("lens"); + // No `?lens=` → the whole file, which is how you'd inspect or diff one. + if (raw === null) return c.json({ ...shared, lenses: readLayoutFile(cfg.projectDir).lenses }); + const lens = normalizeLens(raw); + if (!lens) return c.json({ error: `not a lens key: ${raw}`, code: "bad-layout" }, 400); + return c.json({ ...shared, lens, deltas: readLens(cfg.projectDir, lens) }); + }); + + app.post("/api/layout", async (c) => { + const block = layoutWriteBlock(); + if (block) return c.json({ error: block, code: "read-only" }, 403); + // A JSON body, not a form or a query param — cross-origin JSON POSTs + // preflight, so a hostile page can't blind-fire a write at localhost. Same + // reasoning as /api/project/open. + if (!(c.req.header("content-type") ?? "").includes("application/json")) { + return c.json({ error: "send application/json", code: "bad-layout" }, 415); + } + const declared = Number(c.req.header("content-length") ?? 0); + if (declared > MAX_BODY_BYTES) return c.json({ error: `a layout body is capped at ${MAX_BODY_BYTES} bytes`, code: "too-large" }, 413); + const text = await c.req.text().catch(() => ""); + if (text.length > MAX_BODY_BYTES) return c.json({ error: `a layout body is capped at ${MAX_BODY_BYTES} bytes`, code: "too-large" }, 413); + let body: { lens?: unknown; deltas?: unknown }; + try { + body = JSON.parse(text || "null") as { lens?: unknown; deltas?: unknown }; + } catch { + return c.json({ error: "body must be JSON", code: "bad-layout" }, 400); + } + const lens = normalizeLens(body?.lens); + if (!lens) return c.json({ error: "body needs a `lens` key (the zoom stop, plus +radial / +stack-<name>)", code: "bad-layout" }, 400); + if (!body?.deltas || typeof body.deltas !== "object" || Array.isArray(body.deltas)) { + return c.json({ error: "body needs `deltas`: {<node id>: {dx,dy,dw,dh}}", code: "bad-layout" }, 400); + } + try { + // Echoes what was STORED, not what was sent: zeroes and junk are pruned + // on the way in, and the client should see the truth on disk. + const stored = writeLens(cfg.projectDir, lens, body.deltas); + return c.json({ ok: true, lens: stored.lens, deltas: stored.deltas, count: Object.keys(stored.deltas).length, path: layoutPath(cfg.projectDir) }); + } catch (err) { + if (err instanceof LayoutTooLarge) return c.json({ error: err.message, code: "too-large" }, 413); + return c.json({ error: err instanceof Error ? err.message : String(err) }, 500); + } + }); + + // `?layout=1` on a graph/overlay read bakes the sidecar's translate deltas + // into the SVG before it goes out (src/layout.ts `applyLayoutToSvg`) — that + // is what makes `behold export` and a static snapshot honour a hand layout. + // + // Opt-IN rather than always-on, which is the one design decision here worth + // stating: 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. + // + // Registered as middleware ahead of both routes so the six render paths that + // return an `svg` (source, components, logical, estate, overlay, runtime) + // don't each grow a branch. + const bakeHandLayout = async (c: Context, next: Next): Promise<void> => { + await next(); + const url = new URL(c.req.url); + if (url.searchParams.get("layout") !== "1") return; + const res = c.res; + if (!res || res.status !== 200 || !(res.headers.get("content-type") ?? "").includes("json")) return; + const lens = lensFromQuery(url.searchParams); + const deltas = readLens(cfg.projectDir, lens); + if (!Object.keys(deltas).length) return; + let body: { svg?: unknown; meta?: Record<string, unknown> }; + try { + body = (await res.clone().json()) as { svg?: unknown; meta?: Record<string, unknown> }; + } catch { + return; // not a body we understand — leave it exactly as the route wrote it + } + if (typeof body.svg !== "string") return; + const { svg, applied } = applyLayoutToSvg(body.svg, deltas); + if (!applied) return; + c.res = c.json({ ...body, svg, meta: { ...(body.meta ?? {}), layout: { lens, applied } } }); + }; + app.use("/api/graph", bakeHandLayout); + app.use("/api/overlay", bakeHandLayout); + // #193: the API's front door, for agents. Everything the SPA can see is // plain JSON over these routes — this index makes them discoverable without // reading source. Shapes and the read/act loop: AGENTS.md (shipped in the @@ -682,8 +808,10 @@ export function createApp( { method: "GET", path: "/api/project", desc: "project info: dir, recents, environments, tiers, targets, stacks, preview lock" }, { method: "POST", path: "/api/project/open", desc: "switch the served project: JSON body {dir} (validated; preview-locked)" }, { method: "POST", path: "/api/project/reveal", desc: "open the OS file manager at a served/recent project dir: JSON body {dir?}" }, - { method: "GET", path: "/api/graph", desc: "the graph {ir, svg, meta} — params: detail=0..3, components=1, logical=1, env, stack, tier, target, lens, up=1, down=1, radial=1" }, + { method: "GET", path: "/api/graph", desc: "the graph {ir, svg, meta} — params: detail=0..3, components=1, logical=1, env, stack, tier, target, lens, up=1, down=1, radial=1, layout=1" }, { method: "GET", path: "/api/overlay", desc: "live drift overlay for ?env= — same shape/params as /api/graph, plus runtime=1" }, + { method: "GET", path: "/api/layout", desc: "hand-layout sidecar (.behold/layout.json): ?lens=<key> → {lens, deltas, writable}; no lens → every lens" }, + { method: "POST", path: "/api/layout", desc: "store one lens's deltas: JSON body {lens, deltas: {<node id>: {dx,dy,dw,dh}}} (the only file behold writes in your project)" }, { method: "GET", path: "/api/diff", desc: "per-node live diff for ?env= — {env, nodes: {<id>: {observed, diff, health, fieldDrift}}}" }, { method: "GET", path: "/api/reconcile", desc: "pending-change summary for ?env=" }, { method: "GET", path: "/api/resources", desc: "component → declared resources" }, diff --git a/web/app.js b/web/app.js index 26ac825..e5bd517 100644 --- a/web/app.js +++ b/web/app.js @@ -13,7 +13,24 @@ import { initPanel, setPanelTab, togglePanelCollapsed, isPanelCollapsed } from " // #228: the hand-layout delta store — everything about WHAT gets remembered and // under which key. The pointer work and the SVG surgery stay here (see the // "Hand layout" section below). -import { applicable, clearLayout, isEmpty, layoutKey, lensKeyOf, projectKeyOf, readLayout, setDelta, writeLayout } from "./layout-store.js"; +import { + applicable, + clearLayout, + debounce, + fetchServerLayout, + isEmpty, + layoutKey, + lensKeyOf, + mergeLayouts, + nodeTransform, + pathAnchors, + postServerLayout, + projectKeyOf, + readLayout, + setDelta, + straightEdge, + writeLayout, +} from "./layout-store.js"; initTheme(); initPanel(); mountThemePicker(document.getElementById("panel-theme")); @@ -1916,6 +1933,14 @@ function ensureZoomControls(host) { // the end of every render(), so the graph underneath stays chant's and a // delta for a node that left the estate is simply not applied. // +// Two tiers now (#228's second half): localStorage, and the project's own +// `.behold/layout.json` behind GET/POST /api/layout. On load the server's map +// merges UNDER the local one (`mergeLayouts` — the drag you can see always +// wins); on a finished gesture the current lens is POSTed, debounced. A server +// that refuses — a static export, preview mode, a read-only project, an older +// behold with no such route — leaves the localStorage tier working exactly as +// it did before, and says nothing. +// // What this does NOT do, said plainly rather than faked: // * a resized box does not reflow its children — that is dagre's job on the // next layout, and the reset control's tooltip says so; @@ -1923,25 +1948,26 @@ function ensureZoomControls(host) { // its original anchor points, each shifted by its own node's delta. #228 // accepts the straight-line fallback; spline re-routing is pinhole's job. // An edge with both ends where dagre put them keeps its bezier untouched. -// * nothing is written to the server. The second tier of #228 (a -// `.behold/layout.json` sidecar behind POST /api/layout, so a server-side -// export honours the same deltas) is the follow-up half. +// * the server bakes {dx,dy} into an exported SVG but not a box's {dw,dh} — +// pinhole's containment boxes carry no id to bake against (src/layout.ts). let layoutIndex = null; // {nodes,boxes,edges} for the SVG currently on screen let layoutDeltas = {}; // the deltas in force for the current key let layoutDrag = null; // the gesture in flight let layoutWired = false; +// The sidecar tier, cached per lens: one GET when a lens is first shown, not +// one per render (render() runs on every SSE nudge and every settle poll). +let layoutServer = { lens: null, deltas: {}, writable: false }; /** The storage key for the project + lens on screen; null before /api/project lands. */ function currentLayoutKey() { if (!projectInfo) return null; - return layoutKey(projectKeyOf(projectInfo), lensKeyOf({ zoom: zoomValue(), radial: view.radial, stack: view.stack })); + return layoutKey(projectKeyOf(projectInfo), currentLensKey()); } -/** First and last coordinate pair of a path `d` — pinhole's own edge anchors. */ -function pathAnchors(d) { - const n = String(d || "").match(/-?\d*\.?\d+(?:e[-+]?\d+)?/gi); - if (!n || n.length < 4) return null; - return { sx: +n[0], sy: +n[1], ex: +n[n.length - 2], ey: +n[n.length - 1] }; +/** The lens half of the key — also what /api/layout is keyed by (the project + * half is implicit there: the sidecar lives inside the project). */ +function currentLensKey() { + return lensKeyOf({ zoom: zoomValue(), radial: view.radial, stack: view.stack }); } /** @@ -2036,8 +2062,7 @@ function indexLayout(svgEl) { function renderLayout() { if (!layoutIndex) return; for (const [id, n] of layoutIndex.nodes) { - const d = layoutDeltas[id]; - const t = d && (d.dx || d.dy) ? `translate(${d.dx || 0}, ${d.dy || 0}) ${n.base}`.trim() : n.base; + const t = nodeTransform(n.base, layoutDeltas[id] || {}); if (t) n.el.setAttribute("transform", t); else n.el.removeAttribute("transform"); } @@ -2058,8 +2083,7 @@ function renderLayout() { if (e.paths[0].getAttribute("d") !== e.d0) e.paths.forEach((p) => p.setAttribute("d", e.d0)); continue; } - const { sx, sy, ex, ey } = e.anchors; - const d = `M ${sx + ((a && a.dx) || 0)} ${sy + ((a && a.dy) || 0)} L ${ex + ((z && z.dx) || 0)} ${ey + ((z && z.dy) || 0)}`; + const d = straightEdge(e.anchors, a, z); e.paths.forEach((p) => p.setAttribute("d", d)); } } @@ -2070,16 +2094,55 @@ function applyLayout() { const host = document.getElementById("graph"); if (!svgEl || !host) return; indexLayout(svgEl); + reloadLayoutDeltas(); + ensureLayoutReset(host); + ensureLayoutDrag(host); + const lens = currentLensKey(); + if (currentLayoutKey() && layoutServer.lens !== lens) pullServerLayout(lens); +} + +/** Recompute both tiers onto the SVG already indexed, and repaint. Separate + * from applyLayout() because it must NOT re-index: renderLayout paints from + * each node's ORIGINAL transform, so re-indexing an already-painted SVG would + * take the displaced transform as the new base and apply the delta twice. */ +function reloadLayoutDeltas() { + if (!layoutIndex) return; const key = currentLayoutKey(); + const lens = currentLensKey(); // Stale ids are dropped on apply, not on write: a lens the user hasn't // opened in a while shouldn't have its deltas quietly deleted because this // render happened to be a different projection of the same estate. - layoutDeltas = key ? applicable(readLayout(localStorage, key), [...layoutIndex.nodes.keys(), ...layoutIndex.boxes.keys()]) : {}; + const merged = key ? mergeLayouts(readLayout(localStorage, key), layoutServer.lens === lens ? layoutServer.deltas : {}) : {}; + layoutDeltas = applicable(merged, [...layoutIndex.nodes.keys(), ...layoutIndex.boxes.keys()]); renderLayout(); - ensureLayoutReset(host); - ensureLayoutDrag(host); } +/** One GET per lens. Whatever comes back is merged UNDER the local tier and + * repainted; a refusal is cached as an empty, unwritable answer, so a serve + * with no sidecar (or no server at all) costs exactly one request per lens. */ +async function pullServerLayout(lens) { + layoutServer = { lens, deltas: {}, writable: false }; // claim it first — no request storm + const got = await fetchServerLayout(apiFetch, lens); + if (layoutServer.lens !== lens) return; // the view moved on while we waited + layoutServer = { lens, ...got }; + if (!Object.keys(got.deltas).length || currentLensKey() !== lens) return; + reloadLayoutDeltas(); + ensureLayoutReset(document.getElementById("graph")); +} + +/** Push the current lens to the sidecar, once the hand has stopped moving. + * Nothing here is load-bearing: `writable` false (static export, preview mode, + * read-only project, older behold) simply never pushes, and a failed push is + * not reported — the localStorage tier already has it. */ +const pushServerLayout = debounce((lens, deltas) => { + if (staticMode || !layoutServer.writable || layoutServer.lens !== lens) return; + postServerLayout((url, init) => fetch(url, init), lens, deltas); +}, 600); +// A reload (or a tab closing) within the debounce window would otherwise drop +// the last placement on the floor — localStorage has it, the sidecar wouldn't. +// The push rides `keepalive`, so it survives the document. +window.addEventListener("pagehide", () => pushServerLayout.flush()); + /** The grabbable thing at or above `el`, if any. */ function layoutTargetIn(el) { if (!el || typeof el.closest !== "function") return null; @@ -2161,6 +2224,10 @@ function ensureLayoutDrag(host) { if (!moved) return; const key = currentLayoutKey(); if (key) writeLayout(localStorage, key, layoutDeltas); + // The sidecar gets the WHOLE lens map, not the one id that moved: it is a + // per-lens document, and the local tier is the authority the user is + // looking at. Debounced, so a drag is one write and not sixty. + if (key) pushServerLayout(currentLensKey(), layoutDeltas); ensureLayoutReset(document.getElementById("graph")); }); } @@ -2180,6 +2247,11 @@ function ensureLayoutReset(host) { e.stopPropagation(); const key = currentLayoutKey(); if (key) clearLayout(localStorage, key); + // Both tiers, or it isn't a reset: clearing only localStorage would let + // the next merge pull the sidecar's deltas straight back in. + const lens = currentLensKey(); + layoutServer = { lens, deltas: {}, writable: layoutServer.lens === lens && layoutServer.writable }; + pushServerLayout(lens, {}); layoutDeltas = {}; renderLayout(); ensureLayoutReset(host); diff --git a/web/layout-store.js b/web/layout-store.js index 00cf70b..c87758f 100644 --- a/web/layout-store.js +++ b/web/layout-store.js @@ -9,10 +9,13 @@ // dropped without a word (`applicable`). // // No DOM in here on purpose — this is the testable half of #228 (see -// web/layout-store.test.js). app.js owns the pointer work and the SVG. The -// second tier of the issue (a `.behold/layout.json` sidecar behind -// `POST /api/layout`, so exports and snapshots honour the same deltas) is the -// follow-up; this is the shape it will serialize. +// web/layout-store.test.js). app.js owns the pointer work and the SVG. +// +// Two tiers, and the second one lands here too: `localStorage` (free, per +// browser) and the `.behold/layout.json` sidecar behind `GET/POST /api/layout` +// (shareable, versionable, and what a server-side export bakes in — see +// src/layout.ts). `mergeLayouts` decides who wins when both have an opinion, +// and every server call degrades to localStorage-only without a word. const PREFIX = "behold.layout"; const NUM = ["dx", "dy", "dw", "dh"]; @@ -116,3 +119,115 @@ export function applicable(deltas, liveIds) { for (const [id, d] of Object.entries(normalize(deltas))) if (live.has(id)) out[id] = d; return out; } + +// --- The delta → SVG math --------------------------------------------------- +// MUST stay identical to the copies in src/layout.ts, which is what bakes a +// layout into a server-rendered export. web/layout-store.test.js imports both +// modules and asserts they agree across a table of cases, so a drift fails a +// test instead of quietly making an export disagree with the screen it came +// from. (Same discipline as `canonicalKey`, mirrored in app.js and export.ts.) + +/** A node group's transform with its delta ridden on top of dagre's own. */ +export function nodeTransform(base, d) { + const dx = (d && d.dx) || 0; + const dy = (d && d.dy) || 0; + if (!dx && !dy) return base; + return `translate(${dx}, ${dy}) ${base}`.trim(); +} + +/** First and last coordinate pair of a path `d` — pinhole's own edge anchors. */ +export function pathAnchors(d) { + const n = String(d || "").match(/-?\d*\.?\d+(?:e[-+]?\d+)?/gi); + if (!n || n.length < 4) return null; + return { sx: +n[0], sy: +n[1], ex: +n[n.length - 2], ey: +n[n.length - 1] }; +} + +/** An edge whose ends moved: a straight line between the original anchors, + * each shifted by ITS OWN end's delta. #228 accepts the straight-line fallback + * explicitly; spline re-routing stays pinhole's job. */ +export function straightEdge(anchors, from, to) { + const { sx, sy, ex, ey } = anchors; + return `M ${sx + ((from && from.dx) || 0)} ${sy + ((from && from.dy) || 0)} L ${ex + ((to && to.dx) || 0)} ${ey + ((to && to.dy) || 0)}`; +} + +// --- The server tier -------------------------------------------------------- + +/** + * The two tiers, merged for display. **Local wins where both have an id.** + * + * You are looking at this browser's picture: the drag you just did is in + * localStorage and must not be argued with by a sidecar someone else committed + * (or that you yourself pushed from another machine). 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 you can see on your screen. + * + * The stale-id rule is unchanged and applies after: `applicable` drops whatever + * is no longer in the graph, whichever tier it came from. + */ +export function mergeLayouts(local, server) { + return { ...normalize(server), ...normalize(local) }; +} + +/** The lens's deltas as the server has them (`{}` on any refusal), plus whether + * it would accept a write. No server, a static export, preview mode, a + * read-only project, a 404 from an older behold — all the same answer: this is + * a localStorage-only session, and nothing says so out loud. */ +export async function fetchServerLayout(fetchFn, lens) { + try { + const res = await fetchFn(`/api/layout?lens=${encodeURIComponent(lens)}`); + if (!res || !res.ok) return { deltas: {}, writable: false }; + const body = await res.json(); + return { deltas: normalize(body && body.deltas), writable: !!(body && body.writable) }; + } catch { + return { deltas: {}, writable: false }; + } +} + +/** Push one lens's map to the sidecar. Resolves true iff it was stored. + * `keepalive` so a push flushed on the way out of the page (see `debounce`'s + * `flush`) still completes after the document is gone. */ +export async function postServerLayout(fetchFn, lens, deltas) { + try { + const res = await fetchFn("/api/layout", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ lens, deltas: normalize(deltas) }), + keepalive: true, + }); + return !!(res && res.ok); + } catch { + return false; + } +} + +/** A trailing-edge debouncer for the POST above: a drag emits a pointer-move + * storm and ends on the one write that matters. `flush()` runs a pending call + * now — what a page leaving mid-debounce needs, or the sidecar would miss the + * last placement the localStorage tier already has. Exported so the test can + * drive it with a fake clock rather than sleeping. */ +// The default timers are wrappers, not bare references: a detached +// `setTimeout` is called with no `this` and Chrome answers that with an +// "Illegal invocation" TypeError. +export function debounce(fn, ms, setTimer = (cb, t) => setTimeout(cb, t), clearTimer = (id) => clearTimeout(id)) { + let t = null; + let last = null; + const run = (...args) => { + if (t !== null) clearTimer(t); + last = args; + t = setTimer(() => { + t = null; + last = null; + fn(...args); + }, ms); + }; + run.flush = () => { + if (t === null) return; + clearTimer(t); + t = null; + const args = last || []; + last = null; + fn(...args); + }; + return run; +} diff --git a/web/layout-store.test.js b/web/layout-store.test.js index 3658587..0343337 100644 --- a/web/layout-store.test.js +++ b/web/layout-store.test.js @@ -1,20 +1,32 @@ // #228: the hand-layout delta store, checked without a browser. The pointer // work and the SVG live in app.js and are covered by smoke/ui-smoke.mjs; every // rule that decides WHAT gets stored and under which key lives here. -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { applicable, clearLayout, + debounce, + fetchServerLayout, isEmpty, layoutKey, lensKeyOf, + mergeLayouts, + nodeTransform, normalize, + pathAnchors, + postServerLayout, projectKeyOf, readLayout, setDelta, slug, + straightEdge, writeLayout, } from "./layout-store.js"; +// The server half of #228 — imported here on purpose: this is the one file +// that can hold both copies of the delta→SVG math at once (tsconfig excludes +// web/, so a src/*.test.ts couldn't import the browser module), and the +// parity block at the bottom is what keeps them from drifting. +import * as server from "../src/layout.ts"; /** A localStorage stand-in; `fail` makes every operation throw (private mode). */ function fakeStorage(seed = {}, fail = false) { @@ -161,3 +173,165 @@ describe("storage", () => { expect(readLayout(s, layoutKey("/e", "logical"))).toEqual({ a: { dy: 2 } }); }); }); + +// --- The server tier (#228, second half) ------------------------------------ + +describe("mergeLayouts", () => { + it("local wins where both tiers have the same id", () => { + expect(mergeLayouts({ api: { dx: 10 } }, { api: { dx: -99 } })).toEqual({ api: { dx: 10 } }); + }); + + it("an id only the sidecar has still comes through — that's the point of sharing", () => { + expect(mergeLayouts({ api: { dx: 10 } }, { worker: { dy: 4 } })).toEqual({ api: { dx: 10 }, worker: { dy: 4 } }); + }); + + it("is the whole server layout when nothing is local (a fresh browser)", () => { + expect(mergeLayouts({}, { api: { dx: 1 } })).toEqual({ api: { dx: 1 } }); + }); + + it("normalizes both sides, so junk from either can't reach the SVG", () => { + expect(mergeLayouts({ a: { dx: 0 } }, { b: "nope", c: { dy: 3 } })).toEqual({ c: { dy: 3 } }); + }); +}); + +describe("fetchServerLayout", () => { + const res = (body, ok = true) => ({ ok, json: async () => body }); + + it("asks for one lens and normalizes what comes back", async () => { + const fetchFn = vi.fn(async () => res({ lens: "components", deltas: { api: { dx: "5" }, junk: { dx: 0 } }, writable: true })); + expect(await fetchServerLayout(fetchFn, "components")).toEqual({ deltas: { api: { dx: 5 } }, writable: true }); + expect(fetchFn).toHaveBeenCalledWith("/api/layout?lens=components"); + }); + + it("encodes the lens key (a stack name can be anything)", async () => { + const fetchFn = vi.fn(async () => res({ deltas: {}, writable: false })); + await fetchServerLayout(fetchFn, "resources+stack-edge"); + expect(fetchFn).toHaveBeenCalledWith("/api/layout?lens=resources%2Bstack-edge"); + }); + + it("a refusal is an empty, unwritable answer — never a throw", async () => { + expect(await fetchServerLayout(async () => res({ error: "read-only" }, false), "components")).toEqual({ deltas: {}, writable: false }); + expect(await fetchServerLayout(async () => { + throw new Error("offline"); + }, "components")).toEqual({ deltas: {}, writable: false }); + }); +}); + +describe("postServerLayout", () => { + it("posts the normalized lens map as JSON", async () => { + const fetchFn = vi.fn(async () => ({ ok: true })); + expect(await postServerLayout(fetchFn, "components", { api: { dx: 3, dy: 0 } })).toBe(true); + const [url, init] = fetchFn.mock.calls[0]; + expect(url).toBe("/api/layout"); + expect(init.method).toBe("POST"); + expect(init.headers["content-type"]).toBe("application/json"); + expect(init.keepalive).toBe(true); // survives a flush on the way out of the page + expect(JSON.parse(init.body)).toEqual({ lens: "components", deltas: { api: { dx: 3 } } }); + }); + + it("a rejection is false, not an exception (offline is fine — localStorage has it)", async () => { + expect(await postServerLayout(async () => ({ ok: false }), "components", {})).toBe(false); + expect( + await postServerLayout(async () => { + throw new Error("offline"); + }, "components", {}), + ).toBe(false); + }); +}); + +describe("debounce", () => { + const fakeTimers = () => { + const timers = []; + return { timers, set: (cb) => timers.push(cb) - 1, clear: (i) => (timers[i] = null), run: () => timers.forEach((cb) => cb && cb()) }; + }; + + it("fires once, with the last arguments — a drag is one write, not sixty", () => { + const t = fakeTimers(); + const fn = vi.fn(); + const d = debounce(fn, 100, t.set, t.clear); + d("a"); + d("b"); + d("c"); + t.run(); + expect(fn.mock.calls).toEqual([["c"]]); + }); + + it("flush runs a pending call now — what a page leaving mid-debounce needs", () => { + const t = fakeTimers(); + const fn = vi.fn(); + const d = debounce(fn, 100, t.set, t.clear); + d("a"); + d.flush(); + expect(fn.mock.calls).toEqual([["a"]]); + t.run(); // the cancelled timer must not fire it a second time + expect(fn).toHaveBeenCalledTimes(1); + }); + + it("flush with nothing pending does nothing", () => { + const t = fakeTimers(); + const fn = vi.fn(); + debounce(fn, 100, t.set, t.clear).flush(); + expect(fn).not.toHaveBeenCalled(); + }); +}); + +// --- Parity with src/layout.ts ---------------------------------------------- +// The client paints the deltas in the browser; the server bakes the same ones +// into an exported SVG. If these two ever disagree, an export stops matching +// the screen it was taken from — silently. So they are checked against each +// other here, on the same table. +describe("the delta→SVG math matches the server's copy", () => { + const CASES = [ + ["translate(40, 80)", { dx: 12, dy: -4 }], + ["translate(40, 80) scale(0.9)", { dx: 0.5, dy: 0 }], + ["", { dx: 3, dy: 4 }], + ["translate(1, 2)", { dx: 0, dy: 0 }], + ["translate(1, 2)", { dw: 20, dh: 10 }], + ["translate(1, 2)", {}], + ]; + it("nodeTransform", () => { + for (const [base, d] of CASES) expect(nodeTransform(base, d)).toBe(server.nodeTransform(base, d)); + expect(nodeTransform("translate(40, 80)", { dx: 12, dy: -4 })).toBe("translate(12, -4) translate(40, 80)"); + expect(nodeTransform("translate(1, 2)", { dw: 5 })).toBe("translate(1, 2)"); + }); + + it("pathAnchors", () => { + const DS = ["M 115 112 C 115 112, 305 112, 305 112", "M1 2L3 4", "M 1e2 -3.5 L 7 8", "M 1 2", "", null]; + for (const d of DS) expect(pathAnchors(d)).toEqual(server.pathAnchors(d)); + expect(pathAnchors("M 115 112 C 115 112, 305 112, 305 112")).toEqual({ sx: 115, sy: 112, ex: 305, ey: 112 }); + }); + + it("straightEdge", () => { + const a = { sx: 115, sy: 112, ex: 305, ey: 112 }; + for (const [from, to] of [ + [{ dx: 10, dy: 5 }, undefined], + [undefined, { dx: -2.5, dy: 0 }], + [{ dx: 1 }, { dy: 2 }], + [undefined, undefined], + ]) { + expect(straightEdge(a, from, to)).toBe(server.straightEdge(a, from, to)); + } + expect(straightEdge(a, { dx: 10, dy: 5 }, undefined)).toBe("M 125 117 L 305 112"); + }); + + it("slug and normalize agree, so a key written by one is read by the other", () => { + for (const s of ["/estates/stub-estate", "Resources+Radial", "///", "", "stack-edge"]) expect(slug(s)).toBe(server.slug(s)); + for (const raw of [{ a: { dx: 1, dy: 0 } }, { a: { dx: "12.5" } }, { a: 5 }, null, "nope", { a: { dx: NaN } }]) { + expect(normalize(raw)).toEqual(server.normalizeDeltas(raw)); + } + }); + + it("the lens key the client stores under is the one the server derives from a request", () => { + const q = (s) => new URLSearchParams(s); + expect(server.lensFromQuery(q("components=1"))).toBe(lensKeyOf({ zoom: "components" })); + expect(server.lensFromQuery(q("logical=1"))).toBe(lensKeyOf({ zoom: "logical" })); + expect(server.lensFromQuery(q("env=prod&runtime=1&detail=3"))).toBe(lensKeyOf({ zoom: "runtime" })); + expect(server.lensFromQuery(q("detail=1"))).toBe(lensKeyOf({ zoom: "composites" })); + expect(server.lensFromQuery(q("detail=3"))).toBe(lensKeyOf({ zoom: "attributes" })); + expect(server.lensFromQuery(q(""))).toBe(lensKeyOf({ zoom: "resources" })); + expect(server.lensFromQuery(q("detail=2&radial=1"))).toBe(lensKeyOf({ zoom: "resources", radial: true })); + expect(server.lensFromQuery(q("detail=2&stack=edge"))).toBe(lensKeyOf({ zoom: "resources", stack: "edge" })); + // The env is in neither — an overlay recolours the same nodes (#228). + expect(server.lensFromQuery(q("components=1&env=prod&tier=dev"))).toBe(server.lensFromQuery(q("components=1"))); + }); +});