@intentius/behold 0.18.0 → 0.19.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 (38) hide show
  1. package/AGENTS.md +287 -71
  2. package/README.md +234 -190
  3. package/demos.json +15 -7
  4. package/dist/cli.js +11881 -11193
  5. package/example-argo-estate/package-lock.json +6 -6
  6. package/example-carve/app/package-lock.json +3 -3
  7. package/example-flux-estate/package-lock.json +6 -6
  8. package/example-k8s/package-lock.json +6 -6
  9. package/example-terraform-estate/README.md +109 -0
  10. package/example-terraform-estate/backends/backend.s3.tf +10 -0
  11. package/example-terraform-estate/envs/dev/README.md +5 -0
  12. package/example-terraform-estate/envs/prod/data.tf +18 -0
  13. package/example-terraform-estate/envs/prod/network.tf +80 -0
  14. package/example-terraform-estate/envs/prod/outputs.tf +13 -0
  15. package/example-terraform-estate/envs/prod/provider.tf +10 -0
  16. package/example-terraform-estate/envs/prod/services.tf +21 -0
  17. package/example-terraform-estate/envs/prod/storage.tf +31 -0
  18. package/example-terraform-estate/envs/prod/variables.tf +17 -0
  19. package/example-terraform-estate/envs/prod/versions.tf +10 -0
  20. package/example-terraform-estate/modules/service/main.tf +58 -0
  21. package/example-terraform-estate/modules/service/outputs.tf +7 -0
  22. package/example-terraform-estate/modules/service/variables.tf +16 -0
  23. package/example-terraform-estate/modules/service/versions.tf +10 -0
  24. package/example-terraform-estate/platform/iam.tf +40 -0
  25. package/example-terraform-estate/platform/kms.tf +16 -0
  26. package/example-terraform-estate/platform/outputs.tf +7 -0
  27. package/example-terraform-estate/platform/variables.tf +10 -0
  28. package/example-terraform-estate/platform/versions.tf +10 -0
  29. package/example-writes/package-lock.json +3 -3
  30. package/package.json +2 -1
  31. package/web/app.js +385 -26
  32. package/web/index.html +23 -1
  33. package/web/pending.js +67 -0
  34. package/web/pending.test.js +62 -0
  35. package/web/read-cost.js +49 -0
  36. package/web/read-cost.test.js +76 -0
  37. package/web/refresh-queue.js +26 -0
  38. package/web/refresh-queue.test.js +54 -0
package/web/app.js CHANGED
@@ -8,6 +8,9 @@
8
8
  // floating control panel's chrome (panel.js — drag/snap/collapse/tabs, persisted
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
+ import { createRefreshQueue } from "./refresh-queue.js";
12
+ import { readCostLine } from "./read-cost.js";
13
+ import { applyMemberFrame, pendingLine, stillPending } from "./pending.js";
11
14
  import { initTheme, mountThemePicker, readableOn, colorForCategory, onThemeChange, getTokens, getTheme, pinTokensFor } from "./theme.js";
12
15
  // #399 M2 / #401 M4 of #397: the colour-by modes' arithmetic and the
13
16
  // provenance badge's wording — every decision the behaviour overlay makes,
