From 446de4af40a1899cbeb9a775c70114a83e02f400 Mon Sep 17 00:00:00 2001 From: lex00 <121451605+lex00@users.noreply.github.com> Date: Mon, 10 Aug 2026 10:11:02 -0600 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20the=20carve=20morph=20=E2=80=94=20t?= =?UTF-8?q?he=20card=20glides=20out=20of=20the=20Terraform=20box=20into=20?= =?UTF-8?q?the=20chant=20box=20(#230=20M2b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pinhole 0.3.4's MorphView.groups carries the estate frame's boxes into the morph artifact. renderCarveMorph composes the frame twice — Terraform owns it / chant owns it — with the carved card keeping its TF address in both views, so the FLIP moves the card while the band panels and member boxes resize around it. Served at GET /carve/morph?select=; the Done step links it and the SLIDE half of its deferred-work confession retires. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UXwApfRA4Pvoou5Ajj8qHV --- package-lock.json | 8 +-- package.json | 2 +- src/render.test.ts | 39 ++++++++++++++- src/render.ts | 120 ++++++++++++++++++++++++++++++++++++++------- src/server.ts | 45 ++++++++++++++++- web/carve-steps.js | 13 +++-- 6 files changed, 198 insertions(+), 29 deletions(-) diff --git a/package-lock.json b/package-lock.json index 549dbbe..da0ec39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "@hono/node-server": "^2.0.5", "@intentius/chant": "^0.38.0", "@intentius/chant-k8s-client": "^0.44.5", - "@intentius/pinhole": "^0.3.3", + "@intentius/pinhole": "^0.3.4", "hono": "^4.12.34" }, "bin": { @@ -988,9 +988,9 @@ } }, "node_modules/@intentius/pinhole": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@intentius/pinhole/-/pinhole-0.3.3.tgz", - "integrity": "sha512-OP8mSUg1eLX8/+ZcB3hYIJfdEffDBwYyT0GZ5h15w3II3gFkjsWq0gKcg+o9F9hlJ1eR7xQoIV8ay/s5NTFwxg==", + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@intentius/pinhole/-/pinhole-0.3.4.tgz", + "integrity": "sha512-+n/r6W5/J9VdH/Z4j2RKKgp1eDcAPB2DMBvGK+SClSlOuHTY+fNTwj6IXLuWjomMq6ZyHlSaaKxkzkWOKOG62A==", "dependencies": { "@dagrejs/dagre": "^3.0.0", "@intentius/chant": "^0.44.3" diff --git a/package.json b/package.json index 3c05651..32978a4 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "@hono/node-server": "^2.0.5", "@intentius/chant": "^0.38.0", "@intentius/chant-k8s-client": "^0.44.5", - "@intentius/pinhole": "^0.3.3", + "@intentius/pinhole": "^0.3.4", "hono": "^4.12.34" }, "devDependencies": { diff --git a/src/render.test.ts b/src/render.test.ts index b92ce8c..71a2274 100644 --- a/src/render.test.ts +++ b/src/render.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { renderGraph, renderBanded, renderCarveEstate } from "./render.ts"; +import { renderGraph, renderBanded, renderCarveEstate, renderCarveMorph } from "./render.ts"; import type { GraphIR } from "@intentius/chant"; // M4: renderGraph gained an explicit `boxes: "byStack"` opt-in for the @@ -252,4 +252,41 @@ describe("renderCarveEstate — the carve estate frame (#254)", () => { expect(h).toBeGreaterThan(0); expect(w / h).toBeLessThan(4); }); + + // #230 M2b: the morph — two estate frames, the carved card gliding between + // them. Identity continuity is the whole trick: same id in both views. + describe("renderCarveMorph", () => { + const parseViews = (html: string) => JSON.parse(html.match(/const VIEWS = (\[[\s\S]*?\]);\n/)![1].replace(/\\u003c/g, "<")); + const morph = () => + renderCarveMorph(bandedIr, appIr, ["aws_s3_bucket.b0"], { tfTitle: "legacy-tf — terraform", appTitle: "app — chant" }); + + it("keeps the carved card's id in both views — one badge, two positions", () => { + const html = morph(); + const VIEWS = parseViews(html); + expect(VIEWS).toHaveLength(2); + expect(Object.keys(VIEWS[0].pos)).toContain("aws_s3_bucket.b0"); + expect(Object.keys(VIEWS[1].pos)).toContain("aws_s3_bucket.b0"); + expect((html.match(/data-node-id="aws_s3_bucket\.b0"/g) || []).length).toBe(1); + }); + + it("moves the carved card into the chant member box in the after view", () => { + const VIEWS = parseViews(morph()); + const appBox = VIEWS[1].boxes.find((b: { key: string }) => b.key === "app — chant"); + const before = VIEWS[0].pos["aws_s3_bucket.b0"]; + const after = VIEWS[1].pos["aws_s3_bucket.b0"]; + expect(appBox).toBeDefined(); + expect(after.x).toBeGreaterThan(appBox.x); + expect(after.x).toBeGreaterThan(before.x); + }); + + it("carries both member boxes and the band panels in both views", () => { + const VIEWS = parseViews(morph()); + for (const view of VIEWS) { + const keys = view.boxes.map((b: { key: string }) => b.key); + expect(keys).toContain("legacy-tf — terraform"); + expect(keys).toContain("app — chant"); + expect(keys).toContain("carve now"); + } + }); + }); }); diff --git a/src/render.ts b/src/render.ts index e76d6c6..c214200 100644 --- a/src/render.ts +++ b/src/render.ts @@ -6,7 +6,17 @@ * src/overlay.ts. behold owns the live data + the server + (later) the * temporal/action layers around this. */ -import { layoutIr, layoutArchitecture, renderSvg, cardSizes, registerPack, type GroupBox, type Status } from "@intentius/pinhole"; +import { + layoutIr, + layoutArchitecture, + renderSvg, + renderMorphHtml, + cardSizes, + registerPack, + type GroupBox, + type MorphView, + type Status, +} from "@intentius/pinhole"; import type { GraphIR, IRGroups, Layout } from "@intentius/chant"; import type { ByContainer } from "./logical.ts"; import { k8sIconFor, helmIconFor } from "./icon-packs.ts"; @@ -243,16 +253,45 @@ export function renderCarveEstate( appIr: GraphIR, opts: { tfTitle: string; appTitle: string; theme?: string }, ): RenderResult & { ir: GraphIR } { - const prefix = `${opts.appTitle.split(" ")[0]}/`; + const comp = composeCarveEstate(tfIr, namespaceAppIr(appIr, opts.appTitle), opts); + const svg = renderSvg(comp.ir, comp.layout, { + fit: true, + hideTitle: true, + groups: comp.boxes, + ...(opts.theme ? { theme: opts.theme as never } : {}), + }); + return { svg, ir: comp.ir }; +} + +/** App-side node ids get the member prefix (`app/`, composeStacks' + * convention) so they can never collide with a Terraform address. The carved + * nodes the morph moves INTO the app box deliberately skip this — they keep + * their TF address, which is what makes the card glide instead of swap. */ +function namespaceAppIr(appIr: GraphIR, appTitle: string): GraphIR { + const prefix = `${appTitle.split(" ")[0]}/`; const appId = (id: string) => `${prefix}${id}`; - const appNodes = appIr.nodes.map((n) => ({ ...n, id: appId(n.id) })); - const appEdges = appIr.edges.map((e) => ({ ...e, from: appId(e.from), to: appId(e.to) })); - const namespacedApp: GraphIR = { ...appIr, nodes: appNodes, edges: appEdges, groups: {} }; + return { + ...appIr, + nodes: appIr.nodes.map((n) => ({ ...n, id: appId(n.id) })), + edges: appIr.edges.map((e) => ({ ...e, from: appId(e.from), to: appId(e.to) })), + groups: {}, + }; +} + +/** One composed estate frame, render-ready: layout and boxes in renderSvg's + * y-up plane — the same plane pinhole's `MorphView` reads, so a composition + * serves both the static SVG and one morph view. */ +interface EstateComposition { + ir: GraphIR; + layout: Layout; + boxes: GroupBox[]; +} +function composeCarveEstate(tfIr: GraphIR, appNs: GraphIR, opts: { tfTitle: string; appTitle: string }): EstateComposition { const tfPlan = bandedPlan(tfIr, (tfIr.groups.byStack ?? {}) as Record); // The app side reuses the banded grid as a single panel: same cell metrics // discipline, and the panel title says what these cards have in common. - const appPlan = bandedPlan(namespacedApp, { "carved so far": appNodes.map((n) => n.id) }); + const appPlan = bandedPlan(appNs, { "carved so far": appNs.nodes.map((n) => n.id) }); // Two member boxes, top-aligned, TF on the left where the ranking's weight // is. Content sits inside each member at (PAD, TITLE + PAD). @@ -278,27 +317,70 @@ export function renderCarveEstate( ]; const ir: GraphIR = { - nodes: [...tfIr.nodes, ...appNodes], - edges: [...tfIr.edges, ...appEdges], + nodes: [...tfIr.nodes, ...appNs.nodes], + edges: [...tfIr.edges, ...appNs.edges], groups: { byStack: { ...((tfIr.groups.byStack ?? {}) as Record), - [opts.appTitle]: appNodes.map((n) => n.id), + [opts.appTitle]: appNs.nodes.map((n) => n.id), }, }, }; - const layout: Layout = { - width, - height, - nodes: [...tf.placed, ...app.placed].map((p) => ({ id: p.id, x: p.x, y: height - p.y })), + return { + ir, + layout: { + width, + height, + nodes: [...tf.placed, ...app.placed].map((p) => ({ id: p.id, x: p.x, y: height - p.y })), + }, + boxes: boxes.map((b) => ({ ...b, y: height - b.y })), }; - const svg = renderSvg(ir, layout, { - fit: true, - hideTitle: true, - groups: boxes.map((b) => ({ ...b, y: height - b.y })), - ...(opts.theme ? { theme: opts.theme as never } : {}), +} + +/** The carve morph (#230 M2b): two estate frames — Terraform owns the carved + * resource / chant owns it — as one self-contained morph artifact. The carved + * card keeps its TF address in both views, so pinhole's FLIP glides it out of + * its band and into the chant box while the boxes resize around it; the band + * panels and both member boxes share titles across views, so they morph too. + * (Morph badges are pinhole's neutral fill by design (#107), so the after view + * marks ownership in the carved node's attrs — the inspector shows + * `carved → chant` — rather than in the badge colour.) */ +export function renderCarveMorph( + tfIr: GraphIR, + appIr: GraphIR, + carved: string[], + opts: { tfTitle: string; appTitle: string; title?: string }, +): string { + const appNs = namespaceAppIr(appIr, opts.appTitle); + const before = composeCarveEstate(tfIr, appNs, opts); + + const gone = new Set(carved); + const bands = (tfIr.groups.byStack ?? {}) as Record; + const bandsAfter: Record = {}; + for (const [band, members] of Object.entries(bands)) { + const left = members.filter((id) => !gone.has(id)); + if (left.length) bandsAfter[band] = left; + } + const tfAfter: GraphIR = { + nodes: tfIr.nodes.filter((n) => !gone.has(n.id)), + edges: tfIr.edges.filter((e) => !gone.has(e.from) && !gone.has(e.to)), + groups: { byStack: bandsAfter }, + }; + const carvedNodes = tfIr.nodes + .filter((n) => gone.has(n.id)) + .map((n) => ({ ...n, attrs: { ...n.attrs, _status: "good", carve: "carved → chant" } })); + const appAfter: GraphIR = { ...appNs, nodes: [...appNs.nodes, ...carvedNodes] }; + const after = composeCarveEstate(tfAfter, appAfter, opts); + + const view = (name: string, comp: EstateComposition): MorphView => ({ + name, + ir: comp.ir, + layout: comp.layout, + groups: comp.boxes, + }); + return renderMorphHtml([view("Terraform owns it", before), view("chant owns it", after)], { + title: opts.title ?? `carve morph — ${carved.join(", ")}`, }); - return { svg, ir }; } /** Re-place a laid-out graph's nodes on concentric rings by dagre rank (the diff --git a/src/server.ts b/src/server.ts index 9612748..70f929b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -55,7 +55,7 @@ import { addCompositeDepsCounted } from "./composite-deps.ts"; import { notesFor, tierMismatchNote, namespaceMismatchNote, namespaceJoinNote, type Zoom } from "./zoom-notes.ts"; import { resourcesByComponent, nonResourceEntities } from "./resources.ts"; import { summarizePlan } from "./reconcile.ts"; -import { renderGraph, renderArchitecture, renderBanded, renderCarveEstate } from "./render.ts"; +import { renderGraph, renderArchitecture, renderBanded, renderCarveEstate, renderCarveMorph } from "./render.ts"; import { readCarveReport, carveReportToIr, carveNote } from "./carve-lens.ts"; import { carveWriteBlock, @@ -483,6 +483,49 @@ function carveRoutes(app: Hono, reportPath: string, demo?: CarveDemo): void { }); }); + // The carve morph (#230 M2b): the estate frame twice — Terraform owns the + // carved resource / chant owns it — as pinhole's self-contained morph + // artifact, the card gliding out of its band into the chant box. A page, not + // an API: the Done step links here in a new tab, same posture as /lanes. + // Read-only (it renders a hypothetical from the report; nothing is written), + // so unlike emit/bridge it works for any ranked address, demo or not — but + // it does need the app graph, so a bare `behold carve report.json` gets the + // honest refusal instead of a picture missing its destination. + app.get("/carve/morph", async (c) => { + const parsed = load(); + if (!parsed.ok) return c.json(parsed.refusal, 422); + const select = c.req.query("select") ?? ""; + const ranked = (parsed.report.resources ?? []).some((r) => r.address === select); + if (!ranked) { + return c.json( + { + error: `"${select}" is not a ranked address in this report`, + code: "carve_bad_select", + remedy: "pass ?select=
naming a resource the report ranks — GET /api/carve lists them", + }, + 400, + ); + } + const appSide = await appGraphOnce(); + if (!appSide) { + return c.json( + { + error: "the morph needs the demo estate's chant project, and this server isn't serving a demo copy", + code: "carve_no_demo", + remedy: "run `behold demo carve` — the bundled estate has the chant box the card moves into", + }, + 404, + ); + } + const tfIr = carveReportToIr(parsed.report); + const html = renderCarveMorph(tfIr, appSide.ir, [select], { + tfTitle: `${relative(demo!.root, demo!.from).split(sep).join("/")} — terraform`, + appTitle: `${appSide.label} — chant`, + title: `carve — ${select}`, + }); + return c.html(html); + }); + app.get("/api/project", (c) => { const parsed = load(); return c.json({ diff --git a/web/carve-steps.js b/web/carve-steps.js index fd73130..c96095a 100644 --- a/web/carve-steps.js +++ b/web/carve-steps.js @@ -605,13 +605,20 @@ function renderDone(body, state, ctx, actions) { if (!state.handoff) { body.appendChild(el("p", "panel-muted", "The handoff commands haven't been marked as run — this is the shape of the ending, not a claim about your estate.")); } + if (state.pick) { + const watch = el("a", "act carve-morph-link", "▶ watch it move"); + watch.href = "/carve/morph?select=" + encodeURIComponent(state.pick.node.id); + watch.target = "_blank"; + watch.rel = "noopener"; + watch.title = "The estate frame, morphing: the card slides out of the Terraform box and into the chant project box beside last month's carves."; + body.appendChild(watch); + } body.appendChild( el( "p", "panel-muted carve-honesty", - "Deferred to the follow-up: the card doesn't yet SLIDE out of the Terraform box and into the chant project " + - "beside last month's carves — it is marked in place. The Floci `--live` tier (a real terraform apply, a real " + - "plan showing no destroy) is the other half of that follow-up.", + "Deferred to the follow-up: the Floci `--live` tier — a real terraform apply into a scratch emulator, " + + "a real plan showing no destroy.", ), ); const again = el("button", "act", "↺ start over"); From b6e46ec9ff75b3ef749c3aa8722af589893c095d Mon Sep 17 00:00:00 2001 From: lex00 <121451605+lex00@users.noreply.github.com> Date: Mon, 10 Aug 2026 10:14:25 -0600 Subject: [PATCH 2/2] =?UTF-8?q?test:=20the=20UI=20smoke=20follows=20the=20?= =?UTF-8?q?Done=20step=20=E2=80=94=20morph=20is=20a=20link=20now,=20only?= =?UTF-8?q?=20--live=20stays=20deferred?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UXwApfRA4Pvoou5Ajj8qHV --- smoke/ui-smoke.mjs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/smoke/ui-smoke.mjs b/smoke/ui-smoke.mjs index f8fcf9b..5ee4fb4 100644 --- a/smoke/ui-smoke.mjs +++ b/smoke/ui-smoke.mjs @@ -809,7 +809,13 @@ try { check("acknowledging the handoff lands on Done", (await bodyStep()) === "done"); const done = await carveText(); check("the end card is #254's line", done.includes("chant-owned, observe position") && done.includes("terraform import")); - check("the follow-up is named, not implied", done.includes("SLIDE") && done.includes("--live")); + check("the follow-up is named, not implied", done.includes("--live")); + const morphLink = carvePage.locator("#tab-carve a.carve-morph-link"); + check("the morph is a link, not a promise", (await morphLink.count()) === 1); + check( + "…and it names the picked resource", + ((await morphLink.getAttribute("href")) || "").includes("/carve/morph?select=aws_s3_bucket.assets"), + ); const marker = carvePage.locator('#graph [data-node-id="aws_s3_bucket.assets"] [data-carved="1"]'); check("the carved card carries a marker", (await marker.count()) === 1); check("…inside the card's own box, not at the group's origin", await marker.evaluate((t) => Number(t.getAttribute("x")) > 0 && Number(t.getAttribute("y")) > 0));