@intentius/behold 0.7.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 (104) hide show
  1. package/AGENTS.md +94 -2
  2. package/README.md +133 -2
  3. package/demos.json +25 -1
  4. package/dist/cli.js +3176 -330
  5. package/example-argo-estate/README.md +71 -0
  6. package/example-argo-estate/app-a/chant.config.ts +18 -0
  7. package/example-argo-estate/app-a/manifests/app.yaml +54 -0
  8. package/example-argo-estate/app-a/package.json +13 -0
  9. package/example-argo-estate/app-a/src/app.ts +35 -0
  10. package/example-argo-estate/app-a/tsconfig.json +1 -0
  11. package/example-argo-estate/app-b/chant.config.ts +18 -0
  12. package/example-argo-estate/app-b/manifests/app.yaml +54 -0
  13. package/example-argo-estate/app-b/package.json +13 -0
  14. package/example-argo-estate/app-b/src/app.ts +34 -0
  15. package/example-argo-estate/app-b/tsconfig.json +1 -0
  16. package/example-argo-estate/control-plane/chant.config.ts +29 -0
  17. package/example-argo-estate/control-plane/package.json +13 -0
  18. package/example-argo-estate/control-plane/src/argo.ts +69 -0
  19. package/example-argo-estate/control-plane/tsconfig.json +1 -0
  20. package/example-argo-estate/package-lock.json +1949 -0
  21. package/example-argo-estate/package.json +10 -0
  22. package/example-carve/README.md +194 -0
  23. package/example-carve/app/chant.config.ts +6 -0
  24. package/example-carve/app/package-lock.json +1075 -0
  25. package/example-carve/app/package.json +13 -0
  26. package/example-carve/app/src/carved.ts +30 -0
  27. package/example-carve/app/tsconfig.json +1 -0
  28. package/example-carve/carve-report.json +872 -0
  29. package/example-carve/legacy-tf/cdn.tf +23 -0
  30. package/example-carve/legacy-tf/compute.tf +63 -0
  31. package/example-carve/legacy-tf/floci-override.tf.disabled +61 -0
  32. package/example-carve/legacy-tf/modules/cdn/main.tf +72 -0
  33. package/example-carve/legacy-tf/naming.tf +10 -0
  34. package/example-carve/legacy-tf/network.tf +119 -0
  35. package/example-carve/legacy-tf/observability.tf +18 -0
  36. package/example-carve/legacy-tf/outputs.tf +16 -0
  37. package/example-carve/legacy-tf/storage.tf +33 -0
  38. package/example-carve/legacy-tf/terraform.tfstate +602 -0
  39. package/example-carve/legacy-tf/versions.tf +40 -0
  40. package/example-flux-estate/README.md +22 -12
  41. package/example-flux-estate/app-a/package.json +2 -2
  42. package/example-flux-estate/app-a/src/app.ts +2 -1
  43. package/example-flux-estate/app-b/chant.config.ts +4 -3
  44. package/example-flux-estate/app-b/package.json +2 -2
  45. package/example-flux-estate/app-b/src/app.ts +5 -3
  46. package/example-flux-estate/control-plane/package.json +2 -2
  47. package/example-flux-estate/control-plane/src/flux.ts +10 -2
  48. package/example-flux-estate/package-lock.json +17 -17
  49. package/example-k8s/README.md +16 -2
  50. package/example-k8s/base/deployment.yaml +21 -0
  51. package/example-k8s/base/kustomization.yaml +3 -0
  52. package/example-k8s/base/service.yaml +12 -0
  53. package/example-k8s/chant.config.ts +10 -0
  54. package/example-k8s/overlays/dev/kustomization.yaml +6 -0
  55. package/example-k8s/package-lock.json +18 -18
  56. package/example-k8s/package.json +3 -3
  57. package/example-writes/package-lock.json +14 -14
  58. package/example-writes/package.json +3 -3
  59. package/package.json +9 -6
  60. package/web/app.js +1027 -42
  61. package/web/carve-steps.js +610 -0
  62. package/web/carve-steps.test.js +233 -0
  63. package/web/demos.js +71 -0
  64. package/web/demos.test.js +83 -0
  65. package/web/icons/cncf/argo.svg +1 -0
  66. package/web/icons/cncf/flux.svg +1 -0
  67. package/web/icons/cncf/helm.svg +1 -0
  68. package/web/icons/k8s/c-role.svg +1 -0
  69. package/web/icons/k8s/cm.svg +1 -0
  70. package/web/icons/k8s/crb.svg +1 -0
  71. package/web/icons/k8s/crd.svg +1 -0
  72. package/web/icons/k8s/cronjob.svg +1 -0
  73. package/web/icons/k8s/deploy.svg +1 -0
  74. package/web/icons/k8s/ds.svg +1 -0
  75. package/web/icons/k8s/ep.svg +1 -0
  76. package/web/icons/k8s/group.svg +1 -0
  77. package/web/icons/k8s/hpa.svg +1 -0
  78. package/web/icons/k8s/ing.svg +1 -0
  79. package/web/icons/k8s/job.svg +1 -0
  80. package/web/icons/k8s/limits.svg +1 -0
  81. package/web/icons/k8s/netpol.svg +1 -0
  82. package/web/icons/k8s/ns.svg +1 -0
  83. package/web/icons/k8s/pod.svg +1 -0
  84. package/web/icons/k8s/psp.svg +1 -0
  85. package/web/icons/k8s/pv.svg +1 -0
  86. package/web/icons/k8s/pvc.svg +1 -0
  87. package/web/icons/k8s/quota.svg +1 -0
  88. package/web/icons/k8s/rb.svg +1 -0
  89. package/web/icons/k8s/role.svg +1 -0
  90. package/web/icons/k8s/rs.svg +1 -0
  91. package/web/icons/k8s/sa.svg +1 -0
  92. package/web/icons/k8s/sc.svg +1 -0
  93. package/web/icons/k8s/secret.svg +1 -0
  94. package/web/icons/k8s/sts.svg +1 -0
  95. package/web/icons/k8s/svc.svg +1 -0
  96. package/web/icons/k8s/user.svg +1 -0
  97. package/web/icons/k8s/vol.svg +1 -0
  98. package/web/index.html +285 -85
  99. package/web/json-view.js +334 -0
  100. package/web/json-view.test.js +218 -0
  101. package/web/layout-store.js +278 -0
  102. package/web/layout-store.test.js +388 -0
  103. package/web/panel.js +28 -0
  104. package/web/theme.js +114 -6
