Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: {<lens>:
{<node id>: {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.
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down
6 changes: 6 additions & 0 deletions docs/src/content/docs/start/your-project.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<Aside type="caution" title="If every tier renders the same graph">
Your project's chant needs to resolve build parameters in `chant graph`, which landed in chant 0.38.0. A caret on a `0.x` pins the minor, so `^0.37.x` cannot reach it — bump to `^0.38.0`.
</Aside>
Expand Down
22 changes: 22 additions & 0 deletions smoke/stub.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)));
}
33 changes: 33 additions & 0 deletions smoke/ui-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}/`);
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand All @@ -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"]');
Expand Down
9 changes: 9 additions & 0 deletions src/export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down
13 changes: 11 additions & 2 deletions src/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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 ?? [] };
Expand All @@ -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);
Expand Down
Binary file added src/layout.test.ts
Binary file not shown.
Loading
Loading