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
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
8 changes: 7 additions & 1 deletion smoke/ui-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
39 changes: 38 additions & 1 deletion src/render.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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");
}
});
});
});
120 changes: 101 additions & 19 deletions src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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/<id>`, 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<string, string[]>);
// 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).
Expand All @@ -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<string, string[]>),
[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<string, string[]>;
const bandsAfter: Record<string, string[]> = {};
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
Expand Down
45 changes: 44 additions & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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=<address> 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({
Expand Down
13 changes: 10 additions & 3 deletions web/carve-steps.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading