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 0000000..8ac5959 Binary files /dev/null and b/src/layout.test.ts differ 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"))); + }); +});