@intentius/behold 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/AGENTS.md +85 -0
  2. package/README.md +123 -2
  3. package/demos.json +17 -1
  4. package/dist/cli.js +2522 -294
  5. package/example-argo-estate/README.md +43 -18
  6. package/example-argo-estate/app-a/chant.config.ts +10 -2
  7. package/example-argo-estate/app-a/package.json +2 -2
  8. package/example-argo-estate/app-b/chant.config.ts +11 -2
  9. package/example-argo-estate/app-b/package.json +2 -2
  10. package/example-argo-estate/control-plane/chant.config.ts +20 -5
  11. package/example-argo-estate/control-plane/package.json +2 -2
  12. package/example-argo-estate/package-lock.json +17 -17
  13. package/example-carve/README.md +194 -0
  14. package/example-carve/app/chant.config.ts +6 -0
  15. package/example-carve/app/package-lock.json +1075 -0
  16. package/example-carve/app/package.json +13 -0
  17. package/example-carve/app/src/carved.ts +30 -0
  18. package/example-carve/app/tsconfig.json +1 -0
  19. package/example-carve/carve-report.json +872 -0
  20. package/example-carve/legacy-tf/cdn.tf +23 -0
  21. package/example-carve/legacy-tf/compute.tf +63 -0
  22. package/example-carve/legacy-tf/floci-override.tf.disabled +61 -0
  23. package/example-carve/legacy-tf/modules/cdn/main.tf +72 -0
  24. package/example-carve/legacy-tf/naming.tf +10 -0
  25. package/example-carve/legacy-tf/network.tf +119 -0
  26. package/example-carve/legacy-tf/observability.tf +18 -0
  27. package/example-carve/legacy-tf/outputs.tf +16 -0
  28. package/example-carve/legacy-tf/storage.tf +33 -0
  29. package/example-carve/legacy-tf/terraform.tfstate +602 -0
  30. package/example-carve/legacy-tf/versions.tf +40 -0
  31. package/example-flux-estate/README.md +9 -4
  32. package/example-flux-estate/app-a/package.json +2 -2
  33. package/example-flux-estate/app-a/src/app.ts +2 -1
  34. package/example-flux-estate/app-b/chant.config.ts +4 -3
  35. package/example-flux-estate/app-b/package.json +2 -2
  36. package/example-flux-estate/app-b/src/app.ts +5 -3
  37. package/example-flux-estate/control-plane/package.json +2 -2
  38. package/example-flux-estate/control-plane/src/flux.ts +4 -2
  39. package/example-flux-estate/package-lock.json +17 -17
  40. package/example-k8s/package-lock.json +18 -18
  41. package/example-k8s/package.json +3 -3
  42. package/example-writes/package-lock.json +14 -14
  43. package/example-writes/package.json +3 -3
  44. package/package.json +8 -6
  45. package/web/app.js +714 -57
  46. package/web/carve-steps.js +610 -0
  47. package/web/carve-steps.test.js +233 -0
  48. package/web/demos.js +71 -0
  49. package/web/demos.test.js +83 -0
  50. package/web/index.html +93 -1
  51. package/web/json-view.js +334 -0
  52. package/web/json-view.test.js +218 -0
  53. package/web/layout-store.js +164 -4
  54. package/web/layout-store.test.js +226 -1
  55. package/web/panel.js +28 -0
  56. package/web/theme.js +57 -1
package/web/app.js CHANGED
@@ -9,11 +9,40 @@
9
9
  // position), and the theme picker into the panel's View-tab slot (a stable element
10
10
  // renderPanelView never rewrites, so the select mounts once and survives re-renders).
11
11
  import { initTheme, mountThemePicker, readableOn, colorForCategory, onThemeChange, getTokens } from "./theme.js";
12
- import { initPanel, setPanelTab, togglePanelCollapsed, isPanelCollapsed } from "./panel.js";
12
+ import { addPanelTab, initPanel, setPanelTab, togglePanelCollapsed, isPanelCollapsed } from "./panel.js";
13
+ // #254: the carve walkthrough's stepper — everything it DECIDES is a pure
14
+ // function in there; this file owns the fetches, the graph selection, and the
15
+ // "carved" marker the last step leaves on a card.
16
+ import { CARVE_STEPS, blockedReason, initialCarveState, renderCarvePanel } from "./carve-steps.js";
13
17
  // #228: the hand-layout delta store — everything about WHAT gets remembered and
14
18
  // under which key. The pointer work and the SVG surgery stay here (see the
15
19
  // "Hand layout" section below).
16
- import { applicable, clearLayout, isEmpty, layoutKey, lensKeyOf, projectKeyOf, readLayout, setDelta, writeLayout } from "./layout-store.js";
20
+ import {
21
+ applicable,
22
+ clampDelta,
23
+ clearLayout,
24
+ debounce,
25
+ fetchServerLayout,
26
+ isEmpty,
27
+ layoutKey,
28
+ lensKeyOf,
29
+ mergeLayouts,
30
+ nodeTransform,
31
+ pathAnchors,
32
+ postServerLayout,
33
+ projectKeyOf,
34
+ readLayout,
35
+ setDelta,
36
+ straightEdge,
37
+ writeLayout,
38
+ } from "./layout-store.js";
39
+ // #259: every JSON value this page shows goes through one renderer — pretty
40
+ // printed, collapsible, copyable per subtree. `valueCell`/`pairCell` below are
41
+ // the call-site shorthands: a container becomes the tree, a scalar stays text.
42
+ import { isContainer, jsonCell, renderJson, scalarText } from "./json-view.js";
43
+ // #268: the demo catalog's presentation — what a catalog row's button says
44
+ // about it (disabled + reason, or the fetch it would do).
45
+ import { fetchDemos, demoLabel, demoTitle, demoProgress } from "./demos.js";
17
46
  initTheme();
18
47
  initPanel();
19
48
  mountThemePicker(document.getElementById("panel-theme"));