@@ -199,6 +202,12 @@ function recolorNodesByCategory(ir) {
199
202
  // unpriced count has to agree with what is on the canvas.
200
203
  if (plan.mode !== "drift" && paint.unpriced) g.setAttribute("data-unpriced", "1");
201
204
  else g.removeAttribute("data-unpriced");
205
+ // #429: and the card an overlay never observed because its member has no
206
+ // live half. Only on an overlay: on the source graph NO card has a status
207
+ // and the mark would be on every one of them, saying nothing.
208
+ const onOverlay = !!(lastMeta && lastMeta.env);
209
+ const node = graphIr.nodes.find((n) => n.id === id);
210
+ g.toggleAttribute("data-no-live", onOverlay && !!node && !(node.attrs && node.attrs._status));
202
211
  // Classify each element ONCE by the pinhole token it rode on (data-cat role), then apply
203
212
  // the role's colour on every pass. This is what makes it recolour on theme switch: after
204
213
  // the first pass the fill is a hex (not a --pin-* var), so we must key off the marker, not
@@ -282,11 +291,11 @@ if (staticMode) {
282
291
 
283
292
  /** Canonical key for a read URL — path + the lens params (whitelisted, sorted)
284
293
  * that select a distinct snapshot. MUST match src/export.ts `canonicalKey`. */
285
- const LENS_PARAMS = ["collapse", "components", "detail", "env", "logical", "ops", "radial", "runtime", "tier"];
294
+ const LENS_PARAMS = ["collapse", "components", "detail", "env", "logical", "ops", "radial", "runtime", "stacks", "tier"];
286
295
  function canonicalKey(path, params) {
287
296
  // Components + logical + ops views ignore detail/radial — drop them so they
288
297
  // match the single captured snapshot (MUST match src/export.ts).
289
- const flat = params.get("components") === "1" || params.get("logical") === "1" || params.get("ops") === "1";
298
+ const flat = params.get("components") === "1" || params.get("logical") === "1" || params.get("ops") === "1" || params.get("stacks") === "1";
290
299
  const q = LENS_PARAMS.filter((k) => params.has(k) && !(flat && (k === "detail" || k === "radial")))
291
300
  .map((k) => `${k}=${params.get(k)}`)
292
301
  .join("&");
@@ -329,6 +338,29 @@ function vocabularyNote() {
329
338
  const v = lastMeta && lastMeta.vocabulary;
330
339
  return (v && v.note) || "";
331
340
  }
341
+ // #396 item 5: the inspect pane's LIVE section prints `ownership` raw, and
342
+ // `ownership` is a word two different readers write into the same field.
343
+ // chant's live reader answers it from the resource's own marker — `owned` /
344
+ // `foreign`, which is chant's contract for that field (chant graph-ir.d.ts).
345
+ // src/choudoufu-live.ts has no marker to read: it writes the same two words for
346
+ // choudoufu's `bound[]` and `unowned[]`, so an unowned card read `status:
347
+ // unowned` and, two rows below it, `ownership: foreign` — chant's word for
348
+ // choudoufu's state, the last of the four #393 item 8 did not reach.
349
+ //
350
+ // So this row joins the legend, the counts and the status row in being rendered
351
+ // through the estate's own vocabulary rather than printed. Translated ONLY when
352
+ // the estate's members are all of a kind that has its own words for these
353
+ // colours (`vocabulary.of`): on a chant estate, and on a mixed one — which
354
+ // keeps chant's words on the legend by design and says so in its tooltip — the
355
+ // value is printed exactly as it is today, which is the point. The mapping is
356
+ // value → the colour that value means → that colour's word, so it needs no
357
+ // table of its own beyond the two words chant's field can hold.
358
+ const OWNERSHIP_STATUS = { owned: "good", foreign: "warn" };
359
+ function ownershipLabel(value) {
360
+ const v = lastMeta && lastMeta.vocabulary;
361
+ if (!v || !v.labels || !v.of || v.of === "chant" || v.of === "mixed") return value;
362
+ return v.labels[OWNERSHIP_STATUS[value]] || value;
363
+ }
332
364
  // #404: whether a plan was read for this overlay, and how many cards it would
333
365
  // change. `{read: false}` and `{read: true, drifted: 0}` are DIFFERENT answers
334
366
  // — "nobody looked" versus "looked, nothing drifted" — and the legend must not
@@ -708,7 +740,7 @@ function inspect(node) {
708
740
  if (node.physicalId || node.ownership) {
709
741
  const live = section("live");
710
742
  if (node.physicalId) live("physical id", node.physicalId);
711
- if (node.ownership) live("ownership", node.ownership);
743
+ if (node.ownership) live("ownership", ownershipLabel(node.ownership)); // #396 item 5: the estate's own word for it
712
744
  } else if (liveStatus) {
713
745
  // The colour alone doesn't carry chant's verdict or its reasoning — spell
714
746
  // both out here (never rely on the node's colour alone, #57 accessibility
@@ -994,7 +1026,7 @@ function renderObserved(panel, o, health, healthDetail) {
994
1026
  if (o.type) add("type", o.type);
995
1027
  if (o.status) add("status", o.status, HEALTH_COLOR[health] || undefined);
996
1028
  if (o.physicalId) add("physical id", o.physicalId);
997
- if (o.ownership) add("ownership", o.ownership);
1029
+ if (o.ownership) add("ownership", ownershipLabel(o.ownership)); // #396 item 5: same, off /api/diff
998
1030
  if (o.lastUpdated) add("last updated", o.lastUpdated);
999
1031
  // What the object's own controller says is wrong with it (#86, chant#1401).
1000
1032
  // Placed with health and status rather than among the attributes below,
@@ -1259,6 +1291,13 @@ const ZOOM_OPTS = [
1259
1291
  // same chant source), so it sits last, and only appears when the served
1260
1292
  // estate has actually emitted Ops — `opsAvailable`, from /api/project.
1261
1293
  ["zoom: ops", "ops"],
1294
+ // The apply order (#426): the cross-stack ordering chant already computes at
1295
+ // synthesis — which lexicon partition must apply before which. A different
1296
+ // axis again, like `ops`: neither coarser nor finer than a resource, and the
1297
+ // only stop whose edges are about TIME rather than reference. Single-project
1298
+ // only and gated on `stacksCapable`, because two members' `aws` stacks are
1299
+ // not one stack.
1300
+ ["zoom: apply order", "stacks"],
1262
1301
  ];
1263
1302
  const ZOOM_DETAIL = { composites: 1, resources: 2, attributes: 3, runtime: 3 };
1264
1303
  /** How many emitted Ops the served estate has (0 = the stop doesn't exist).
@@ -1271,18 +1310,28 @@ let opsAvailable = 0;
1271
1310
  * Kubernetes tier that could only ever answer "nothing below the declaration
1272
1311
  * boundary". */
1273
1312
  let runtimeCapable = false;
1313
+ /** #422: the members whose live read has not landed yet, off the progressive
1314
+ * overlay's `meta.pending`. Empty on every non-progressive load, which is every
1315
+ * single-project serve and every static snapshot. */
1316
+ let pendingMembers = [];
1317
+ let pendingTotal = 0;
1318
+ /** #426: whether the apply-order stop is worth offering — /api/project's
1319
+ * `stacksCapable`, which is false for an estate and for a single-lexicon
1320
+ * project, where the answer could only ever be one box. */
1321
+ let stacksCapable = false;
1274
1322
  /** The zoom stops this project can actually offer — `runtime` needs an env AND
1275
1323
  * a member whose substrate has an owner chain, `ops` needs emitted op.json
1276
1324
  * files. Both the panel and ⌘K read this, so a stop never appears in one
1277
1325
  * surface and not the other. */
1278
1326
  function availableZooms() {
1279
- return ZOOM_OPTS.filter(([, z]) => (z === "runtime" ? Boolean(view.env) && runtimeCapable : z === "ops" ? opsAvailable > 0 : true));
1327
+ return ZOOM_OPTS.filter(([, z]) => (z === "runtime" ? Boolean(view.env) && runtimeCapable : z === "ops" ? opsAvailable > 0 : z === "stacks" ? stacksCapable : true));
1280
1328
  }
1281
1329
  /** Current zoom value from (components, logical, ops, detail). */
1282
1330
  function zoomValue() {
1283
1331
  if (view.components) return "components";
1284
1332
  if (view.logical) return "logical";
1285
1333
  if (view.ops) return "ops";
1334
+ if (view.stacks) return "stacks";
1286
1335
  if (view.runtime) return "runtime";
1287
1336
  return { 1: "composites", 2: "resources", 3: "attributes" }[view.detail] ?? "resources";
1288
1337
  }
@@ -1291,8 +1340,9 @@ function applyZoom(z) {
1291
1340
  view.components = z === "components";
1292
1341
  view.logical = z === "logical";
1293
1342
  view.ops = z === "ops";
1343
+ view.stacks = z === "stacks";
1294
1344
  view.runtime = z === "runtime";
1295
- if (z !== "components" && z !== "logical" && z !== "ops") view.detail = ZOOM_DETAIL[z] ?? 2;
1345
+ if (z !== "components" && z !== "logical" && z !== "ops" && z !== "stacks") view.detail = ZOOM_DETAIL[z] ?? 2;
1296
1346
  }
1297
1347
 
1298
1348
  // --- Floating control panel content ---------------------------------------
@@ -1539,12 +1589,21 @@ function renderCarveState(host, state) {
1539
1589
  */
1540
1590
  function copyableRow(add, key, text, title) {
1541
1591
  const wrap = document.createElement("span");
1542
- wrap.style.cssText = "display:flex;gap:6px;align-items:baseline;min-width:0";
1592
+ // `center`, not `baseline`: an `overflow: hidden` flex item takes its bottom
1593
+ // margin edge as its baseline, so the clipped line below would have dragged
1594
+ // the copy button down with it.
1595
+ wrap.style.cssText = "display:flex;gap:6px;align-items:center;min-width:0";
1543
1596
  const line = document.createElement("code");
1544
1597
  line.className = "grow";
1545
- line.style.cssText = "flex:1;min-width:0;overflow-wrap:anywhere";
1598
+ // #396 item 7c: ONE line, clipped. `tofu-estate=… tofu-address=…` wrapped
1599
+ // onto three lines in the 260px pane and pushed everything the card had to
1600
+ // say off the bottom — for a string nobody reads off the screen anyway,
1601
+ // because the copy button beside it is how it gets used. The whole of it is
1602
+ // on this row's tooltip, on the button's, and in the clipboard.
1603
+ line.style.cssText = "flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis";
1546
1604
  line.textContent = text;
1547
- const copy = actButton("copy", () => copyToClipboard(text, copy), title);
1605
+ line.title = text;
1606
+ const copy = actButton("copy", () => copyToClipboard(text, copy), title ? `${title}:\n${text}` : text);
1548
1607
  wrap.append(line, copy);
1549
1608
  add(key, wrap);
1550
1609
  }
@@ -2073,6 +2132,23 @@ function renderPanelModel() {
2073
2132
  // bound and is already counted on the row above. The row appears only when
2074
2133
  // a plan was actually read; where it was not, the legend says so in words
2075
2134
  // and prints no number, because "0 drifted" would be a claim nobody made.
2135
+ // #429: a card with NO `_status` at all. pinhole's painter falls back to
2136
+ // `neutral` for an unrecognised value, `undefined` included, so a member
2137
+ // with no live half — a Terraform root, whose `live`/`overlay`/`env` are
2138
+ // stripped in src/terraform-member.ts — paints exactly like a card behold
2139
+ // looked at and could not see. It is in no legend bucket either, so the
2140
+ // colour claimed 37 unobserved cards on an estate whose legend counted 2.
2141
+ //
2142
+ // Named rather than folded into `unobserved`, because they are different
2143
+ // claims: "I looked and could not see" against "there is nothing here to
2144
+ // look at". Same argument #399 makes for an unpriced card, and the same
2145
+ // answer — mark it, do not merely colour it.
2146
+ const noLive = ir.nodes.filter((n) => !(n.attrs && n.attrs._status)).length;
2147
+ if (noLive) {
2148
+ const row = panelDotRow("var(--muted)", "no live half", String(noLive));
2149
+ row.title = "These members declare no live half, so nothing observed them — which is not the same as unobserved, and not the same as absent.";
2150
+ host.appendChild(row);
2151
+ }
2076
2152
  const dm = driftMeta();
2077
2153
  if (dm && dm.read) {
2078
2154
  host.appendChild(panelDotRow("var(--degraded)", "drifted (the plan would change it)", String(dm.drifted)));
@@ -2462,6 +2538,36 @@ function markOperatorCards(ir) {
2462
2538
  * estates this can appear on the corner is free; the guard below still skips a
2463
2539
  * card that already carries one of those, rather than drawing over it.
2464
2540
  */
2541
+ /**
2542
+ * The cards whose member is still being read (#422).
2543
+ *
2544
+ * The same post-render stamp `markCarvedCards`/`markDriftedCards` use, and for
2545
+ * the same reason: pinhole's SVG arrives as a string and is replaced on every
2546
+ * render, so a mark is re-applied here rather than asked for at layout time.
2547
+ *
2548
+ * A dashed outline and a muted corner rather than a fill, because the fill is
2549
+ * the status channel and this card HAS no status yet — painting one would be
2550
+ * the claim the whole milestone exists to avoid. `_pendingRead` rides its own
2551
+ * attr for the same reason: a fifth `_status` value falls back to neutral in
2552
+ * pinhole's painter, which reads as "unobserved", which is a different and
2553
+ * wrong thing.
2554
+ */
2555
+ function markPendingCards(ir) {
2556
+ const svg = document.querySelector("#graph svg");
2557
+ if (!svg) return;
2558
+ for (const n of (ir && ir.nodes) || []) {
2559
+ const g = svg.querySelector('[data-node-id="' + CSS.escape(n.id) + '"]');
2560
+ if (!g) continue;
2561
+ const pending = !!(n.attrs && n.attrs._pendingRead);
2562
+ g.toggleAttribute("data-pending-read", pending);
2563
+ if (pending && !g.querySelector("title")) {
2564
+ const t = document.createElementNS("http://www.w3.org/2000/svg", "title");
2565
+ t.textContent = "still reading this member — not yet observed";
2566
+ g.appendChild(t);
2567
+ }
2568
+ }
2569
+ }
2570
+
2465
2571
  function markDriftedCards(ir) {
2466
2572
  const svg = document.querySelector("#graph svg");
2467
2573
  if (!svg) return;
@@ -2559,6 +2665,11 @@ function renderStatusbar() {
2559
2665
  if (view.stack) parts.push(`stack: ${view.stack}`);
2560
2666
  if (axes.tier) parts.push(`tier: ${axes.tier}`);
2561
2667
  if (view.radial && !view.components && !view.logical && !view.ops) parts.push("radial");
2668
+ // #422: what the picture is still waiting on, while it is waiting. Ahead of
2669
+ // the zoom note, because a half-read estate is the more urgent fact about
2670
+ // what is on screen.
2671
+ const stillReading = pendingLine(pendingMembers, pendingTotal);
2672
+ if (stillReading) parts.push(stillReading);
2562
2673
  el.textContent = parts.join(" · ");
2563
2674
  // #131: why this level rendered empty, or as the one below it. The server
2564
2675
  // decides (src/zoom-notes.ts) — the SPA never infers it, so the note always
@@ -3388,6 +3499,7 @@ function render(ir, svg, m) {
3388
3499
  markPlayhead(ir); // #284 item 2: same, for the step the run is sitting on
3389
3500
  markOperatorCards(ir); // #234 free rider: same, for the operating loop's home
3390
3501
  markDriftedCards(ir); // #404: same, for a bound card the plan would change — after the carve/operator stamps, so it can see and yield to them
3502
+ markPendingCards(ir); // #422: and the cards whose member has not answered yet
3391
3503
  applyLayout(); // #228: last, so the hand-placed deltas ride on top of every other pass
3392
3504
  renderDial();
3393
3505
  }
@@ -3483,7 +3595,13 @@ function setupGraphViewBox(svg) {
3483
3595
  const a = (svg.getAttribute("viewBox") || "").split(/\s+/).map(Number);
3484
3596
  if (a.length === 4 && a.every((n) => !Number.isNaN(n))) {
3485
3597
  vbInit = a.slice();
3486
- vb = a.slice();
3598
+ // #396 item 2: land on the fit that keeps the panel's footprint clear, not
3599
+ // on the raw box — a re-render (a zoom change, an env change, the SSE
3600
+ // re-pull) goes through here and never through fitGraph, so the first
3601
+ // column would slide back under the panel at every one of them.
3602
+ vb = fittedViewBox() || a.slice();
3603
+ vbAtFit = true;
3604
+ applyVB();
3487
3605
  } else {
3488
3606
  vbInit = vb = null;
3489
3607
  }
@@ -3494,11 +3612,178 @@ function currentSvg() {
3494
3612
  function applyVB() {
3495
3613
  const s = currentSvg();
3496
3614
  if (s && vb) s.setAttribute("viewBox", vb.join(" "));
3615
+ scaleBoxLabels(); // #396 item 7b: box titles/badges keep a legible size at every zoom
3616
+ }
3617
+
3618
+ // #396 item 2: the panel is `position: fixed` and floats OVER the graph pane
3619
+ // (index.html), so "fit the pane" put the leftmost column of cards underneath
3620
+ // it — a whole column of the terralith, half waterpark's `github` box — at
3621
+ // boot and after every re-render. The panel is furniture the reader cannot see
3622
+ // through, so the fit's target is the pane MINUS whatever the panel is
3623
+ // currently covering.
3624
+ //
3625
+ // Read at fit time, never remembered: the panel drags, docks to either side and
3626
+ // collapses to its tab bar, and a fit computed against where it used to be is
3627
+ // the same bug with a different offset. Collapsed or hidden → no footprint, and
3628
+ // the fit is the whole pane exactly as before.
3629
+ //
3630
+ // Horizontal only. The panel is a tall palette that docks left or right; a
3631
+ // top/bottom dock still leaves the full width, and insetting vertically for it
3632
+ // would shrink the graph for a strip the cards mostly clear anyway.
3633
+ const FIT_PANEL_MAX = 0.45; // the panel can never claim more than this of the pane
3634
+
3635
+ function panelInsets(pane) {
3636
+ const none = { left: 0, right: 0 };
3637
+ const p = document.getElementById("panel");
3638
+ if (!p || p.classList.contains("collapsed")) return none;
3639
+ const cs = getComputedStyle(p);
3640
+ if (cs.display === "none" || cs.visibility === "hidden" || Number(cs.opacity) === 0) return none;
3641
+ const r = p.getBoundingClientRect();
3642
+ if (!r.width || !r.height) return none;
3643
+ // Nothing to inset for a panel that has been dragged clear of the graph pane.
3644
+ if (r.bottom <= pane.top || r.top >= pane.bottom || r.right <= pane.left || r.left >= pane.right) return none;
3645
+ const cap = pane.width * FIT_PANEL_MAX;
3646
+ // Whichever side it is nearer to is the side it eats.
3647
+ return (r.left + r.right) / 2 < (pane.left + pane.right) / 2
3648
+ ? { left: Math.min(cap, Math.max(0, r.right - pane.left)), right: 0 }
3649
+ : { left: 0, right: Math.min(cap, Math.max(0, pane.right - r.left)) };
3650
+ }
3651
+
3652
+ /**
3653
+ * The viewBox that fits the whole graph into the pane's UNCOVERED part.
3654
+ *
3655
+ * The returned box is given the pane's own aspect ratio, so `xMidYMid meet`
3656
+ * letterboxes nothing and the pane's pixels map to viewBox units at one known
3657
+ * scale: the graph is then placed by arithmetic — centred inside the band
3658
+ * between the insets, centred vertically — instead of wherever `meet` would
3659
+ * have dropped it. With no insets the result is pixel-identical to the old
3660
+ * `vb = vbInit`, which is why nothing without a panel over it moves.
3661
+ */
3662
+ function fittedViewBox() {
3663
+ if (!vbInit) return null;
3664
+ const host = document.getElementById("graph");
3665
+ if (!host) return null;
3666
+ const pane = host.getBoundingClientRect();
3667
+ const [x0, y0, w0, h0] = vbInit;
3668
+ if (!(pane.width > 0 && pane.height > 0 && w0 > 0 && h0 > 0)) return null;
3669
+ const { left, right } = panelInsets(pane);
3670
+ const band = Math.max(1, pane.width - left - right);
3671
+ const s = Math.min(band / w0, pane.height / h0);
3672
+ const vw = pane.width / s;
3673
+ const vh = pane.height / s;
3674
+ return [x0 - left / s - (band / s - w0) / 2, y0 - (vh - h0) / 2, vw, vh];
3497
3675
  }
3676
+
3498
3677
  function fitGraph() {
3499
- if (vbInit) {
3500
- vb = vbInit.slice();
3501
- applyVB();
3678
+ if (!vbInit) return;
3679
+ vb = fittedViewBox() || vbInit.slice();
3680
+ vbAtFit = true;
3681
+ applyVB();
3682
+ }
3683
+
3684
+ // #396 item 2: the fit now depends on the pane — its size, and what the panel
3685
+ // covers of it — where `vbInit` alone depended on neither, so a graph sitting
3686
+ // at fit has to be re-fitted when the pane moves under it. It moves more often
3687
+ // than a window resize: the statusbar's note wraps onto a second line, the
3688
+ // inspect pane is dragged wider, a panel tab's content grows. Only while the
3689
+ // view IS the fit: a pan or a zoom takes ownership of the viewBox, and nothing
3690
+ // here may pull it back out from under the reader's hands.
3691
+ let vbAtFit = false;
3692
+ function watchPaneForFit(host) {
3693
+ if (typeof ResizeObserver === "undefined") return;
3694
+ new ResizeObserver(() => {
3695
+ if (vbAtFit) fitGraph();
3696
+ }).observe(host);
3697
+ }
3698
+
3699
+ // --- #396 item 7b: box titles and badges that survive the fit ---------------
3700
+ //
3701
+ // pinhole draws a group box's title at a flat `font-size="12"` and its badge at
3702
+ // `11`, in viewBox UNITS. That reads as 12px on a graph whose fit is 1:1 and as
3703
+ // 7px on terralith-4, whose box is 8000 units wide — the one label that says
3704
+ // which member 301 cards belong to, and the one that counts them, are the two
3705
+ // things on the canvas a person cannot read at the zoom they arrive at.
3706
+ //
3707
+ // So the label is given a floor in SCREEN pixels: its size in units is raised
3708
+ // by the inverse of the current scale until it renders at its natural size, and
3709
+ // never below it, so anything already legible is untouched (zoomed IN, the
3710
+ // attribute is left at 12 and grows with the graph, exactly as before). The
3711
+ // anchor never moves — x, y and `text-anchor` are pinhole's — so a title stays
3712
+ // welded to its box's top-left corner and a badge to its top-right through
3713
+ // every pan and zoom; only the type gets bigger.
3714
+ //
3715
+ // The floor yields to the box: a label may not grow past a fraction of the
3716
+ // box's height, nor past its width. A sub-box too small to hold readable type
3717
+ // at this zoom says so by staying small, rather than by writing across its
3718
+ // neighbours.
3719
+ const BOX_LABEL_GUTTER = 34; // units below a box's top edge that the title row occupies
3720
+ const BOX_LABEL_MAX_H = 8; // a label may be at most box height / this
3721
+ const BOX_LABEL_EM = 0.62; // rough advance width per char, as a fraction of the size
3722
+
3723
+ let boxLabelCache = { svg: null, labels: [] };
3724
+ let boxLabelScale = null;
3725
+
3726
+ /** The `<text>` elements pinhole put in each group box's title gutter, with the
3727
+ * box they belong to and the size it drew them at. Matched by geometry rather
3728
+ * than by sibling order: a box's optional identity mark sits between the title
3729
+ * and the badge, and #228's layout pass lifts the rect and its title into a
3730
+ * wrapper `<g>` after the render, so neither order nor a common parent holds.
3731
+ * What geometry alone would let through — a card's own text, an edge chip's —
3732
+ * is excluded by the ancestor it sits under. */
3733
+ function boxLabelsOf(svg) {
3734
+ if (boxLabelCache.svg === svg) return boxLabelCache.labels;
3735
+ const labels = [];
3736
+ const boxes = [];
3737
+ for (const rect of svg.querySelectorAll("rect[data-group-id]")) {
3738
+ const b = {
3739
+ x: parseFloat(rect.getAttribute("x")),
3740
+ y: parseFloat(rect.getAttribute("y")),
3741
+ w: parseFloat(rect.getAttribute("width")),
3742
+ h: parseFloat(rect.getAttribute("height")),
3743
+ };
3744
+ if ([b.x, b.y, b.w, b.h].every((n) => Number.isFinite(n))) boxes.push(b);
3745
+ }
3746
+ if (boxes.length) {
3747
+ for (const t of svg.querySelectorAll("text")) {
3748
+ const x = parseFloat(t.getAttribute("x"));
3749
+ const y = parseFloat(t.getAttribute("y"));
3750
+ // pinhole's own size, stashed the first time so a re-index can never
3751
+ // read a size this pass already raised as if it were the natural one.
3752
+ const base = parseFloat(t.dataset.pinFont ?? t.getAttribute("font-size"));
3753
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(base)) continue;
3754
+ // Not a card's own text (inside its `g[data-node-id]`), not an edge
3755
+ // chip's (inside the edge's `g`), not a radial wedge label behold adds
3756
+ // itself. What is left at a box's title row IS the box's title row.
3757
+ if (t.closest("[data-node-id], [data-edge-from], [data-edge-to], #radial-labels")) continue;
3758
+ const box = boxes.find((b) => y > b.y && y <= b.y + BOX_LABEL_GUTTER && x >= b.x - 2 && x <= b.x + b.w + 2);
3759
+ if (!box) continue;
3760
+ t.dataset.pinFont = String(base);
3761
+ labels.push({ el: t, box, base });
3762
+ }
3763
+ }
3764
+ boxLabelCache = { svg, labels };
3765
+ boxLabelScale = null;
3766
+ return labels;
3767
+ }
3768
+
3769
+ /** Re-size every box label for the current zoom. Cheap and idempotent: the
3770
+ * scale is remembered, so the pan handler's stream of `applyVB` calls (which
3771
+ * move the viewBox without changing its width) touches no attribute at all. */
3772
+ function scaleBoxLabels() {
3773
+ const svg = currentSvg();
3774
+ if (!svg || !vb) return;
3775
+ const labels = boxLabelsOf(svg);
3776
+ if (!labels.length) return;
3777
+ const pane = svg.getBoundingClientRect();
3778
+ if (!(pane.width > 0 && pane.height > 0)) return;
3779
+ const scale = Math.min(pane.width / vb[2], pane.height / vb[3]);
3780
+ if (!(scale > 0) || scale === boxLabelScale) return;
3781
+ boxLabelScale = scale;
3782
+ for (const l of labels) {
3783
+ const chars = Math.max(4, (l.el.textContent || "").length);
3784
+ const cap = Math.max(l.base, Math.min(l.box.h / BOX_LABEL_MAX_H, l.box.w / (chars * BOX_LABEL_EM)));
3785
+ const size = Math.max(l.base, Math.min(l.base / scale, cap));
3786
+ l.el.setAttribute("font-size", String(Math.round(size * 10) / 10));
3502
3787
  }
3503
3788
  }
3504
3789
 
@@ -3537,6 +3822,7 @@ function revealNode(id) {
3537
3822
  const w = Math.min(vbInit[2], Math.max(box.width * REVEAL_CARDS_ACROSS, vbInit[2] / 60));
3538
3823
  const h = w * (vbInit[3] / vbInit[2]);
3539
3824
  vb = [box.x + box.width / 2 - w / 2, box.y + box.height / 2 - h / 2, w, h];
3825
+ vbAtFit = false;
3540
3826
  applyVB();
3541
3827
  return true;
3542
3828
  }
@@ -3555,6 +3841,7 @@ function ensureZoomControls(host) {
3555
3841
  }
3556
3842
  if (zoomWired) return;
3557
3843
  zoomWired = true;
3844
+ watchPaneForFit(host);
3558
3845
  host.addEventListener(
3559
3846
  "wheel",
3560
3847
  (e) => {
@@ -3576,6 +3863,7 @@ function ensureZoomControls(host) {
3576
3863
  vb[1] = cy - ((cy - vb[1]) * nh) / vb[3];
3577
3864
  vb[2] = nw;
3578
3865
  vb[3] = nh;
3866
+ vbAtFit = false;
3579
3867
  applyVB();
3580
3868
  },
3581
3869
  { passive: false },
@@ -3608,6 +3896,7 @@ function ensureZoomControls(host) {
3608
3896
  }
3609
3897
  vb[0] -= (dx / r.width) * vb[2];
3610
3898
  vb[1] -= (dy / r.height) * vb[3];
3899
+ vbAtFit = false;
3611
3900
  px = e.clientX;
3612
3901
  py = e.clientY;
3613
3902
  applyVB();
@@ -4211,7 +4500,8 @@ function renderPreconditionError(body) {
4211
4500
  }
4212
4501
 
4213
4502
  // Fetch the current view (source graph, or the picked env's live overlay).
4214
- async function load(opts = {}) {
4503
+ const load = createRefreshQueue(loadOnce);
4504
+ async function loadOnce(opts = {}, isCurrent = () => true) {
4215
4505
  const meta = document.getElementById("meta");
4216
4506
  // A background settle re-pull (post-apply) shouldn't flash the meta/overlay.
4217
4507
  if (!opts.quiet) {
@@ -4230,6 +4520,14 @@ async function load(opts = {}) {
4230
4520
  // cross-substrate entity overlay, which components never use.
4231
4521
  q.set("components", "1");
4232
4522
  if (view.env) q.set("env", view.env);
4523
+ } else if (view.stacks) {
4524
+ // The apply-order lens (#426): chant's cross-stack ordering, built fresh
4525
+ // for the same reason the ops lens is — it reads none of the entity
4526
+ // knobs. No env (it is computed at synthesis, with no substrate), no
4527
+ // detail, no radial. Tier rides along because a project's source can
4528
+ // branch on one, which can change which lexicons it partitions into.
4529
+ q = new URLSearchParams({ stacks: "1" });
4530
+ if (view.tier) q.set("tier", view.tier);
4233
4531
  } else if (view.ops) {
4234
4532
  // The ops lens (#284): the declared Ops, read from each Op's emitted
4235
4533
  // op.json. Built fresh rather than layered onto the entity lenses,
@@ -4263,6 +4561,11 @@ async function load(opts = {}) {
4263
4561
  } else if (view.env) {
4264
4562
  endpoint = "/api/overlay";
4265
4563
  q.set("env", view.env);
4564
+ // #422: draw the estate from source at once and recolour each member as
4565
+ // its own live read lands, instead of a blank canvas until the slowest
4566
+ // one finishes. The server only honours it for a composed estate, and
4567
+ // never in a static snapshot, where there is no stream to follow.
4568
+ if (!staticMode) q.set("progressive", "1");
4266
4569
  // The runtime tier (#86) descends below the declaration boundary. It is
4267
4570
  // live-only by nature — owner-referenced children exist in the cluster,
4268
4571
  // never in your source — so it rides the overlay and means nothing
@@ -4294,6 +4597,7 @@ async function load(opts = {}) {
4294
4597
  }
4295
4598
  const res = await apiFetch(`${endpoint}?${q}`);
4296
4599
  const body = await res.json();
4600
+ if (!isCurrent()) return; // a newer view/event is pending; do not paint the old result
4297
4601
  if (!res.ok) {
4298
4602
  // #72: a classified precondition failure (lint gate, not installed, a
4299
4603
  // tier that needs credentials) gets the calmer entry/error card instead
@@ -4314,13 +4618,19 @@ async function load(opts = {}) {
4314
4618
  autoZoomFallback = false;
4315
4619
  applyZoom("resources");
4316
4620
  renderStatusbar();
4317
- return load(opts);
4621
+ return loadOnce(opts, isCurrent);
4318
4622
  }
4319
4623
  autoZoomFallback = false;
4624
+ // #422: a progressive answer names the members it is still reading, and
4625
+ // the `member` stream above clears them one at a time. Any other answer
4626
+ // resets it, so a switch away from a progressive load never leaves a stale
4627
+ // "still reading" claim on screen.
4628
+ pendingMembers = body.meta && body.meta.mode === "progressive" ? [...(body.meta.pending || [])] : [];
4629
+ pendingTotal = pendingMembers.length;
4320
4630
  render(body.ir, body.svg, body.meta);
4321
4631
  } catch (err) {
4322
4632
  // A background settle poll must not blow away a good graph on a transient error.
4323
- if (!opts.quiet) {
4633
+ if (!opts.quiet && isCurrent()) {
4324
4634
  // Text, never innerHTML — `err.message` embeds chant's own stderr, which
4325
4635
  // is not ours to interpolate as markup (the sibling precondition card has
4326
4636
  // said so since #72; this branch had been left behind).
@@ -4434,6 +4744,7 @@ async function initPickers() {
4434
4744
  // honestly open. Both are read before the first load() below, so a non-chant
4435
4745
  // estate never paints the components apology on its way to a picture.
4436
4746
  runtimeCapable = !!info.runtimeCapable;
4747
+ stacksCapable = !!info.stacksCapable;
4437
4748
  const boot = bootZoom(info.memberKinds);
4438
4749
  if (boot !== "components") {
4439
4750
  applyZoom(boot);
@@ -4460,20 +4771,39 @@ initPickers();
4460
4771
  // No backend in a static export → no live event stream; a no-op keeps the
4461
4772
  // `events.addEventListener(...)` wiring below harmless.
4462
4773
  const events = staticMode ? { addEventListener() {} } : new EventSource("/api/events");
4463
- // Post-op settle re-pull: an apply's CLI can exit while the last stacks are still
4464
- // flipping to *_COMPLETE, so the immediate reload catches a few components mid-
4465
- // deploy ("all done, 3 still pending"). Quietly re-pull a couple more times so
4466
- // the graph lands on the final colours without a manual Re-check live.
4467
- let settleTimers = [];
4468
- function scheduleSettle() {
4469
- settleTimers.forEach(clearTimeout);
4470
- settleTimers = [3000, 8000, 15000].map((ms) => setTimeout(() => load({ quiet: true }), ms));
4471
- }
4774
+ // One notification requests one refresh. Further notifications during a slow
4775
+ // read collapse to one follow-up; no wall-clock timers multiply estate scans.
4776
+ // #422: one frame per member as its live read settles. Payload-carrying, like
4777
+ // `apply` and `run` — the node set is already on screen, so this is id ->
4778
+ // status and the client repaints in place. Nothing is re-fetched: a refetch
4779
+ // here would re-run the whole estate read this stream exists to narrate.
4780
+ events.addEventListener("member", (e) => {
4781
+ let frame;
4782
+ try {
4783
+ frame = JSON.parse(e.data);
4784
+ } catch {
4785
+ return;
4786
+ }
4787
+ // The live pass itself failed. Say so on the now-line and stop claiming the
4788
+ // remaining members are still arriving, because nothing more is coming.
4789
+ if (frame.error) {
4790
+ pendingMembers = [];
4791
+ nowline(`the estate read failed — ${frame.error}`);
4792
+ renderStatusbar();
4793
+ return;
4794
+ }
4795
+ if (!lastGraphIr) return;
4796
+ const moved = applyMemberFrame(lastGraphIr, frame);
4797
+ pendingMembers = stillPending(pendingMembers, frame);
4798
+ if (moved.length) recolorNodesByCategory(lastGraphIr);
4799
+ markPendingCards(lastGraphIr);
4800
+ renderStatusbar();
4801
+ });
4802
+
4472
4803
  events.addEventListener("changed", () => {
4473
4804
  bulkDiffCache = null; // an op ran → per-node live state may have changed
4474
4805
  load();
4475
4806
  loadSubstrates(); // a bring-up (or any op) finished → re-detect readiness
4476
- scheduleSettle();
4477
4807
  });
4478
4808
 
4479
4809
  // Substrate readiness (M5, #54): is each substrate the project needs actually
@@ -4610,11 +4940,19 @@ setInterval(() => {
4610
4940
  // can't fire a second pull while one's in flight. Ref-counted — nested loads
4611
4941
  // (graph + CI + resources) only lift the scrim when the last finishes.
4612
4942
  let loadingDepth = 0;
4943
+ // #421: what the LAST read cost, shown while this one runs. The person watching
4944
+ // the scrim wants to know whether to wait or go away, and behold has never told
4945
+ // them. Fetched after a load settles rather than during one — asking the server
4946
+ // how slow it is while it is being slow helps nobody — so the line always
4947
+ // describes the previous read and never guesses at this one.
4948
+ let lastReadCost = null;
4613
4949
  function showLoading(msg) {
4614
4950
  loadingDepth++;
4615
4951
  const o = document.getElementById("loading-overlay");
4616
4952
  if (!o) return;
4617
4953
  document.getElementById("loading-msg").textContent = msg || "loading…";
4954
+ const cost = document.getElementById("loading-cost");
4955
+ if (cost) cost.textContent = lastReadCost || "";
4618
4956
  o.hidden = false;
4619
4957
  }
4620
4958
  function hideLoading() {
@@ -4622,6 +4960,27 @@ function hideLoading() {
4622
4960
  if (loadingDepth > 0) return;
4623
4961
  const o = document.getElementById("loading-overlay");
4624
4962
  if (o) o.hidden = true;
4963
+ void refreshReadCost();
4964
+ }
4965
+
4966
+ // One cheap call on counters, never on the read path. `?reads=1` is what makes
4967
+ // that true: the bare route also runs the full doctor diagnosis, whose substrate
4968
+ // probes spawn docker/gh/helm/k3d with no timeout, and this runs after every
4969
+ // load settles. A cost line is never worth breaking a load over, so every
4970
+ // failure here leaves the previous line standing.
4971
+ //
4972
+ // A static export has no backend to ask and no reads to report, so it does not
4973
+ // ask. Without this guard the bundle logs a 404 on every page load and relies on
4974
+ // the fetch failing to stay silent, which is an outcome rather than a rule.
4975
+ async function refreshReadCost() {
4976
+ if (staticMode) return;
4977
+ try {
4978
+ const res = await fetch("/api/doctor?reads=1");
4979
+ if (!res.ok) return;
4980
+ lastReadCost = readCostLine((await res.json()).reads);
4981
+ } catch {
4982
+ /* no line rather than a wrong one */
4983
+ }
4625
4984
  }
4626
4985
 
4627
4986
  // #259: the op stream is chant's own stdout, and an Op that reports as JSON