package/web/app.js CHANGED
@@ -9,7 +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";
17
+ // #228: the hand-layout delta store — everything about WHAT gets remembered and
18
+ // under which key. The pointer work and the SVG surgery stay here (see the
19
+ // "Hand layout" section below).
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";
13
46
  initTheme();
14
47
  initPanel();
15
48
  mountThemePicker(document.getElementById("panel-theme"));
@@ -63,6 +96,39 @@ function recolorNodesByCategory(ir) {
63
96
  }
64
97
  onThemeChange(() => recolorNodesByCategory());
65
98
 
99
+ // #229, the one motion signature behold spends on data: a node whose drift
100
+ // `_status` CHANGED between two renders pulses once in the colour it just
101
+ // became. Node ids are stable across renders, so the diff is a single map
102
+ // compare — no extra fetch, no per-node bookkeeping. The first render seeds the
103
+ // map and pulses nothing (everything would "change"), and a lens switch that
104
+ // swaps the id set pulses nothing either, since only ids present in BOTH
105
+ // renders can have changed. The CSS keyframes + the reduced-motion guard live
106
+ // in index.html.
107
+ let lastStatusById = new Map();
108
+ function markStatusChanges(ir, statusVar) {
109
+ const prev = lastStatusById;
110
+ const next = new Map();
111
+ for (const n of ir.nodes) {
112
+ const s = n.attrs && n.attrs._status;
113
+ if (s) next.set(n.id, s);
114
+ }
115
+ lastStatusById = next;
116
+ if (!prev.size) return;
117
+ const svg = document.querySelector("#graph svg");
118
+ if (!svg) return;
119
+ for (const g of svg.querySelectorAll("[data-node-id]")) {
120
+ const id = g.getAttribute("data-node-id");
121
+ const now = next.get(id);
122
+ if (!now || prev.get(id) === undefined || prev.get(id) === now) continue;
123
+ // The pulse wears the NEW status's hue, read through whichever vocabulary
124
+ // this graph is painted in — a node going foreign glows yellow in the drift
125
+ // overlay; a component that rolled back glows red in the component view,
126
+ // where the same `warn` means something else entirely.
127
+ g.style.setProperty("--pulse", statusVar[now] || "var(--pending)");
128
+ g.classList.add("status-changed");
129
+ }
130
+ }
131
+
66
132
  // Static-export mode (`behold export`): the SPA runs off a pre-captured bundle
67
133
  // with no backend. Detect the flag the export injects, load its manifest, and
68
134
  // replay every read from `snapshots/` — the graph, zoom dial, radial, inspect,