@@ -163,12 +192,68 @@ const ARTIFACT_STATUS_LABEL = { good: "installed", warn: "installed, not healthy
163
192
  // A declared attribute value may be a cross-resource reference ({$ref:"x.y"}) —
164
193
  // the "static infra refs" — rather than a concrete value. Render those readably;
165
194
  // concrete values (present once a resource is provisioned) show as-is.
195
+ // #259: everything else that isn't a scalar becomes the collapsible tree rather
196
+ // than a flat one-line JSON.stringify — a declared `spec` used to wrap six times
197
+ // and say nothing about its shape.
166
198
  function fmtValue(v) {
167
- if (v && typeof v === "object") {
168
- if (typeof v.$ref === "string") return "→ " + v.$ref;
169
- return JSON.stringify(v);
199
+ if (v && typeof v === "object" && typeof v.$ref === "string") return "→ " + v.$ref;
200
+ return valueCell(v);
201
+ }
202
+
203
+ /**
204
+ * A `<dd>` body for one value: an object or an array becomes the collapsible
205
+ * tree, a scalar keeps the pane's plain voice — `deploy/api`, not
206
+ * `"deploy/api"`. Quoting is JSON's punctuation and belongs inside a tree, not
207
+ * beside a label, so this is NOT json-view's own `jsonCell` (which quotes; the
208
+ * value PAIRS below want that, since they always did).
209
+ */
210
+ function valueCell(v) {
211
+ return isContainer(v) ? renderJson(v) : String(v);
212
+ }
213
+
214
+ /** Put a value into a `<dd>`: a rendered JSON tree is appended, a scalar's text
215
+ * is set. Every `add`/`section` setter in this pane funnels through here (#259). */
216
+ function setCell(dd, v) {
217
+ if (v instanceof Node) dd.appendChild(v);
218
+ else dd.textContent = v;
219
+ }
220
+
221
+ /**
222
+ * The pane shows values in PAIRS as often as alone — old → new (drift),
223
+ * declared · live (field ownership), baseline · live (accepted deviation).
224
+ * Two scalars keep the one-line form the pane always had. If either side is an
225
+ * object or an array it becomes a collapsible tree, and an arrow wedged between
226
+ * two trees reads as neither — so the pair stacks into labelled rows instead
227
+ * (#259). `lead` is the sentence that precedes a field-drift pair ("owned by
228
+ * hpa-controller — drifted"); it keeps its own line in the stacked form.
229
+ * Returns a Node or a string, for setCell.
230
+ */
231
+ function pairCell(aLabel, a, bLabel, b, opts = {}) {
232
+ const { lead = "", arrow = false } = opts;
233
+ if (!isContainer(a) && !isContainer(b)) {
234
+ const one = arrow ? `${scalarText(a)} → ${scalarText(b)}` : `${aLabel}: ${scalarText(a)} · ${bLabel}: ${scalarText(b)}`;
235
+ return lead ? `${lead} — ${one}` : one;
170
236
  }
171
- return String(v);
237
+ const wrap = document.createElement("div");
238
+ if (lead) {
239
+ const p = document.createElement("div");
240
+ p.textContent = lead;
241
+ wrap.appendChild(p);
242
+ }
243
+ for (const [label, v] of [
244
+ [aLabel, a],
245
+ [bLabel, b],
246
+ ]) {
247
+ const row = document.createElement("div");
248
+ row.className = "pair-row";
249
+ const tag = document.createElement("span");
250
+ tag.className = "pair-label";
251
+ tag.textContent = label;
252
+ row.appendChild(tag);
253
+ setCell(row, jsonCell(v));
254
+ wrap.appendChild(row);
255
+ }
256
+ return wrap;
172
257
  }
173
258
 
174
259
  function inspect(node) {
@@ -185,7 +270,7 @@ function inspect(node) {
185
270
  const dt = document.createElement("dt");
186
271
  dt.textContent = k;
187
272
  const dd = document.createElement("dd");
188
- dd.textContent = v;
273
+ setCell(dd, v);
189
274
  dl.append(dt, dd);
190
275
  };
191
276
  };
@@ -433,7 +518,7 @@ function renderObserved(panel, o, health, healthDetail) {
433
518
  const dt = document.createElement("dt");
434
519
  dt.textContent = k;
435
520
  const dd = document.createElement("dd");
436
- dd.textContent = v;
521
+ setCell(dd, v);
437
522
  if (color) dd.style.color = color;
438
523
  dl.append(dt, dd);
439
524
  };
@@ -461,11 +546,16 @@ function renderObserved(panel, o, health, healthDetail) {
461
546
  // none.
462
547
  const conditions = o.attributes?.conditions;
463
548
  if (Array.isArray(conditions)) {
464
- for (const c of conditions) add("condition", String(c), "var(--degraded)");
549
+ // Usually chant sends these pre-rendered as sentences; a substrate that
550
+ // sends the raw condition object gets the tree instead of `[object Object]`.
551
+ for (const c of conditions) add("condition", valueCell(c), "var(--degraded)");
465
552
  }
553
+ // #259: an observed attribute is whatever the substrate reported — a k8s
554
+ // `spec`, a nested `loadBalancer`, an array of ports. All of it collapsible
555
+ // now, instead of one flat JSON.stringify line per key.
466
556
  for (const [k, v] of Object.entries(o.attributes || {})) {
467
557
  if (k === "conditions") continue; // rendered above, one line each
468
- add(k, typeof v === "object" ? JSON.stringify(v) : String(v));
558
+ add(k, valueCell(v));
469
559
  }
470
560
  panel.appendChild(dl);
471
561
  }
@@ -549,7 +639,9 @@ function renderDiff(panel, diff) {
549
639
  const dt = document.createElement("dt");
550
640
  dt.textContent = ch.path;
551
641
  const dd = document.createElement("dd");
552
- dd.textContent = `${JSON.stringify(ch.oldValue)} ${JSON.stringify(ch.newValue)}`;
642
+ // #259: a drifted `spec` used to be two flat JSON blobs either side of an
643
+ // arrow; now each side is its own collapsible tree with its own copy.
644
+ setCell(dd, pairCell("was", ch.oldValue, "now", ch.newValue, { arrow: true }));
553
645
  dl.append(dt, dd);
554
646
  }
555
647
  panel.appendChild(dl);
@@ -592,7 +684,7 @@ function renderFieldDrift(panel, fieldDrift) {
592
684
  // holding it is somebody editing around the pipeline. Both are `changed`.
593
685
  // Absent on every substrate but k8s, where the line reads as it always did.
594
686
  const owned = ch.owner ? `owned by ${ch.owner} — ` : "";
595
- dd.textContent = `${owned}${FIELD_KIND_LABEL[ch.kind] || ch.kind} — declared: ${JSON.stringify(ch.declared)} · live: ${JSON.stringify(ch.live)}`;
687
+ setCell(dd, pairCell("declared", ch.declared, "live", ch.live, { lead: `${owned}${FIELD_KIND_LABEL[ch.kind] || ch.kind}` }));
596
688
  dl.append(dt, dd);
597
689
  }
598
690
  for (const ch of fieldDrift.accepted) {
@@ -600,7 +692,7 @@ function renderFieldDrift(panel, fieldDrift) {
600
692
  dt.textContent = ch.path;
601
693
  dt.style.color = "var(--muted)";
602
694
  const dd = document.createElement("dd");
603
- dd.textContent = `accepted deviation — baseline: ${JSON.stringify(ch.baseline)} · live: ${JSON.stringify(ch.live)}`;
695
+ setCell(dd, pairCell("baseline", ch.baseline, "live", ch.live, { lead: "accepted deviation" }));
604
696
  dl.append(dt, dd);
605
697
  }
606
698
  panel.appendChild(dl);
@@ -618,6 +710,10 @@ function wire(ir) {
618
710
  host.querySelectorAll(".sel").forEach((n) => n.classList.remove("sel"));
619
711
  g.classList.add("sel");
620
712
  inspect(node);
713
+ // #254: in carve mode a click is also the walkthrough's Pick step — the
714
+ // inspect pane already shows the score arithmetic the lens spelled out,
715
+ // and the stepper picks up the same node.
716
+ carvePick(node);
621
717
  });
622
718
  }
623
719
  }
@@ -807,26 +903,54 @@ async function revealProject(dir) {
807
903
  // client-side list and cache is project-scoped, so a clean boot is the honest
808
904
  // way to re-seed all of it.
809
905
  async function switchProject(dir) {
810
- showLoading(`switching to ${pathBasename(dir)}…`);
906
+ return postSwitch("/api/project/open", { dir }, `switching to ${pathBasename(dir)}…`, "switch");
907
+ }
908
+
909
+ // #268: load a bundled demo and serve it. Same treatment as a project switch —
910
+ // it IS one, after a copy/clone + install the server runs on the click. The
911
+ // body carries the catalog NAME; the server never takes a path here.
912
+ async function openDemo(demo) {
913
+ if (!demo.satisfiable) return;
914
+ return postSwitch("/api/demos/open", { name: demo.name }, demoProgress(demo), "demo");
915
+ }
916
+
917
+ // The switch itself: a JSON POST behind the loading scrim, then a reload on
918
+ // success. Shared by the recents/path switch and the demo catalog above.
919
+ async function postSwitch(url, body, message, what) {
920
+ showLoading(message);
811
921
  try {
812
- const r = await fetch("/api/project/open", {
922
+ const r = await fetch(url, {
813
923
  method: "POST",
814
924
  headers: { "content-type": "application/json" },
815
- body: JSON.stringify({ dir }),
925
+ body: JSON.stringify(body),
816
926
  });
817
927
  const j = await r.json();
818
928
  if (!r.ok || j.error) {
819
929
  hideLoading();
820
- showToast("✗ switch: " + (j.error || r.statusText), false);
930
+ showToast(`✗ ${what}: ` + (j.error || r.statusText), false);
821
931
  return;
822
932
  }
823
933
  location.reload();
824
934
  } catch (e) {
825
935
  hideLoading();
826
- showToast("✗ switch: " + e.message, false);
936
+ showToast(`✗ ${what}: ` + e.message, false);
827
937
  }
828
938
  }
829
939
 
940
+ // #268: the bundled catalog, fetched once per page. `null` until it lands (the
941
+ // first Scope render kicks it off and re-renders when it does), then a list —
942
+ // empty on a server that doesn't serve the route, which renders no group.
943
+ let demoCatalog = null;
944
+ let demoCatalogPending = false;
945
+ function primeDemoCatalog() {
946
+ if (demoCatalogPending) return;
947
+ demoCatalogPending = true;
948
+ fetchDemos().then((demos) => {
949
+ demoCatalog = demos;
950
+ renderPanelScope();
951
+ });
952
+ }
953
+
830
954
  function renderPanelScope() {
831
955
  const host = document.getElementById("tab-scope");
832
956
  if (!host) return;
@@ -876,6 +1000,19 @@ function renderPanelScope() {
876
1000
  });
877
1001
  row.append(input, go);
878
1002
  host.appendChild(row);
1003
+ // #268: the bundled demo catalog, under recents — every demo `behold demo
1004
+ // --list` names, one click from wherever you are. An entry whose
1005
+ // prerequisites are missing renders disabled with the reason on it, and one
1006
+ // that would clone from the network says so before it runs.
1007
+ if (demoCatalog === null) primeDemoCatalog();
1008
+ else if (demoCatalog.length) {
1009
+ host.appendChild(panelHeading("demos"));
1010
+ for (const d of demoCatalog) {
1011
+ const b = panelOpt(demoLabel(d), false, () => openDemo(d), demoTitle(d));
1012
+ if (!d.satisfiable || d.switchable === false) b.disabled = true;
1013
+ host.appendChild(b);
1014
+ }
1015
+ }
879
1016
  }
880
1017
  host.appendChild(panelHeading("environment"));
881
1018
  host.appendChild(
@@ -979,6 +1116,7 @@ function selectNode(id) {
979
1116
  const g = host.querySelector(`[data-node-id="${CSS.escape(id)}"]`);
980
1117
  if (g) g.classList.add("sel");
981
1118
  inspect(node);
1119
+ carvePick(node);
982
1120
  }
983
1121
 
984
1122
  function renderPanelModel() {
@@ -1044,10 +1182,199 @@ function renderPanelModel() {
1044
1182
  }
1045
1183
  }
1046
1184
 
1185
+ // ---------------------------------------------------------------------------
1186
+ // The carve walkthrough (#254, M1.5 of #230)
1187
+ //
1188
+ // A Carve tab appears only when /api/project says this server is in carve mode,
1189
+ // and its two ACTION steps light up only when it also says a demo copy is
1190
+ // behind it (`carve.demo.runnable`) — a plain `behold carve report.json` gets
1191
+ // the same six steps with the runs honestly greyed out, rather than buttons
1192
+ // that 403.
1193
+ //
1194
+ // The walkthrough's state lives here and nowhere else: no session on the
1195
+ // server, exactly as #254 asks. Reload and you're back at Advise, with whatever
1196
+ // the previous run wrote still sitting in the demo copy.
1197
+ // ---------------------------------------------------------------------------
1198
+ let carveInfo = null; // /api/project's `carve` block (report meta + demo, or null)
1199
+ let carveReport = null; // the raw report off /api/carve — the boundary lists live here
1200
+ let carveState = initialCarveState();
1201
+ let carveHost = null; // the panel section, mounted on first sight of carve mode
1202
+ const carvedIds = new Set(); // addresses the walkthrough has taken all the way through
1203
+
1204
+ function carveMode() {
1205
+ return !!carveInfo;
1206
+ }
1207
+
1208
+ /** The stepper's wiring. Everything that talks to the network or the graph. */
1209
+ const carveActions = {
1210
+ go(index) {
1211
+ const id = CARVE_STEPS[index] && CARVE_STEPS[index].id;
1212
+ if (!id) return;
1213
+ if (blockedReason(carveState, id)) return;
1214
+ carveState.step = index;
1215
+ carveState.error = null;
1216
+ renderPanelCarve();
1217
+ },
1218
+ select(address) {
1219
+ selectNode(address); // the same path a graph click takes — inspect included
1220
+ },
1221
+ reset() {
1222
+ carveState = initialCarveState();
1223
+ renderPanelCarve();
1224
+ },
1225
+ markHandoff() {
1226
+ carveState.handoff = true;
1227
+ if (carveState.pick) {
1228
+ carvedIds.add(carveState.pick.node.id);
1229
+ markCarvedCards();
1230
+ }
1231
+ carveState.step = CARVE_STEPS.findIndex((s) => s.id === "done");
1232
+ renderPanelCarve();
1233
+ },
1234
+ runEmit: () => runCarveStep("emit"),
1235
+ runBridge: () => runCarveStep("bridge"),
1236
+ copy(text, el) {
1237
+ const done = () => {
1238
+ el.dataset.copied = "1";
1239
+ const was = el.textContent;
1240
+ el.textContent = "copied ✓";
1241
+ setTimeout(() => {
1242
+ el.textContent = was;
1243
+ }, 1200);
1244
+ };
1245
+ if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(done, done);
1246
+ else done();
1247
+ },
1248
+ };
1249
+
1250
+ /** Run one of the two safe steps. Both are POSTs with a `{select}` body; both
1251
+ * answer either their result or #193's `{error, code, remedy}`. */
1252
+ async function runCarveStep(which) {
1253
+ if (carveState.busy || !carveState.pick) return;
1254
+ carveState.busy = which;
1255
+ carveState.error = null;
1256
+ renderPanelCarve();
1257
+ try {
1258
+ const res = await fetch(`/api/carve/${which}`, {
1259
+ method: "POST",
1260
+ headers: { "content-type": "application/json" },
1261
+ body: JSON.stringify({ select: carveState.pick.node.id }),
1262
+ });
1263
+ const body = await res.json().catch(() => ({ error: `${which} returned an unreadable body`, remedy: "" }));
1264
+ if (!res.ok || body.error) {
1265
+ carveState.error = { step: which, ...body };
1266
+ showToast(`✗ carve ${which}: ${body.error || res.status}`, false);
1267
+ } else {
1268
+ carveState[which] = body;
1269
+ // Deliberately does NOT advance. The result IS the step — the emitted
1270
+ // source and the lint verdict, the proposed patch — and skipping past it
1271
+ // to the next button would hide the thing the run was for. The "next"
1272
+ // control unlocks; pressing it stays the viewer's move.
1273
+ showToast(`✓ carve ${which} — wrote into ${(carveInfo.demo && carveInfo.demo.outLabel) || "the demo copy"}`, true);
1274
+ }
1275
+ } catch (err) {
1276
+ carveState.error = { step: which, error: String((err && err.message) || err), remedy: "Is the behold server still running?" };
1277
+ } finally {
1278
+ carveState.busy = null;
1279
+ renderPanelCarve();
1280
+ }
1281
+ }
1282
+
1283
+ /** A picked card becomes the walkthrough's subject. Called from the graph's
1284
+ * click handler and from selectNode(), so the panel rows and the cards agree. */
1285
+ function carvePick(node) {
1286
+ if (!carveMode()) return;
1287
+ const resource = (carveReport && carveReport.resources ? carveReport.resources : []).find((r) => r.address === node.id) || null;
1288
+ const pickStep = CARVE_STEPS.findIndex((s) => s.id === "pick");
1289
+ // A NEW pick invalidates the runs that were about the old one — showing one
1290
+ // resource's emitted source under another's name is the one way this panel
1291
+ // could actively lie — and drops the walkthrough back to Pick, wherever it
1292
+ // had got to. Re-clicking the SAME card is just a re-select and moves
1293
+ // nothing, so reading a card mid-walkthrough costs no progress.
1294
+ if (!carveState.pick || carveState.pick.node.id !== node.id) {
1295
+ carveState.emit = null;
1296
+ carveState.bridge = null;
1297
+ carveState.handoff = false;
1298
+ carveState.error = null;
1299
+ carveState.step = pickStep;
1300
+ } else if (carveState.step < pickStep) {
1301
+ carveState.step = pickStep;
1302
+ }
1303
+ carveState.pick = { node, resource };
1304
+ renderPanelCarve();
1305
+ }
1306
+
1307
+ /** The "carved" marker the last step leaves on the card — a class the CSS
1308
+ * paints plus a small label. Re-applied after every render, because the SVG is
1309
+ * replaced wholesale on each load. The full morph (the box sliding out of the
1310
+ * Terraform boundary and into the chant project beside last month's carves) is
1311
+ * the follow-up; this is the honest still frame of it. */
1312
+ function markCarvedCards() {
1313
+ const svg = document.querySelector("#graph svg");
1314
+ if (!svg || !carvedIds.size) return;
1315
+ for (const g of svg.querySelectorAll("[data-node-id]")) {
1316
+ const id = g.getAttribute("data-node-id");
1317
+ if (!carvedIds.has(id) || g.querySelector('[data-carved="1"]')) continue;
1318
+ g.classList.add("carved");
1319
+ // Measured, not read off attributes: pinhole sizes the card from its
1320
+ // content and doesn't always stamp width/height, and a missing attribute
1321
+ // read as 0 parks the label at the group's origin — which is off the card
1322
+ // entirely (seen in the browser before this was measured instead).
1323
+ const rect = g.querySelector("rect");
1324
+ const box = rect && rect.getBBox ? rect.getBBox() : null;
1325
+ const tag = document.createElementNS("http://www.w3.org/2000/svg", "text");
1326
+ tag.setAttribute("data-carved", "1");
1327
+ // Inside the card's own box, bottom-right — the one corner the terraform
1328
+ // presentation pack leaves empty.
1329
+ tag.setAttribute("x", String((box ? box.x + box.width : 150) - 10));
1330
+ tag.setAttribute("y", String((box ? box.y + box.height : 60) - 9));
1331
+ tag.setAttribute("text-anchor", "end");
1332
+ tag.setAttribute("font-size", "11");
1333
+ tag.setAttribute("font-weight", "600");
1334
+ tag.setAttribute("fill", "var(--managed)");
1335
+ tag.textContent = "✓ carved → chant";
1336
+ g.appendChild(tag);
1337
+ }
1338
+ }
1339
+
1340
+ function renderPanelCarve() {
1341
+ if (!carveMode()) return;
1342
+ if (!carveHost) {
1343
+ carveHost = addPanelTab("carve", "Carve", "The peel walkthrough: advise → pick → emit → bridge → handoff → done.");
1344
+ if (!carveHost) return;
1345
+ carveHost.id = "tab-carve";
1346
+ }
1347
+ renderCarvePanel(
1348
+ carveHost,
1349
+ carveState,
1350
+ { carve: carveInfo, demo: carveInfo && carveInfo.demo, report: carveReport, renderJson },
1351
+ carveActions,
1352
+ );
1353
+ }
1354
+
1355
+ /** Carve mode's one extra fetch: the raw report, for the per-resource boundary
1356
+ * lists the graph IR deliberately doesn't carry. Best-effort — the stepper
1357
+ * degrades to the counts in the IR's own attrs. */
1358
+ async function loadCarveReport() {
1359
+ if (!carveMode()) return;
1360
+ try {
1361
+ carveReport = await apiFetch("/api/carve").then((r) => r.json());
1362
+ } catch {
1363
+ carveReport = null;
1364
+ }
1365
+ renderPanelCarve();
1366
+ // A readiness marker, so a test can wait for the extra fetch instead of
1367
+ // racing it. The panel itself never waits: `cutSummary` falls back to the
1368
+ // counts the IR node already carries, which is what the report would have
1369
+ // told it anyway on a chant that publishes no edge lists.
1370
+ if (carveHost) carveHost.dataset.report = carveReport ? "1" : "0";
1371
+ }
1372
+
1047
1373
  function renderPanel() {
1048
1374
  renderPanelView();
1049
1375
  renderPanelScope();
1050
1376
  renderPanelModel();
1377
+ if (carveMode()) renderPanelCarve();
1051
1378
  }
1052
1379
 
1053
1380
  function renderStatusbar() {
@@ -1716,6 +2043,7 @@ function render(ir, svg, m) {
1716
2043
  ensureBackToInfra(g);
1717
2044
  wire(ir);
1718
2045
  if (view.radial && !view.components && !view.logical) addRadialLabels(ir);
2046
+ markCarvedCards(); // #254: the SVG is replaced per render — re-stamp the marker
1719
2047
  applyLayout(); // #228: last, so the hand-placed deltas ride on top of every other pass
1720
2048
  renderDial();
1721
2049
  }
@@ -1916,6 +2244,14 @@ function ensureZoomControls(host) {
1916
2244
  // the end of every render(), so the graph underneath stays chant's and a
1917
2245
  // delta for a node that left the estate is simply not applied.
1918
2246
  //
2247
+ // Two tiers now (#228's second half): localStorage, and the project's own
2248
+ // `.behold/layout.json` behind GET/POST /api/layout. On load the server's map
2249
+ // merges UNDER the local one (`mergeLayouts` — the drag you can see always
2250
+ // wins); on a finished gesture the current lens is POSTed, debounced. A server
2251
+ // that refuses — a static export, preview mode, a read-only project, an older
2252
+ // behold with no such route — leaves the localStorage tier working exactly as
2253
+ // it did before, and says nothing.
2254
+ //
1919
2255
  // What this does NOT do, said plainly rather than faked:
1920
2256
  // * a resized box does not reflow its children — that is dagre's job on the
1921
2257
  // next layout, and the reset control's tooltip says so;
@@ -1923,51 +2259,86 @@ function ensureZoomControls(host) {
1923
2259
  // its original anchor points, each shifted by its own node's delta. #228
1924
2260
  // accepts the straight-line fallback; spline re-routing is pinhole's job.
1925
2261
  // An edge with both ends where dagre put them keeps its bezier untouched.
1926
- // * nothing is written to the server. The second tier of #228 (a
1927
- // `.behold/layout.json` sidecar behind POST /api/layout, so a server-side
1928
- // export honours the same deltas) is the follow-up half.
2262
+ // Everything anchored TO that edge rides with it (#267): both paths in the
2263
+ // group the visible line and pinhole's fat transparent hit-path — and the
2264
+ // `viaAttr` chip, which lands on the new midpoint (see edgeLabelOf).
2265
+ // A box's title needs nothing: pinhole paints it at the rect's top-left
2266
+ // corner, which is the one corner a resize never moves, and a box move
2267
+ // translates the whole wrapper group the title is already inside.
2268
+ // * the server bakes {dx,dy} into an exported SVG but not a box's {dw,dh} —
2269
+ // only pinhole's ARCHITECTURE boxes carry an id to bake against, and a bake
2270
+ // that worked on some lenses and not others would be worse than none
2271
+ // (src/layout.ts says the same at more length).
1929
2272
  let layoutIndex = null; // {nodes,boxes,edges} for the SVG currently on screen
1930
2273
  let layoutDeltas = {}; // the deltas in force for the current key
1931
2274
  let layoutDrag = null; // the gesture in flight
1932
2275
  let layoutWired = false;
2276
+ // The sidecar tier, cached per lens: one GET when a lens is first shown, not
2277
+ // one per render (render() runs on every SSE nudge and every settle poll).
2278
+ let layoutServer = { lens: null, deltas: {}, writable: false };
1933
2279
 
1934
2280
  /** The storage key for the project + lens on screen; null before /api/project lands. */
1935
2281
  function currentLayoutKey() {
1936
2282
  if (!projectInfo) return null;
1937
- return layoutKey(projectKeyOf(projectInfo), lensKeyOf({ zoom: zoomValue(), radial: view.radial, stack: view.stack }));
2283
+ return layoutKey(projectKeyOf(projectInfo), currentLensKey());
1938
2284
  }
1939
2285
 
1940
- /** First and last coordinate pair of a path `d` pinhole's own edge anchors. */
1941
- function pathAnchors(d) {
1942
- const n = String(d || "").match(/-?\d*\.?\d+(?:e[-+]?\d+)?/gi);
1943
- if (!n || n.length < 4) return null;
1944
- return { sx: +n[0], sy: +n[1], ex: +n[n.length - 2], ey: +n[n.length - 1] };
2286
+ /** The lens half of the key also what /api/layout is keyed by (the project
2287
+ * half is implicit there: the sidecar lives inside the project). */
2288
+ function currentLensKey() {
2289
+ return lensKeyOf({ zoom: zoomValue(), radial: view.radial, stack: view.stack });
1945
2290
  }
1946
2291
 
1947
2292
  /**
1948
2293
  * Wrap each containment box in a `<g data-layout-box>` and give it a corner
1949
2294
  * handle, once per rendered SVG.
1950
2295
  *
1951
- * FRAGILE BY CONSTRUCTION, and knowingly so: pinhole's `Canvas.groupBox` emits
1952
- * a bare `<rect rx=…>` immediately followed by its title `<text>`, as a direct
1953
- * child of the `<svg>` — no group, no `data-group-id` to match on. So behold
1954
- * matches the structure, exactly the way addGitlabWaveBadges already has to.
1955
- * The discriminators: a positive `rx` (the two page-background rects have
1956
- * none), a `<text>` as the next non-badge sibling, and svg-root parentage (a
1957
- * card's rect lives inside `[data-node-id]`, an edge-label's inside its own
1958
- * `<g>`). The real fix is upstream a `data-group-id` on the box and until
1959
- * pinhole stamps one, prefer the attribute here the moment it exists.
2296
+ * TWO WAYS TO FIND A BOX, in that order (#250):
2297
+ *
2298
+ * 1. `rect[data-group-id]` pinhole stamps the container key on an
2299
+ * architecture group box from 0.3.3 (pinhole#103/#104), the same hook
2300
+ * `data-node-id` gives a card. That key is also what the box's delta is
2301
+ * stored under, so a box keeps its size when its title changes and two
2302
+ * boxes that happen to read alike stay separate placements.
2303
+ * 2. The structural match #245 shipped, kept for one release as a fallback and
2304
+ * then deleted. It is still load-bearing, not dead weight: `layoutArchitecture`
2305
+ * is the only pinhole layout that sets `GroupBox.id`, so the wave/stack/
2306
+ * container boxes `renderGraph` draws (src/render.ts) arrive with a title
2307
+ * and nothing else. FRAGILE BY CONSTRUCTION and knowingly so — the
2308
+ * discriminators are a positive `rx` (the two page-background rects have
2309
+ * none), a `<text>` as the next non-badge sibling, and svg-root parentage
2310
+ * (a card's rect lives inside `[data-node-id]`, an edge-label's inside its
2311
+ * own `<g>`), exactly the way addGitlabWaveBadges still has to.
2312
+ *
2313
+ * Identification runs over a DOM nothing has moved yet and the wrapping happens
2314
+ * after: both paths read `nextElementSibling`, and inserting a wrapper mid-walk
2315
+ * would put one box's `<g>` in the middle of the next box's member run.
2316
+ *
2317
+ * MIGRATION, said plainly: a box that gains an id changes storage key —
2318
+ * `layoutArchitecture` titles a box `<id> · <kind>`, so `box:vpc · VPC`
2319
+ * becomes `box:vpc` — and `applicable` drops the old key on the next render.
2320
+ * That is a deliberate one-time loss of hand-set box sizes on the logical
2321
+ * views, taken rather than carrying a key-rewriting migration for a delta that
2322
+ * is cosmetic and one drag to redo. Node placements are untouched: they were
2323
+ * always keyed by `data-node-id`.
1960
2324
  */
1961
2325
  function wrapContainmentBoxes(svgEl) {
1962
2326
  const boxes = new Map();
1963
2327
  const handleSize = Math.max(12, Math.round((vbInit ? vbInit[2] : 1000) / 90));
2328
+ const found = [];
2329
+ const claimed = new Set();
1964
2330
  for (const rect of [...svgEl.children]) {
1965
2331
  if (rect.tagName.toLowerCase() !== "rect") continue;
2332
+ const groupId = (rect.getAttribute("data-group-id") || "").trim();
1966
2333
  const x = parseFloat(rect.getAttribute("x"));
1967
2334
  const y = parseFloat(rect.getAttribute("y"));
1968
2335
  const w = parseFloat(rect.getAttribute("width"));
1969
2336
  const h = parseFloat(rect.getAttribute("height"));
1970
- if (!(parseFloat(rect.getAttribute("rx")) > 0) || !(w > 0) || !(h > 0) || Number.isNaN(x) || Number.isNaN(y)) continue;
2337
+ if (!(w > 0) || !(h > 0) || Number.isNaN(x) || Number.isNaN(y)) continue;
2338
+ // The `rx` sniff is the fallback's discriminator only. An id'd rect has
2339
+ // already said what it is, and hardening a shape pinhole never promised
2340
+ // would be the same mistake this issue exists to undo.
2341
+ if (!groupId && !(parseFloat(rect.getAttribute("rx")) > 0)) continue;
1971
2342
  // Collect rect → title, stepping over anything already stamped between
1972
2343
  // them (the GitLab wave badge), and bail at the next box or card.
1973
2344
  const members = [rect];
@@ -1981,17 +2352,28 @@ function wrapContainmentBoxes(svgEl) {
1981
2352
  break;
1982
2353
  }
1983
2354
  }
1984
- if (!title) continue;
1985
- const id = `box:${(title.textContent || "").trim() || boxes.size}`;
1986
- if (boxes.has(id)) continue;
2355
+ // An untitled box is only knowable by its attribute — the structural path
2356
+ // has literally nothing else to go on, and a titleless id'd box keeps its
2357
+ // resize handle and simply has no drag-by-title. It also keeps nothing but
2358
+ // its own rect: the walk above only stops at a title, so without one it has
2359
+ // swept up whatever pinhole painted next, which is not this box's to move.
2360
+ if (!title && !groupId) continue;
2361
+ const id = `box:${groupId || (title.textContent || "").trim() || found.length}`;
2362
+ if (claimed.has(id)) continue;
2363
+ claimed.add(id);
2364
+ found.push({ id, rect, members: title ? members : [rect], title, x, y, w, h });
2365
+ }
2366
+ for (const { id, rect, members, title, x, y, w, h } of found) {
1987
2367
  const g = document.createElementNS(SVGNS, "g");
1988
2368
  g.setAttribute("data-layout-box", id);
1989
2369
  rect.parentNode.insertBefore(g, rect);
1990
2370
  members.forEach((m) => g.appendChild(m));
1991
2371
  // The title doubles as the box's move handle — the interior stays pan
1992
2372
  // territory, which on a logical view is most of the canvas.
1993
- title.setAttribute("data-layout-move", id);
1994
- title.setAttribute("cursor", "move");
2373
+ if (title) {
2374
+ title.setAttribute("data-layout-move", id);
2375
+ title.setAttribute("cursor", "move");
2376
+ }
1995
2377
  const handle = document.createElementNS(SVGNS, "g");
1996
2378
  handle.setAttribute("data-layout-resize", id);
1997
2379
  handle.setAttribute("cursor", "nwse-resize");
@@ -2015,38 +2397,178 @@ function positionBoxHandle(b, w, h) {
2015
2397
  b.handle.setAttribute("transform", `translate(${b.x + w - b.size - 3}, ${b.y + h - b.size - 3})`);
2016
2398
  }
2017
2399
 
2400
+ /** A box's rect as it stands right now: pinhole's, plus whatever the hand did to
2401
+ * it. One definition for the painter and for the clamp, so growing a box really
2402
+ * does buy its children room (#267) and cannot drift from what you can see. */
2403
+ function boxRect(b, d) {
2404
+ return {
2405
+ x: b.x + ((d && d.dx) || 0),
2406
+ y: b.y + ((d && d.dy) || 0),
2407
+ w: Math.max(b.size * 3, b.w0 + ((d && d.dw) || 0)),
2408
+ h: Math.max(b.size * 3, b.h0 + ((d && d.dh) || 0)),
2409
+ };
2410
+ }
2411
+
2412
+ /** Screen pixels → this SVG's own user units, as a function. Null when the SVG
2413
+ * isn't laid out (a hidden pane, a detached document) — and a null mapper means
2414
+ * no measured geometry, which the clamp reads as "don't clamp". */
2415
+ function userSpaceMapper(svgEl) {
2416
+ const m = svgEl.getScreenCTM && svgEl.getScreenCTM();
2417
+ if (!m || typeof svgEl.createSVGPoint !== "function") return null;
2418
+ const inv = m.inverse();
2419
+ const pt = svgEl.createSVGPoint();
2420
+ return (x, y) => {
2421
+ pt.x = x;
2422
+ pt.y = y;
2423
+ return pt.matrixTransform(inv);
2424
+ };
2425
+ }
2426
+
2427
+ /** An element's box in SVG user units. MEASURED, not parsed off attributes:
2428
+ * pinhole's cards carry absolute coordinates and no transform, the smoke stub's
2429
+ * carry a `translate()`, and a measurement holds either without this code
2430
+ * knowing which. Only ever taken on a freshly rendered SVG, before any delta is
2431
+ * painted — it is the dagre-placed rect the clamp reasons about. */
2432
+ function userRect(el, toUser) {
2433
+ if (!toUser || typeof el.getBoundingClientRect !== "function") return null;
2434
+ const r = el.getBoundingClientRect();
2435
+ if (!(r.width > 0) || !(r.height > 0)) return null;
2436
+ const a = toUser(r.left, r.top);
2437
+ const b = toUser(r.right, r.bottom);
2438
+ return { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), w: Math.abs(b.x - a.x), h: Math.abs(b.y - a.y) };
2439
+ }
2440
+
2441
+ /**
2442
+ * The chip pinhole paints on a labelled edge (`project`, `sourceRef`,
2443
+ * `selector` — the IR edge's `viaAttr`), or null if this edge has none.
2444
+ *
2445
+ * HOW THE TWO ARE ASSOCIATED, and why it is what it is: `renderSvg` emits
2446
+ * `Canvas.edge(...)` and then, for a labelled edge, `Canvas.edgeLabel(...)` —
2447
+ * an ANONYMOUS `<g><rect rx="9"/><text>…</text></g>` carrying no edge identity
2448
+ * whatsoever. There is no id, no data attribute, no shared class. What is left
2449
+ * is document order (the chip is the edge group's next element sibling) and the
2450
+ * one piece of content the two provably share: the chip's text IS the edge
2451
+ * group's `data-edge-via` value. Behold requires BOTH before it will move
2452
+ * anything, so a pinhole that stops emitting chips, reorders them, or slips
2453
+ * something between the pair leaves the label untouched rather than dragging an
2454
+ * unrelated group around the canvas.
2455
+ *
2456
+ * Read at index time and kept as an element reference, never re-derived:
2457
+ * wireEdgeHighlight's `raise()` re-appends a hovered edge group to the end of
2458
+ * the SVG, so the sibling link is gone the instant a pointer crosses an edge.
2459
+ *
2460
+ * The real fix is upstream, and it is the same ask boxes made and won in
2461
+ * pinhole 0.3.3 (`data-group-id`, #250): stamp the chip with the edge it
2462
+ * belongs to. The moment pinhole emits a `data-edge-*` pairing on the label
2463
+ * group, prefer it here the way wrapContainmentBoxes now prefers the attribute.
2464
+ */
2465
+ function edgeLabelOf(g) {
2466
+ const via = (g.getAttribute("data-edge-via") || "").trim();
2467
+ if (!via) return null;
2468
+ const next = g.nextElementSibling;
2469
+ if (!next || next.tagName.toLowerCase() !== "g") return null;
2470
+ if (next.hasAttribute("data-node-id") || next.hasAttribute("data-edge-from")) return null;
2471
+ const text = next.querySelector("text");
2472
+ if (!text || (text.textContent || "").trim() !== via) return null;
2473
+ return next;
2474
+ }
2475
+
2018
2476
  /** Index the freshly rendered SVG: node groups, containment boxes, edge paths. */
2019
2477
  function indexLayout(svgEl) {
2478
+ const toUser = userSpaceMapper(svgEl);
2020
2479
  const nodes = new Map();
2021
2480
  for (const el of svgEl.querySelectorAll("[data-node-id]")) {
2022
2481
  const id = el.getAttribute("data-node-id");
2023
- if (!nodes.has(id)) nodes.set(id, { el, base: el.getAttribute("transform") || "" });
2482
+ if (!nodes.has(id)) nodes.set(id, { el, base: el.getAttribute("transform") || "", rect: userRect(el, toUser) });
2024
2483
  }
2025
2484
  const edges = [];
2026
2485
  for (const g of svgEl.querySelectorAll("g[data-edge-from]")) {
2027
2486
  const paths = [...g.querySelectorAll("path")];
2028
2487
  if (!paths.length) continue;
2029
2488
  const d0 = paths[0].getAttribute("d");
2030
- edges.push({ from: g.getAttribute("data-edge-from"), to: g.getAttribute("data-edge-to"), paths, d0, anchors: pathAnchors(d0) });
2489
+ const label = edgeLabelOf(g);
2490
+ edges.push({
2491
+ from: g.getAttribute("data-edge-from"),
2492
+ to: g.getAttribute("data-edge-to"),
2493
+ paths,
2494
+ d0,
2495
+ anchors: pathAnchors(d0),
2496
+ label,
2497
+ labelBase: label ? label.getAttribute("transform") || "" : "",
2498
+ });
2031
2499
  }
2032
2500
  layoutIndex = { nodes, boxes: wrapContainmentBoxes(svgEl), edges };
2033
2501
  }
2034
2502
 
2503
+ /**
2504
+ * The box a card sits in, geometrically — the containment is visual and nothing
2505
+ * in the DOM says which card belongs to which box. The SMALLEST box whose
2506
+ * CURRENT rect covers the card's original centre wins, so a card in a namespace
2507
+ * nested inside a cluster clamps to the namespace, not the cluster.
2508
+ *
2509
+ * `current` is the load-bearing word: growing a box is the sanctioned way to
2510
+ * make room, so the wall a drag stops at has to be the box as it is now.
2511
+ */
2512
+ function containerOf(rect) {
2513
+ if (!layoutIndex || !rect) return null;
2514
+ const cx = rect.x + rect.w / 2;
2515
+ const cy = rect.y + rect.h / 2;
2516
+ let best = null;
2517
+ for (const [id, b] of layoutIndex.boxes) {
2518
+ const r = boxRect(b, layoutDeltas[id]);
2519
+ if (cx < r.x || cx > r.x + r.w || cy < r.y || cy > r.y + r.h) continue;
2520
+ if (!best || r.w * r.h < best.w * best.h) best = r;
2521
+ }
2522
+ return best;
2523
+ }
2524
+
2525
+ /** A node's delta, clamped to whatever contains it: its box, or failing that
2526
+ * the canvas pinhole drew (`vbInit` — the graph's own extent, NOT the panned
2527
+ * and zoomed `vb`, which is just where you happen to be looking).
2528
+ *
2529
+ * Nodes only. A box's own gesture is the escape hatch, so clamping a box would
2530
+ * put the only way to make room behind the wall it enforces. And an id with no
2531
+ * measured rect is returned untouched — see clampDelta on failing open. */
2532
+ function clampNodeDelta(id, delta) {
2533
+ const n = layoutIndex && layoutIndex.nodes.get(id);
2534
+ if (!n || !n.rect) return delta;
2535
+ const canvas = vbInit ? { x: vbInit[0], y: vbInit[1], w: vbInit[2], h: vbInit[3] } : null;
2536
+ const bounds = containerOf(n.rect) || canvas;
2537
+ return bounds ? clampDelta(delta, n.rect, bounds) : delta;
2538
+ }
2539
+
2540
+ /** Bring every stored node delta back inside its container. Clamped, never
2541
+ * discarded (#267): a placement made before this rule existed — or against a
2542
+ * box someone has since shrunk — keeps everything about it except the part that
2543
+ * escaped. Box deltas are read as-is, which is why this runs after they land. */
2544
+ function clampStoredDeltas(deltas) {
2545
+ if (!layoutIndex) return deltas;
2546
+ let out = deltas;
2547
+ for (const id of layoutIndex.nodes.keys()) {
2548
+ const d = out[id];
2549
+ if (!d || (!d.dx && !d.dy)) continue;
2550
+ const c = clampNodeDelta(id, d);
2551
+ if (c.dx !== (d.dx || 0) || c.dy !== (d.dy || 0)) out = setDelta(out, id, { ...d, dx: c.dx, dy: c.dy });
2552
+ }
2553
+ return out;
2554
+ }
2555
+
2035
2556
  /** Paint `layoutDeltas` onto the indexed SVG. Idempotent: always from the original. */
2036
2557
  function renderLayout() {
2037
2558
  if (!layoutIndex) return;
2038
2559
  for (const [id, n] of layoutIndex.nodes) {
2039
- const d = layoutDeltas[id];
2040
- const t = d && (d.dx || d.dy) ? `translate(${d.dx || 0}, ${d.dy || 0}) ${n.base}`.trim() : n.base;
2560
+ const t = nodeTransform(n.base, layoutDeltas[id] || {});
2041
2561
  if (t) n.el.setAttribute("transform", t);
2042
2562
  else n.el.removeAttribute("transform");
2043
2563
  }
2044
2564
  for (const [id, b] of layoutIndex.boxes) {
2045
2565
  const d = layoutDeltas[id] || {};
2046
- const w = Math.max(b.size * 3, b.w0 + (d.dw || 0));
2047
- const h = Math.max(b.size * 3, b.h0 + (d.dh || 0));
2566
+ const { w, h } = boxRect(b, d);
2048
2567
  b.rect.setAttribute("width", String(w));
2049
2568
  b.rect.setAttribute("height", String(h));
2569
+ // The title rides inside this same group, and pinhole anchors it to the
2570
+ // rect's top-left — the one corner a resize leaves alone — so a box's
2571
+ // caption never detaches from its box (#267 asked; nothing to fix).
2050
2572
  if (d.dx || d.dy) b.g.setAttribute("transform", `translate(${d.dx || 0}, ${d.dy || 0})`);
2051
2573
  else b.g.removeAttribute("transform");
2052
2574
  positionBoxHandle(b, w, h);
@@ -2056,30 +2578,88 @@ function renderLayout() {
2056
2578
  const z = layoutDeltas[e.to];
2057
2579
  if ((!a && !z) || !e.anchors) {
2058
2580
  if (e.paths[0].getAttribute("d") !== e.d0) e.paths.forEach((p) => p.setAttribute("d", e.d0));
2581
+ placeEdgeLabel(e, 0, 0);
2059
2582
  continue;
2060
2583
  }
2061
- const { sx, sy, ex, ey } = e.anchors;
2062
- const d = `M ${sx + ((a && a.dx) || 0)} ${sy + ((a && a.dy) || 0)} L ${ex + ((z && z.dx) || 0)} ${ey + ((z && z.dy) || 0)}`;
2584
+ const d = straightEdge(e.anchors, a, z);
2585
+ // EVERY path in the group: the visible line and pinhole's 14-wide
2586
+ // transparent hit-path both, so the edge you can grab stays under the edge
2587
+ // you can see.
2063
2588
  e.paths.forEach((p) => p.setAttribute("d", d));
2589
+ // The chip was painted at the midpoint of the two anchors, so the midpoint
2590
+ // of the re-anchored line is that point plus the MEAN of the two ends'
2591
+ // deltas. No re-reading of the chip's own coordinates, and no assumption
2592
+ // about how pinhole spelled them.
2593
+ placeEdgeLabel(e, (((a && a.dx) || 0) + ((z && z.dx) || 0)) / 2, (((a && a.dy) || 0) + ((z && z.dy) || 0)) / 2);
2064
2594
  }
2065
2595
  }
2066
2596
 
2597
+ /** Shift an edge's label chip by `(sx,sy)`, from its painted position. Composed
2598
+ * onto whatever transform it was painted with (none, today) and removed when
2599
+ * the shift is zero — so this is idempotent, like the rest of renderLayout. */
2600
+ function placeEdgeLabel(e, sx, sy) {
2601
+ if (!e.label) return;
2602
+ const t = nodeTransform(e.labelBase, { dx: sx, dy: sy });
2603
+ if (t) e.label.setAttribute("transform", t);
2604
+ else e.label.removeAttribute("transform");
2605
+ }
2606
+
2067
2607
  /** Re-index the SVG, load this lens's deltas, paint them. Called from render(). */
2068
2608
  function applyLayout() {
2069
2609
  const svgEl = currentSvg();
2070
2610
  const host = document.getElementById("graph");
2071
2611
  if (!svgEl || !host) return;
2072
2612
  indexLayout(svgEl);
2613
+ reloadLayoutDeltas();
2614
+ ensureLayoutReset(host);
2615
+ ensureLayoutDrag(host);
2616
+ const lens = currentLensKey();
2617
+ if (currentLayoutKey() && layoutServer.lens !== lens) pullServerLayout(lens);
2618
+ }
2619
+
2620
+ /** Recompute both tiers onto the SVG already indexed, and repaint. Separate
2621
+ * from applyLayout() because it must NOT re-index: renderLayout paints from
2622
+ * each node's ORIGINAL transform, so re-indexing an already-painted SVG would
2623
+ * take the displaced transform as the new base and apply the delta twice. */
2624
+ function reloadLayoutDeltas() {
2625
+ if (!layoutIndex) return;
2073
2626
  const key = currentLayoutKey();
2627
+ const lens = currentLensKey();
2074
2628
  // Stale ids are dropped on apply, not on write: a lens the user hasn't
2075
2629
  // opened in a while shouldn't have its deltas quietly deleted because this
2076
2630
  // render happened to be a different projection of the same estate.
2077
- layoutDeltas = key ? applicable(readLayout(localStorage, key), [...layoutIndex.nodes.keys(), ...layoutIndex.boxes.keys()]) : {};
2631
+ const merged = key ? mergeLayouts(readLayout(localStorage, key), layoutServer.lens === lens ? layoutServer.deltas : {}) : {};
2632
+ layoutDeltas = applicable(merged, [...layoutIndex.nodes.keys(), ...layoutIndex.boxes.keys()]);
2633
+ layoutDeltas = clampStoredDeltas(layoutDeltas);
2078
2634
  renderLayout();
2079
- ensureLayoutReset(host);
2080
- ensureLayoutDrag(host);
2081
2635
  }
2082
2636
 
2637
+ /** One GET per lens. Whatever comes back is merged UNDER the local tier and
2638
+ * repainted; a refusal is cached as an empty, unwritable answer, so a serve
2639
+ * with no sidecar (or no server at all) costs exactly one request per lens. */
2640
+ async function pullServerLayout(lens) {
2641
+ layoutServer = { lens, deltas: {}, writable: false }; // claim it first — no request storm
2642
+ const got = await fetchServerLayout(apiFetch, lens);
2643
+ if (layoutServer.lens !== lens) return; // the view moved on while we waited
2644
+ layoutServer = { lens, ...got };
2645
+ if (!Object.keys(got.deltas).length || currentLensKey() !== lens) return;
2646
+ reloadLayoutDeltas();
2647
+ ensureLayoutReset(document.getElementById("graph"));
2648
+ }
2649
+
2650
+ /** Push the current lens to the sidecar, once the hand has stopped moving.
2651
+ * Nothing here is load-bearing: `writable` false (static export, preview mode,
2652
+ * read-only project, older behold) simply never pushes, and a failed push is
2653
+ * not reported — the localStorage tier already has it. */
2654
+ const pushServerLayout = debounce((lens, deltas) => {
2655
+ if (staticMode || !layoutServer.writable || layoutServer.lens !== lens) return;
2656
+ postServerLayout((url, init) => fetch(url, init), lens, deltas);
2657
+ }, 600);
2658
+ // A reload (or a tab closing) within the debounce window would otherwise drop
2659
+ // the last placement on the floor — localStorage has it, the sidecar wouldn't.
2660
+ // The push rides `keepalive`, so it survives the document.
2661
+ window.addEventListener("pagehide", () => pushServerLayout.flush());
2662
+
2083
2663
  /** The grabbable thing at or above `el`, if any. */
2084
2664
  function layoutTargetIn(el) {
2085
2665
  if (!el || typeof el.closest !== "function") return null;
@@ -2151,7 +2731,11 @@ function ensureLayoutDrag(host) {
2151
2731
  layoutDrag.kind === "resize"
2152
2732
  ? { dx: b.dx || 0, dy: b.dy || 0, dw: (b.dw || 0) + dx, dh: (b.dh || 0) + dy }
2153
2733
  : { dx: (b.dx || 0) + dx, dy: (b.dy || 0) + dy, dw: b.dw || 0, dh: b.dh || 0 };
2154
- layoutDeltas = setDelta(layoutDeltas, layoutDrag.id, next);
2734
+ // #267: the card stops at its box's wall while the pointer keeps going. A
2735
+ // no-op for a box id and for a resize, and the box's CURRENT size is what
2736
+ // is read — so widening a box mid-session immediately buys its children the
2737
+ // room, no re-render needed.
2738
+ layoutDeltas = setDelta(layoutDeltas, layoutDrag.id, clampNodeDelta(layoutDrag.id, next));
2155
2739
  renderLayout();
2156
2740
  });
2157
2741
  window.addEventListener("pointerup", () => {
@@ -2161,6 +2745,10 @@ function ensureLayoutDrag(host) {
2161
2745
  if (!moved) return;
2162
2746
  const key = currentLayoutKey();
2163
2747
  if (key) writeLayout(localStorage, key, layoutDeltas);
2748
+ // The sidecar gets the WHOLE lens map, not the one id that moved: it is a
2749
+ // per-lens document, and the local tier is the authority the user is
2750
+ // looking at. Debounced, so a drag is one write and not sixty.
2751
+ if (key) pushServerLayout(currentLensKey(), layoutDeltas);
2164
2752
  ensureLayoutReset(document.getElementById("graph"));
2165
2753
  });
2166
2754
  }
@@ -2180,6 +2768,11 @@ function ensureLayoutReset(host) {
2180
2768
  e.stopPropagation();
2181
2769
  const key = currentLayoutKey();
2182
2770
  if (key) clearLayout(localStorage, key);
2771
+ // Both tiers, or it isn't a reset: clearing only localStorage would let
2772
+ // the next merge pull the sidecar's deltas straight back in.
2773
+ const lens = currentLensKey();
2774
+ layoutServer = { lens, deltas: {}, writable: layoutServer.lens === lens && layoutServer.writable };
2775
+ pushServerLayout(lens, {});
2183
2776
  layoutDeltas = {};
2184
2777
  renderLayout();
2185
2778
  ensureLayoutReset(host);
@@ -2202,6 +2795,10 @@ const PRECONDITION_TITLE = {
2202
2795
  // #193: behold was pointed at a directory that isn't a chant project at all
2203
2796
  // — the first screen must say so, not draw a blank graph.
2204
2797
  "no-project": "This isn't a chant project",
2798
+ // #252: carve mode is served a `chant carve advise --json` report, not a
2799
+ // project — so a bad file is a bad REPORT, and the card must not blame chant
2800
+ // for failing to evaluate a project that was never involved.
2801
+ "carve-report": "This isn't a carve report",
2205
2802
  };
2206
2803
 
2207
2804
  // A precondition failure — the lint gate, a not-installed/no-typegen project,
@@ -2232,6 +2829,18 @@ function renderPreconditionError(body) {
2232
2829
  remedy.textContent = body.remedy;
2233
2830
  card.appendChild(remedy);
2234
2831
  }
2832
+ // #259: the rest of the /api error payload. The card above is the human
2833
+ // reading — code, error, remedy — but the server sends more than those three
2834
+ // (chant's argv, exit code, the endpoint it queried), and until now that
2835
+ // detail was simply dropped on the floor. Collapsed by default so the calm
2836
+ // card stays calm, copyable in one gesture for a bug report.
2837
+ const raw = document.createElement("div");
2838
+ raw.className = "precondition-error-raw";
2839
+ const label = document.createElement("div");
2840
+ label.className = "precondition-error-rawlabel";
2841
+ label.textContent = "response payload";
2842
+ raw.append(label, renderJson(body, { openDepth: -1 }));
2843
+ card.appendChild(raw);
2235
2844
  host.appendChild(card);
2236
2845
  }
2237
2846
 
@@ -2314,7 +2923,15 @@ async function load(opts = {}) {
2314
2923
  } catch (err) {
2315
2924
  // A background settle poll must not blow away a good graph on a transient error.
2316
2925
  if (!opts.quiet) {
2317
- document.getElementById("graph").innerHTML = `<div class="err">graph failed: ${err.message}</div>`;
2926
+ // Text, never innerHTML `err.message` embeds chant's own stderr, which
2927
+ // is not ours to interpolate as markup (the sibling precondition card has
2928
+ // said so since #72; this branch had been left behind).
2929
+ const graph = document.getElementById("graph");
2930
+ graph.innerHTML = "";
2931
+ const box = document.createElement("div");
2932
+ box.className = "err";
2933
+ box.textContent = `graph failed: ${err.message}`;
2934
+ graph.appendChild(box);
2318
2935
  meta.textContent = "error";
2319
2936
  }
2320
2937
  } finally {
@@ -2381,6 +2998,13 @@ async function initPickers() {
2381
2998
  // with none leaves `view.stack` (and the picker + status tag) null.
2382
2999
  stacks = info.stacks || [];
2383
3000
  view.stack = stacks[0] || null;
3001
+ // #254: carve mode declares itself here. The Carve tab is mounted at runtime
3002
+ // (panel.js's addPanelTab), so nothing else grows a dead tab.
3003
+ carveInfo = info.carve || null;
3004
+ if (carveMode()) {
3005
+ renderPanelCarve();
3006
+ loadCarveReport();
3007
+ }
2384
3008
  axes = { tier: info.tier || null, target: info.target || null };
2385
3009
  environments = info.environments || [];
2386
3010
  tiers = info.tiers || [];
@@ -2541,11 +3165,28 @@ function hideLoading() {
2541
3165
  if (o) o.hidden = true;
2542
3166
  }
2543
3167
 
3168
+ // #259: the op stream is chant's own stdout, and an Op that reports as JSON
3169
+ // (a plan summary, a run report, an unrecognized `--progress-json` line that
3170
+ // src/op-runner.ts didn't filter out) used to land here as one unbroken line.
3171
+ // Parse it; if it IS a JSON object or array, show the collapsible tree instead.
3172
+ // Anything else — every ordinary human log line — is untouched text.
3173
+ function asJsonPayload(line) {
3174
+ const s = String(line).trim();
3175
+ if (!(s.startsWith("{") || s.startsWith("["))) return null;
3176
+ try {
3177
+ const v = JSON.parse(s);
3178
+ return isContainer(v) ? v : null;
3179
+ } catch {
3180
+ return null;
3181
+ }
3182
+ }
3183
+
2544
3184
  function nowline(line) {
2545
3185
  const p = document.getElementById("nowline");
2546
3186
  p.style.display = "block";
2547
- const d = document.createElement("div");
2548
- d.textContent = line;
3187
+ const payload = asJsonPayload(line);
3188
+ const d = payload ? renderJson(payload) : document.createElement("div");
3189
+ if (!payload) d.textContent = line;
2549
3190
  p.appendChild(d);
2550
3191
  p.scrollTop = p.scrollHeight;
2551
3192
  }
@@ -2910,6 +3551,22 @@ function paletteCommands() {
2910
3551
  for (const b of document.querySelectorAll("#panel-tabs button[data-tab]")) {
2911
3552
  c.push([`Panel: ${b.textContent}`, () => setPanelTab(b.dataset.tab)]);
2912
3553
  }
3554
+ // #254: the walkthrough's steps get palette twins like every other control.
3555
+ // Blocked steps are listed with their reason rather than hidden — "why can't
3556
+ // I do that yet" is the question the palette should be able to answer.
3557
+ if (carveMode()) {
3558
+ CARVE_STEPS.forEach((s, i) => {
3559
+ const why = blockedReason(carveState, s.id);
3560
+ c.push([
3561
+ `Carve: ${i + 1}. ${s.label}${why ? ` — ${why}` : ""}`,
3562
+ () => {
3563
+ if (why) return showToast(why, false);
3564
+ setPanelTab("carve");
3565
+ carveActions.go(i);
3566
+ },
3567
+ ]);
3568
+ });
3569
+ }
2913
3570
 
2914
3571
  // Lens/zoom switches (#56, #63) — replaces the old header zoom picker.
2915
3572
  for (const [label, v] of ZOOM_OPTS) {