@@ -126,12 +192,68 @@ const ARTIFACT_STATUS_LABEL = { good: "installed", warn: "installed, not healthy
126
192
  // A declared attribute value may be a cross-resource reference ({$ref:"x.y"}) —
127
193
  // the "static infra refs" — rather than a concrete value. Render those readably;
128
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.
129
198
  function fmtValue(v) {
130
- if (v && typeof v === "object") {
131
- if (typeof v.$ref === "string") return "→ " + v.$ref;
132
- 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;
133
236
  }
134
- 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;
135
257
  }
136
258
 
137
259
  function inspect(node) {
@@ -141,7 +263,6 @@ function inspect(node) {
141
263
  const section = (title) => {
142
264
  const h = document.createElement("h3");
143
265
  h.textContent = title;
144
- h.style.cssText = "font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);margin:14px 0 6px";
145
266
  panel.appendChild(h);
146
267
  const dl = document.createElement("dl");
147
268
  panel.appendChild(dl);
@@ -149,7 +270,7 @@ function inspect(node) {
149
270
  const dt = document.createElement("dt");
150
271
  dt.textContent = k;
151
272
  const dd = document.createElement("dd");
152
- dd.textContent = v;
273
+ setCell(dd, v);
153
274
  dl.append(dt, dd);
154
275
  };
155
276
  };
@@ -202,7 +323,6 @@ function inspect(node) {
202
323
  if (isComposite) {
203
324
  const h = document.createElement("h3");
204
325
  h.textContent = `members · ${node.attrs.members}`;
205
- h.style.cssText = "font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);margin:14px 0 6px";
206
326
  panel.appendChild(h);
207
327
  const loading = document.createElement("p");
208
328
  loading.style.color = "var(--muted)";
@@ -354,7 +474,7 @@ function inspect(node) {
354
474
  panel.appendChild(p);
355
475
  return;
356
476
  }
357
- renderObserved(panel, j.observed, j.health); // #30 observed state + #26 health
477
+ renderObserved(panel, j.observed, j.health, j.healthDetail); // #30 observed state + #26/#226 health
358
478
  renderDiff(panel, j.diff); // #27 — drift since snapshot
359
479
  renderFieldDrift(panel, j.fieldDrift); // #87 — field-level (per-manager) drift
360
480
  });
@@ -388,24 +508,30 @@ const HEALTH_COLOR = {
388
508
  };
389
509
 
390
510
  // Render a node's observed live state (#30) + health verdict (#26).
391
- function renderObserved(panel, o, health) {
511
+ function renderObserved(panel, o, health, healthDetail) {
392
512
  if (!o) return; // pending/foreign nodes have no observed record in the diff
393
513
  const h = document.createElement("h3");
394
514
  h.textContent = "observed";
395
- h.style.cssText = "font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);margin:14px 0 6px";
396
515
  panel.appendChild(h);
397
516
  const dl = document.createElement("dl");
398
517
  const add = (k, v, color) => {
399
518
  const dt = document.createElement("dt");
400
519
  dt.textContent = k;
401
520
  const dd = document.createElement("dd");
402
- dd.textContent = v;
521
+ setCell(dd, v);
403
522
  if (color) dd.style.color = color;
404
523
  dl.append(dt, dd);
405
524
  };
406
525
  // Health first — the "is it well?" verdict, distinct from drift. Absent when
407
526
  // the substrate reports no status (not fabricated).
408
- if (health && health !== "unknown") add("health", health, HEALTH_COLOR[health]);
527
+ // #226: for an Argo Application or a Flux object the verdict comes from the
528
+ // controller's own conditions, and `healthDetail` is the sentence it read
529
+ // there — `Ready=False (BuildFailed)`, `health=Degraded, sync=OutOfSync`.
530
+ // Appended to the verdict rather than given its own row, because it is the
531
+ // same claim said precisely.
532
+ if (health && health !== "unknown") {
533
+ add("health", healthDetail ? `${health} — ${healthDetail}` : health, HEALTH_COLOR[health]);
534
+ }
409
535
  if (o.type) add("type", o.type);
410
536
  if (o.status) add("status", o.status, HEALTH_COLOR[health] || undefined);
411
537
  if (o.physicalId) add("physical id", o.physicalId);
@@ -420,11 +546,16 @@ function renderObserved(panel, o, health) {
420
546
  // none.
421
547
  const conditions = o.attributes?.conditions;
422
548
  if (Array.isArray(conditions)) {
423
- 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)");
424
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.
425
556
  for (const [k, v] of Object.entries(o.attributes || {})) {
426
557
  if (k === "conditions") continue; // rendered above, one line each
427
- add(k, typeof v === "object" ? JSON.stringify(v) : String(v));
558
+ add(k, valueCell(v));
428
559
  }
429
560
  panel.appendChild(dl);
430
561
  }
@@ -448,7 +579,6 @@ const DIFF_LABEL = {
448
579
  function renderDiff(panel, diff) {
449
580
  const h = document.createElement("h3");
450
581
  h.textContent = "drift";
451
- h.style.cssText = "font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);margin:14px 0 6px";
452
582
  panel.appendChild(h);
453
583
  if (!diff) {
454
584
  const p = document.createElement("p");
@@ -466,7 +596,7 @@ function renderDiff(panel, diff) {
466
596
  // this address makes that visible). Monospace so a request path scans.
467
597
  if (diff.queried) {
468
598
  const q = document.createElement("p");
469
- q.style.cssText = "color:var(--muted);font:11px/1.4 ui-monospace,monospace;overflow-wrap:anywhere;margin-top:2px";
599
+ q.className = "queried"; // mono + muted, styled with the rest of the pane's scale
470
600
  q.textContent = `queried: ${diff.queried} → ${diff.category === "missing" ? "not found" : "read failed"}`;
471
601
  panel.appendChild(q);
472
602
  }
@@ -509,7 +639,9 @@ function renderDiff(panel, diff) {
509
639
  const dt = document.createElement("dt");
510
640
  dt.textContent = ch.path;
511
641
  const dd = document.createElement("dd");
512
- 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 }));
513
645
  dl.append(dt, dd);
514
646
  }
515
647
  panel.appendChild(dl);
@@ -539,7 +671,6 @@ function renderFieldDrift(panel, fieldDrift) {
539
671
  if (!fieldDrift.drifted.length && !fieldDrift.accepted.length) return; // deep ran, nothing to report for this node
540
672
  const h = document.createElement("h3");
541
673
  h.textContent = "field ownership";
542
- h.style.cssText = "font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);margin:14px 0 6px";
543
674
  panel.appendChild(h);
544
675
  const dl = document.createElement("dl");
545
676
  for (const ch of fieldDrift.drifted) {
@@ -553,7 +684,7 @@ function renderFieldDrift(panel, fieldDrift) {
553
684
  // holding it is somebody editing around the pipeline. Both are `changed`.
554
685
  // Absent on every substrate but k8s, where the line reads as it always did.
555
686
  const owned = ch.owner ? `owned by ${ch.owner} — ` : "";
556
- 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}` }));
557
688
  dl.append(dt, dd);
558
689
  }
559
690
  for (const ch of fieldDrift.accepted) {
@@ -561,7 +692,7 @@ function renderFieldDrift(panel, fieldDrift) {
561
692
  dt.textContent = ch.path;
562
693
  dt.style.color = "var(--muted)";
563
694
  const dd = document.createElement("dd");
564
- 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" }));
565
696
  dl.append(dt, dd);
566
697
  }
567
698
  panel.appendChild(dl);
@@ -579,6 +710,10 @@ function wire(ir) {
579
710
  host.querySelectorAll(".sel").forEach((n) => n.classList.remove("sel"));
580
711
  g.classList.add("sel");
581
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);
582
717
  });
583
718
  }
584
719
  }
@@ -734,7 +869,7 @@ function renderPanelView() {
734
869
  lanes.href = "/lanes";
735
870
  lanes.textContent = "lanes →";
736
871
  lanes.title = "The time-lanes view — captured frames of this graph over time";
737
- lanes.style.cssText = "color:var(--pending);text-decoration:none;font-size:12px";
872
+ lanes.style.cssText = "color:var(--pending);text-decoration:none;font-size:var(--t-body)";
738
873
  row.appendChild(lanes);
739
874
  tools.appendChild(row);
740
875
  }
@@ -768,26 +903,54 @@ async function revealProject(dir) {
768
903
  // client-side list and cache is project-scoped, so a clean boot is the honest
769
904
  // way to re-seed all of it.
770
905
  async function switchProject(dir) {
771
- 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);
772
921
  try {
773
- const r = await fetch("/api/project/open", {
922
+ const r = await fetch(url, {
774
923
  method: "POST",
775
924
  headers: { "content-type": "application/json" },
776
- body: JSON.stringify({ dir }),
925
+ body: JSON.stringify(body),
777
926
  });
778
927
  const j = await r.json();
779
928
  if (!r.ok || j.error) {
780
929
  hideLoading();
781
- showToast("✗ switch: " + (j.error || r.statusText), false);
930
+ showToast(`✗ ${what}: ` + (j.error || r.statusText), false);
782
931
  return;
783
932
  }
784
933
  location.reload();
785
934
  } catch (e) {
786
935
  hideLoading();
787
- showToast("✗ switch: " + e.message, false);
936
+ showToast(`✗ ${what}: ` + e.message, false);
788
937
  }
789
938
  }
790
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
+
791
954
  function renderPanelScope() {
792
955
  const host = document.getElementById("tab-scope");
793
956
  if (!host) return;
@@ -837,6 +1000,19 @@ function renderPanelScope() {
837
1000
  });
838
1001
  row.append(input, go);
839
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
+ }
840
1016
  }
841
1017
  host.appendChild(panelHeading("environment"));
842
1018
  host.appendChild(
@@ -940,6 +1116,7 @@ function selectNode(id) {
940
1116
  const g = host.querySelector(`[data-node-id="${CSS.escape(id)}"]`);
941
1117
  if (g) g.classList.add("sel");
942
1118
  inspect(node);
1119
+ carvePick(node);
943
1120
  }
944
1121
 
945
1122
  function renderPanelModel() {
@@ -1005,10 +1182,199 @@ function renderPanelModel() {
1005
1182
  }
1006
1183
  }
1007
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
+
1008
1373
  function renderPanel() {
1009
1374
  renderPanelView();
1010
1375
  renderPanelScope();
1011
1376
  renderPanelModel();
1377
+ if (carveMode()) renderPanelCarve();
1012
1378
  }
1013
1379
 
1014
1380
  function renderStatusbar() {
@@ -1210,7 +1576,7 @@ function renderDial() {
1210
1576
  }
1211
1577
  host.style.display = "flex";
1212
1578
  const hint = document.createElement("span");
1213
- hint.style.cssText = "font-size:11px;color:var(--muted);align-self:center";
1579
+ hint.style.cssText = "font-size:var(--t-caption);color:var(--muted);align-self:center";
1214
1580
  hint.textContent = "observe → reconcile → apply needs an environment — pick one in ⌘K (env: …)";
1215
1581
  host.appendChild(hint);
1216
1582
  // A pipeline run (#163) is env-less — its progress still belongs here.
@@ -1283,7 +1649,7 @@ function renderApplyPicker() {
1283
1649
  wrap.style.gap = "6px";
1284
1650
  const sel = document.createElement("select");
1285
1651
  sel.style.cssText =
1286
- "background:var(--panel);color:var(--fg);border:1px solid var(--line);border-radius:6px;padding:3px 8px;font-size:12px";
1652
+ "background:var(--well);color:var(--fg);border:1px solid var(--line);border-radius:var(--r-ctl);padding:3px 8px;font-size:var(--t-body)";
1287
1653
  sel.add(new Option("all components", "all"));
1288
1654
  for (const name of componentChoices) sel.add(new Option(applyOptionLabel(name), name));
1289
1655
  const go = button("Apply →", "", () => {
@@ -1405,7 +1771,7 @@ function renderApplyProgress(state) {
1405
1771
  const wrap = document.createElement("div");
1406
1772
  wrap.style.cssText = "display:flex;flex-direction:column;gap:6px;width:100%;margin-top:4px";
1407
1773
  const summary = document.createElement("div");
1408
- summary.style.cssText = `font-size:11px;color:${APPLY_STATUS_COLOR[state.status] || "var(--muted)"}`;
1774
+ summary.style.cssText = `font-size:var(--t-caption);color:${APPLY_STATUS_COLOR[state.status] || "var(--muted)"}`;
1409
1775
  // A pipeline run (#163) reuses this whole panel — same shape, different
1410
1776
  // executor — and says so instead of claiming to be an apply.
1411
1777
  summary.textContent = `${state.kind || "apply"}: ${state.status}`;
@@ -1414,14 +1780,14 @@ function renderApplyProgress(state) {
1414
1780
  const row = document.createElement("div");
1415
1781
  row.style.cssText = "display:flex;align-items:center;gap:8px;flex-wrap:wrap";
1416
1782
  const label = document.createElement("span");
1417
- label.style.cssText = `font-size:11px;color:${APPLY_STATUS_COLOR[w.status] || "var(--muted)"};min-width:52px`;
1783
+ label.style.cssText = `font-size:var(--t-caption);color:${APPLY_STATUS_COLOR[w.status] || "var(--muted)"};min-width:52px`;
1418
1784
  label.textContent = `wave ${w.wave}`;
1419
1785
  row.appendChild(label);
1420
1786
  for (const cname of w.components) {
1421
1787
  const c = (state.components || []).find((x) => x.component === cname) || { status: "pending" };
1422
1788
  const color = APPLY_STATUS_COLOR[c.status] || "var(--muted)";
1423
1789
  const chip = document.createElement("span");
1424
- chip.style.cssText = `border:1px solid ${color};color:${color};border-radius:6px;padding:2px 8px;font-size:11px`;
1790
+ chip.style.cssText = `border:1px solid ${color};color:${color};border-radius:var(--r-ctl);padding:2px 8px;font-size:var(--t-caption)`;
1425
1791
  const detail = [c.phase, c.step].filter(Boolean).join(" · ");
1426
1792
  chip.textContent = `${cname}${detail ? " · " + detail : ""} (${c.status})`;
1427
1793
  if (c.error) chip.title = c.error;
@@ -1657,6 +2023,7 @@ function render(ir, svg, m) {
1657
2023
  // green — the SVG's own :root shadows behold's override within the graph subtree.
1658
2024
  g.innerHTML = svg.replace(/:root\s*\{[^{}]*--pin-[^{}]*\}/g, "");
1659
2025
  recolorNodesByCategory(ir); // #62: category-hued fills from the theme's full palette
2026
+ markStatusChanges(ir, componentStatus ? COMPONENT_STATUS_VAR : DRIFT_STATUS_VAR); // #229
1660
2027
  const svgEl = g.querySelector("svg");
1661
2028
  if (svgEl) {
1662
2029
  // Drop pinhole's fixed pixel size so the viewBox drives sizing; behold then
@@ -1676,6 +2043,8 @@ function render(ir, svg, m) {
1676
2043
  ensureBackToInfra(g);
1677
2044
  wire(ir);
1678
2045
  if (view.radial && !view.components && !view.logical) addRadialLabels(ir);
2046
+ markCarvedCards(); // #254: the SVG is replaced per render — re-stamp the marker
2047
+ applyLayout(); // #228: last, so the hand-placed deltas ride on top of every other pass
1679
2048
  renderDial();
1680
2049
  }
1681
2050
 
@@ -1833,6 +2202,11 @@ function ensureZoomControls(host) {
1833
2202
  let py = 0;
1834
2203
  host.addEventListener("mousedown", (e) => {
1835
2204
  if (!vb) return;
2205
+ // #228: pan is the default on empty ground, but a node card (or a box's
2206
+ // title / resize handle) belongs to the layout drag — starting a pan too
2207
+ // would move the graph out from under the thing you grabbed. The layout
2208
+ // drag owns the panMoved latch for the rest of the gesture.
2209
+ if (layoutTargetOf(e)) return;
1836
2210
  drag = true;
1837
2211
  panMoved = false;
1838
2212
  px = e.clientX;
@@ -1862,6 +2236,553 @@ function ensureZoomControls(host) {
1862
2236
  });
1863
2237
  }
1864
2238
 
2239
+ // --- Hand layout (#228): drag a node, resize a containment box -------------
2240
+ // dagre is a good first draft and a bad final layout. A pointer-drag on a card
2241
+ // writes a {dx,dy} for that `data-node-id`; a corner handle on a containment
2242
+ // box writes {dw,dh} (and its title drags the box). The deltas live in
2243
+ // localStorage per project + lens (web/layout-store.js) and are re-applied at
2244
+ // the end of every render(), so the graph underneath stays chant's and a
2245
+ // delta for a node that left the estate is simply not applied.
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
+ //
2255
+ // What this does NOT do, said plainly rather than faked:
2256
+ // * a resized box does not reflow its children — that is dagre's job on the
2257
+ // next layout, and the reset control's tooltip says so;
2258
+ // * an edge touching a displaced node is redrawn as a STRAIGHT line between
2259
+ // its original anchor points, each shifted by its own node's delta. #228
2260
+ // accepts the straight-line fallback; spline re-routing is pinhole's job.
2261
+ // An edge with both ends where dagre put them keeps its bezier untouched.
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).
2272
+ let layoutIndex = null; // {nodes,boxes,edges} for the SVG currently on screen
2273
+ let layoutDeltas = {}; // the deltas in force for the current key
2274
+ let layoutDrag = null; // the gesture in flight
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 };
2279
+
2280
+ /** The storage key for the project + lens on screen; null before /api/project lands. */
2281
+ function currentLayoutKey() {
2282
+ if (!projectInfo) return null;
2283
+ return layoutKey(projectKeyOf(projectInfo), currentLensKey());
2284
+ }
2285
+
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 });
2290
+ }
2291
+
2292
+ /**
2293
+ * Wrap each containment box in a `<g data-layout-box>` and give it a corner
2294
+ * handle, once per rendered SVG.
2295
+ *
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`.
2324
+ */
2325
+ function wrapContainmentBoxes(svgEl) {
2326
+ const boxes = new Map();
2327
+ const handleSize = Math.max(12, Math.round((vbInit ? vbInit[2] : 1000) / 90));
2328
+ const found = [];
2329
+ const claimed = new Set();
2330
+ for (const rect of [...svgEl.children]) {
2331
+ if (rect.tagName.toLowerCase() !== "rect") continue;
2332
+ const groupId = (rect.getAttribute("data-group-id") || "").trim();
2333
+ const x = parseFloat(rect.getAttribute("x"));
2334
+ const y = parseFloat(rect.getAttribute("y"));
2335
+ const w = parseFloat(rect.getAttribute("width"));
2336
+ const h = parseFloat(rect.getAttribute("height"));
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;
2342
+ // Collect rect → title, stepping over anything already stamped between
2343
+ // them (the GitLab wave badge), and bail at the next box or card.
2344
+ const members = [rect];
2345
+ let title = null;
2346
+ for (let el = rect.nextElementSibling; el; el = el.nextElementSibling) {
2347
+ const tag = el.tagName.toLowerCase();
2348
+ if (tag === "rect" || (tag === "g" && (el.hasAttribute("data-node-id") || el.hasAttribute("data-edge-from")))) break;
2349
+ members.push(el);
2350
+ if (tag === "text") {
2351
+ title = el;
2352
+ break;
2353
+ }
2354
+ }
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) {
2367
+ const g = document.createElementNS(SVGNS, "g");
2368
+ g.setAttribute("data-layout-box", id);
2369
+ rect.parentNode.insertBefore(g, rect);
2370
+ members.forEach((m) => g.appendChild(m));
2371
+ // The title doubles as the box's move handle — the interior stays pan
2372
+ // territory, which on a logical view is most of the canvas.
2373
+ if (title) {
2374
+ title.setAttribute("data-layout-move", id);
2375
+ title.setAttribute("cursor", "move");
2376
+ }
2377
+ const handle = document.createElementNS(SVGNS, "g");
2378
+ handle.setAttribute("data-layout-resize", id);
2379
+ handle.setAttribute("cursor", "nwse-resize");
2380
+ handle.setAttribute("opacity", "0"); // CSS reveals it on hover; a static export never shows it
2381
+ const grip = document.createElementNS(SVGNS, "rect");
2382
+ grip.setAttribute("width", String(handleSize));
2383
+ grip.setAttribute("height", String(handleSize));
2384
+ grip.setAttribute("rx", "3");
2385
+ grip.setAttribute("fill", "var(--focus)");
2386
+ const tip = document.createElementNS(SVGNS, "title");
2387
+ tip.textContent = "Drag to resize this box. Children don't move with it — that's the next layout's job.";
2388
+ handle.append(tip, grip);
2389
+ g.appendChild(handle);
2390
+ boxes.set(id, { g, rect, handle, x, y, w0: w, h0: h, size: handleSize });
2391
+ }
2392
+ return boxes;
2393
+ }
2394
+
2395
+ /** Park the corner handle at the box's current bottom-right. */
2396
+ function positionBoxHandle(b, w, h) {
2397
+ b.handle.setAttribute("transform", `translate(${b.x + w - b.size - 3}, ${b.y + h - b.size - 3})`);
2398
+ }
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
+
2476
+ /** Index the freshly rendered SVG: node groups, containment boxes, edge paths. */
2477
+ function indexLayout(svgEl) {
2478
+ const toUser = userSpaceMapper(svgEl);
2479
+ const nodes = new Map();
2480
+ for (const el of svgEl.querySelectorAll("[data-node-id]")) {
2481
+ const id = el.getAttribute("data-node-id");
2482
+ if (!nodes.has(id)) nodes.set(id, { el, base: el.getAttribute("transform") || "", rect: userRect(el, toUser) });
2483
+ }
2484
+ const edges = [];
2485
+ for (const g of svgEl.querySelectorAll("g[data-edge-from]")) {
2486
+ const paths = [...g.querySelectorAll("path")];
2487
+ if (!paths.length) continue;
2488
+ const d0 = paths[0].getAttribute("d");
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
+ });
2499
+ }
2500
+ layoutIndex = { nodes, boxes: wrapContainmentBoxes(svgEl), edges };
2501
+ }
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
+
2556
+ /** Paint `layoutDeltas` onto the indexed SVG. Idempotent: always from the original. */
2557
+ function renderLayout() {
2558
+ if (!layoutIndex) return;
2559
+ for (const [id, n] of layoutIndex.nodes) {
2560
+ const t = nodeTransform(n.base, layoutDeltas[id] || {});
2561
+ if (t) n.el.setAttribute("transform", t);
2562
+ else n.el.removeAttribute("transform");
2563
+ }
2564
+ for (const [id, b] of layoutIndex.boxes) {
2565
+ const d = layoutDeltas[id] || {};
2566
+ const { w, h } = boxRect(b, d);
2567
+ b.rect.setAttribute("width", String(w));
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).
2572
+ if (d.dx || d.dy) b.g.setAttribute("transform", `translate(${d.dx || 0}, ${d.dy || 0})`);
2573
+ else b.g.removeAttribute("transform");
2574
+ positionBoxHandle(b, w, h);
2575
+ }
2576
+ for (const e of layoutIndex.edges) {
2577
+ const a = layoutDeltas[e.from];
2578
+ const z = layoutDeltas[e.to];
2579
+ if ((!a && !z) || !e.anchors) {
2580
+ if (e.paths[0].getAttribute("d") !== e.d0) e.paths.forEach((p) => p.setAttribute("d", e.d0));
2581
+ placeEdgeLabel(e, 0, 0);
2582
+ continue;
2583
+ }
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.
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);
2594
+ }
2595
+ }
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
+
2607
+ /** Re-index the SVG, load this lens's deltas, paint them. Called from render(). */
2608
+ function applyLayout() {
2609
+ const svgEl = currentSvg();
2610
+ const host = document.getElementById("graph");
2611
+ if (!svgEl || !host) return;
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;
2626
+ const key = currentLayoutKey();
2627
+ const lens = currentLensKey();
2628
+ // Stale ids are dropped on apply, not on write: a lens the user hasn't
2629
+ // opened in a while shouldn't have its deltas quietly deleted because this
2630
+ // render happened to be a different projection of the same estate.
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);
2634
+ renderLayout();
2635
+ }
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
+
2663
+ /** The grabbable thing at or above `el`, if any. */
2664
+ function layoutTargetIn(el) {
2665
+ if (!el || typeof el.closest !== "function") return null;
2666
+ const resize = el.closest("[data-layout-resize]");
2667
+ if (resize) return { kind: "resize", id: resize.getAttribute("data-layout-resize") };
2668
+ const move = el.closest("[data-layout-move]");
2669
+ if (move) return { kind: "move", id: move.getAttribute("data-layout-move") };
2670
+ const node = el.closest("[data-node-id]");
2671
+ if (node) return { kind: "move", id: node.getAttribute("data-node-id") };
2672
+ return null;
2673
+ }
2674
+
2675
+ /** What (if anything) a pointerdown grabbed. Also the pan handler's bail test. */
2676
+ function layoutTargetOf(e) {
2677
+ const direct = layoutTargetIn(e.target);
2678
+ if (direct) return direct;
2679
+ // An edge can swallow the grab: wireEdgeHighlight raises every edge on a
2680
+ // hovered card to the top of the SVG, and pinhole's 14-wide transparent
2681
+ // hit-path is anchored at the card's own centre — so by the time the pointer
2682
+ // is down, the edge you just lit is lying across the card you meant to move.
2683
+ // Look through the stack for the card underneath instead of giving up.
2684
+ if (!e.target || typeof e.target.closest !== "function" || !e.target.closest("g[data-edge-from]")) return null;
2685
+ for (const el of document.elementsFromPoint(e.clientX, e.clientY)) {
2686
+ const t = layoutTargetIn(el);
2687
+ if (t) return t;
2688
+ }
2689
+ return null;
2690
+ }
2691
+
2692
+ /** Screen pixels → viewBox units at the current zoom. */
2693
+ function userPerPixel(svgEl) {
2694
+ const m = svgEl.getScreenCTM && svgEl.getScreenCTM();
2695
+ if (m && m.a && m.d) return { x: 1 / m.a, y: 1 / m.d };
2696
+ const r = svgEl.getBoundingClientRect();
2697
+ return vb && r.width && r.height ? { x: vb[2] / r.width, y: vb[3] / r.height } : { x: 1, y: 1 };
2698
+ }
2699
+
2700
+ function ensureLayoutDrag(host) {
2701
+ if (layoutWired) return;
2702
+ layoutWired = true;
2703
+ host.addEventListener("pointerdown", (e) => {
2704
+ if (e.button !== 0 || !layoutIndex) return;
2705
+ const t = layoutTargetOf(e);
2706
+ if (!t || !currentSvg()) return;
2707
+ layoutDrag = { ...t, x0: e.clientX, y0: e.clientY, base: layoutDeltas[t.id] || {}, moved: false };
2708
+ // The same latch pan uses (wire()'s click-inspect bails on it): claim it on
2709
+ // grab so a drag is never also a click, and so the NEXT plain click on a
2710
+ // node isn't swallowed by a stale `true` from the gesture before it.
2711
+ panMoved = false;
2712
+ // Deliberately no preventDefault(): cancelling pointerdown suppresses the
2713
+ // compatibility mouse events, and click-inspect rides on those. The pan is
2714
+ // held off by its own guard instead, and the text selection a drag would
2715
+ // otherwise smear across the graph by `user-select: none` on the SVG.
2716
+ });
2717
+ window.addEventListener("pointermove", (e) => {
2718
+ if (!layoutDrag) return;
2719
+ const svgEl = currentSvg();
2720
+ if (!svgEl) return;
2721
+ const px = e.clientX - layoutDrag.x0;
2722
+ const py = e.clientY - layoutDrag.y0;
2723
+ if (!layoutDrag.moved && Math.abs(px) + Math.abs(py) <= 3) return;
2724
+ layoutDrag.moved = true;
2725
+ panMoved = true;
2726
+ const s = userPerPixel(svgEl);
2727
+ const dx = px * s.x;
2728
+ const dy = py * s.y;
2729
+ const b = layoutDrag.base;
2730
+ const next =
2731
+ layoutDrag.kind === "resize"
2732
+ ? { dx: b.dx || 0, dy: b.dy || 0, dw: (b.dw || 0) + dx, dh: (b.dh || 0) + dy }
2733
+ : { dx: (b.dx || 0) + dx, dy: (b.dy || 0) + dy, dw: b.dw || 0, dh: b.dh || 0 };
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));
2739
+ renderLayout();
2740
+ });
2741
+ window.addEventListener("pointerup", () => {
2742
+ if (!layoutDrag) return;
2743
+ const moved = layoutDrag.moved;
2744
+ layoutDrag = null;
2745
+ if (!moved) return;
2746
+ const key = currentLayoutKey();
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);
2752
+ ensureLayoutReset(document.getElementById("graph"));
2753
+ });
2754
+ }
2755
+
2756
+ /** The "↺ layout" control beside ⤢ fit — shown only while something is hand-placed. */
2757
+ function ensureLayoutReset(host) {
2758
+ if (!host) return;
2759
+ let btn = document.getElementById("layout-reset");
2760
+ if (!btn || btn.parentElement !== host) {
2761
+ btn = document.createElement("button");
2762
+ btn.id = "layout-reset";
2763
+ btn.textContent = "↺ layout";
2764
+ btn.title =
2765
+ "Drop the hand-placed layout for this project + this lens and go back to dagre's. " +
2766
+ "(A resized box never reflows what's inside it — only the next layout does that.)";
2767
+ btn.addEventListener("click", (e) => {
2768
+ e.stopPropagation();
2769
+ const key = currentLayoutKey();
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, {});
2776
+ layoutDeltas = {};
2777
+ renderLayout();
2778
+ ensureLayoutReset(host);
2779
+ nowline("↺ layout reset — back to dagre's placement");
2780
+ });
2781
+ host.appendChild(btn);
2782
+ }
2783
+ btn.style.display = isEmpty(layoutDeltas) ? "none" : "";
2784
+ }
2785
+
1865
2786
  // Precondition-failure codes (#72) → a short, human title for the entry/error
1866
2787
  // screen below. Mirrors every `code` a read route's structured error can
1867
2788
  // carry (src/server.ts RouteErrorCode: chant.ts's lint/not-installed/eval,
@@ -1874,6 +2795,10 @@ const PRECONDITION_TITLE = {
1874
2795
  // #193: behold was pointed at a directory that isn't a chant project at all
1875
2796
  // — the first screen must say so, not draw a blank graph.
1876
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",
1877
2802
  };
1878
2803
 
1879
2804
  // A precondition failure — the lint gate, a not-installed/no-typegen project,
@@ -1904,6 +2829,18 @@ function renderPreconditionError(body) {
1904
2829
  remedy.textContent = body.remedy;
1905
2830
  card.appendChild(remedy);
1906
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);
1907
2844
  host.appendChild(card);
1908
2845
  }
1909
2846
 
@@ -1986,7 +2923,15 @@ async function load(opts = {}) {
1986
2923
  } catch (err) {
1987
2924
  // A background settle poll must not blow away a good graph on a transient error.
1988
2925
  if (!opts.quiet) {
1989
- 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);
1990
2935
  meta.textContent = "error";
1991
2936
  }
1992
2937
  } finally {
@@ -2053,6 +2998,13 @@ async function initPickers() {
2053
2998
  // with none leaves `view.stack` (and the picker + status tag) null.
2054
2999
  stacks = info.stacks || [];
2055
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
+ }
2056
3008
  axes = { tier: info.tier || null, target: info.target || null };
2057
3009
  environments = info.environments || [];
2058
3010
  tiers = info.tiers || [];
@@ -2213,11 +3165,28 @@ function hideLoading() {
2213
3165
  if (o) o.hidden = true;
2214
3166
  }
2215
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
+
2216
3184
  function nowline(line) {
2217
3185
  const p = document.getElementById("nowline");
2218
3186
  p.style.display = "block";
2219
- const d = document.createElement("div");
2220
- d.textContent = line;
3187
+ const payload = asJsonPayload(line);
3188
+ const d = payload ? renderJson(payload) : document.createElement("div");
3189
+ if (!payload) d.textContent = line;
2221
3190
  p.appendChild(d);
2222
3191
  p.scrollTop = p.scrollHeight;
2223
3192
  }
@@ -2238,7 +3207,7 @@ function showToast(msg, ok) {
2238
3207
  }
2239
3208
  const t = document.createElement("div");
2240
3209
  const color = ok ? "var(--managed)" : "var(--degraded)";
2241
- t.style.cssText = `background:var(--panel);color:var(--fg);border:1px solid ${color};border-left:4px solid ${color};border-radius:8px;padding:8px 12px;font-size:12px;box-shadow:0 4px 16px rgba(0,0,0,.35);cursor:pointer;white-space:pre-wrap`;
3210
+ t.style.cssText = `background:var(--panel);color:var(--fg);border:1px solid ${color};border-left:4px solid ${color};border-radius:var(--r-ctl);padding:8px 12px;font-size:var(--t-body);box-shadow:0 6px 18px color-mix(in srgb, var(--shadow) 50%, transparent);cursor:pointer;white-space:pre-wrap`;
2242
3211
  t.textContent = msg;
2243
3212
  t.onclick = () => t.remove();
2244
3213
  host.appendChild(t);
@@ -2319,7 +3288,7 @@ async function openRollback(btn) {
2319
3288
  wrap.style.cssText = "display:flex;gap:6px;align-self:center";
2320
3289
  const sel = document.createElement("select");
2321
3290
  sel.style.cssText =
2322
- "background:var(--panel);color:var(--fg);border:1px solid var(--line);border-radius:6px;padding:4px 8px;font-size:12px;max-width:340px";
3291
+ "background:var(--well);color:var(--fg);border:1px solid var(--line);border-radius:var(--r-ctl);padding:4px 8px;font-size:var(--t-body);max-width:340px";
2323
3292
  for (const c of commits) sel.add(new Option(`${c.sha} · ${c.subject} (${c.date})`, c.sha));
2324
3293
  const go = button("Roll back →", "", () => {
2325
3294
  const to = sel.value;
@@ -2363,7 +3332,7 @@ async function initActions() {
2363
3332
  const pill = document.createElement("span");
2364
3333
  pill.textContent = `● static snapshot${manifest && manifest.capturedAt ? " · " + manifest.capturedAt.slice(0, 16).replace("T", " ") : ""}`;
2365
3334
  pill.title = "An exported, read-only snapshot — no live observe or deploy.";
2366
- pill.style.cssText = "align-self:center;font-size:11px;color:var(--muted);border:1px solid var(--line);border-radius:6px;padding:2px 8px";
3335
+ pill.style.cssText = "align-self:center;font:var(--t-caption)/1.4 var(--font-mono);color:var(--muted);border:1px solid var(--line);border-radius:var(--r-ctl);padding:2px 8px";
2367
3336
  bar.appendChild(pill);
2368
3337
  previewMode = true;
2369
3338
  return; // nothing else in the bar is a read
@@ -2395,7 +3364,7 @@ async function initActions() {
2395
3364
  local.emulators.map((e) => `${e.lexicon} ${e.name} @ ${e.endpoint}`).join("; ") +
2396
3365
  ". Deploys and the overlay observe them — no cloud creds.";
2397
3366
  pill.style.cssText =
2398
- "align-self:center;font-size:11px;color:var(--managed);border:1px solid var(--managed);border-radius:6px;padding:2px 8px";
3367
+ "align-self:center;font:var(--t-caption)/1.4 var(--font-mono);color:var(--managed);border:1px solid var(--managed);border-radius:var(--r-ctl);padding:2px 8px";
2399
3368
  bar.appendChild(pill);
2400
3369
  }
2401
3370
  // Auto-sync banner (#29) — make an active self-heal loop visible, not silent.
@@ -2404,7 +3373,7 @@ async function initActions() {
2404
3373
  pill.textContent = `⟳ auto-sync: ${autoSync}`;
2405
3374
  pill.title = `On polled drift, behold triggers the ${autoSync === "apply" ? "ApplyOp (heal)" : "ReconcileOp (adopt)"}. Gated applies still wait for Approve.`;
2406
3375
  pill.style.cssText =
2407
- "align-self:center;font-size:11px;color:var(--pending);border:1px solid var(--pending);border-radius:6px;padding:2px 8px";
3376
+ "align-self:center;font:var(--t-caption)/1.4 var(--font-mono);color:var(--pending);border:1px solid var(--pending);border-radius:var(--r-ctl);padding:2px 8px";
2408
3377
  bar.appendChild(pill);
2409
3378
  }
2410
3379
  opsApply = ops.find((o) => o.kind === "apply") ?? null;
@@ -2451,7 +3420,7 @@ async function initActions() {
2451
3420
  // its deploy path is Apply all, not committed Ops).
2452
3421
  if (ops.length === 0 && !previewMode && !opsInitialEnv) {
2453
3422
  const hint = document.createElement("span");
2454
- hint.style.cssText = "color:var(--muted);font-size:11px;align-self:center";
3423
+ hint.style.cssText = "color:var(--muted);font-size:var(--t-caption);align-self:center";
2455
3424
  hint.textContent = "no Ops — commit an *.op.ts (ApplyOp / ReconcileOp / any deploy Op) to act";
2456
3425
  hint.title = "behold triggers committed Ops on your executor. Add one to enable Deploy / Adopt / Run.";
2457
3426
  bar.appendChild(hint);
@@ -2520,7 +3489,7 @@ events.addEventListener("pr", (e) => {
2520
3489
  slot.id = "pr-link";
2521
3490
  slot.target = "_blank";
2522
3491
  slot.rel = "noopener";
2523
- slot.style.cssText = "color:var(--managed);text-decoration:none;font-size:12px;align-self:center";
3492
+ slot.style.cssText = "color:var(--managed);text-decoration:none;font-size:var(--t-body);align-self:center";
2524
3493
  document.getElementById("actions").after(slot);
2525
3494
  }
2526
3495
  slot.href = url;
@@ -2582,6 +3551,22 @@ function paletteCommands() {
2582
3551
  for (const b of document.querySelectorAll("#panel-tabs button[data-tab]")) {
2583
3552
  c.push([`Panel: ${b.textContent}`, () => setPanelTab(b.dataset.tab)]);
2584
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
+ }
2585
3570
 
2586
3571
  // Lens/zoom switches (#56, #63) — replaces the old header zoom picker.
2587
3572
  for (const [label, v] of ZOOM_OPTS) {