@intentius/behold 0.18.0 → 0.20.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 (68) hide show
  1. package/AGENTS.md +377 -76
  2. package/README.md +238 -190
  3. package/demos.json +17 -8
  4. package/dist/cli.js +13118 -11622
  5. package/example-argo-estate/README.md +8 -8
  6. package/example-argo-estate/app-a/package.json +2 -2
  7. package/example-argo-estate/app-b/package.json +2 -2
  8. package/example-argo-estate/chant.workspace.json +27 -0
  9. package/example-argo-estate/control-plane/package.json +2 -2
  10. package/example-argo-estate/package-lock.json +139 -62
  11. package/example-carve/README.md +14 -14
  12. package/example-carve/app/package-lock.json +75 -12
  13. package/example-carve/app/package.json +2 -2
  14. package/example-carve/legacy-tf/versions.tf +1 -1
  15. package/example-choudoufu-estate/README.md +4 -4
  16. package/example-choudoufu-estate/chant.workspace.json +32 -0
  17. package/example-choudoufu-estate/scripts/choudoufu-up.sh +21 -0
  18. package/example-flux-estate/README.md +5 -5
  19. package/example-flux-estate/app-a/package.json +2 -2
  20. package/example-flux-estate/app-b/package.json +2 -2
  21. package/example-flux-estate/chant.workspace.json +27 -0
  22. package/example-flux-estate/control-plane/package.json +2 -2
  23. package/example-flux-estate/package-lock.json +139 -62
  24. package/example-k8s/README.md +20 -20
  25. package/example-k8s/chant.config.ts +1 -1
  26. package/example-k8s/ops/k3d-apply.op.ts +1 -1
  27. package/example-k8s/package-lock.json +135 -217
  28. package/example-k8s/package.json +2 -3
  29. package/example-terraform-estate/README.md +109 -0
  30. package/example-terraform-estate/backends/backend.s3.tf +10 -0
  31. package/example-terraform-estate/chant.workspace.json +22 -0
  32. package/example-terraform-estate/envs/dev/README.md +5 -0
  33. package/example-terraform-estate/envs/prod/data.tf +18 -0
  34. package/example-terraform-estate/envs/prod/network.tf +80 -0
  35. package/example-terraform-estate/envs/prod/outputs.tf +13 -0
  36. package/example-terraform-estate/envs/prod/provider.tf +10 -0
  37. package/example-terraform-estate/envs/prod/services.tf +21 -0
  38. package/example-terraform-estate/envs/prod/storage.tf +31 -0
  39. package/example-terraform-estate/envs/prod/variables.tf +17 -0
  40. package/example-terraform-estate/envs/prod/versions.tf +10 -0
  41. package/example-terraform-estate/modules/service/main.tf +58 -0
  42. package/example-terraform-estate/modules/service/outputs.tf +7 -0
  43. package/example-terraform-estate/modules/service/variables.tf +16 -0
  44. package/example-terraform-estate/modules/service/versions.tf +10 -0
  45. package/example-terraform-estate/platform/iam.tf +40 -0
  46. package/example-terraform-estate/platform/kms.tf +16 -0
  47. package/example-terraform-estate/platform/outputs.tf +7 -0
  48. package/example-terraform-estate/platform/variables.tf +10 -0
  49. package/example-terraform-estate/platform/versions.tf +10 -0
  50. package/example-writes/README.md +5 -5
  51. package/example-writes/chant.config.ts +1 -1
  52. package/example-writes/ops/apply.op.ts +1 -1
  53. package/example-writes/ops/floci.op.ts +2 -2
  54. package/example-writes/ops/reconcile.op.ts +1 -1
  55. package/example-writes/package-lock.json +65 -178
  56. package/example-writes/package.json +2 -3
  57. package/package.json +7 -6
  58. package/web/app.js +439 -34
  59. package/web/behaviour-scale.js +32 -0
  60. package/web/behaviour-scale.test.js +39 -0
  61. package/web/index.html +23 -1
  62. package/web/pending.js +67 -0
  63. package/web/pending.test.js +62 -0
  64. package/web/read-cost.js +49 -0
  65. package/web/read-cost.test.js +76 -0
  66. package/web/refresh-queue.js +26 -0
  67. package/web/refresh-queue.test.js +54 -0
  68. package/example-choudoufu-estate/.behold.json +0 -8
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,
@@ -32,6 +35,7 @@ import {
32
35
  scaleEnds,
33
36
  totalsFor,
34
37
  totalsText,
38
+ deltaRows,
35
39
  } from "./behaviour-scale.js";
36
40
  import { addPanelTab, initPanel, setPanelTab, togglePanelCollapsed, isPanelCollapsed } from "./panel.js";
37
41
  // #254: the carve walkthrough's stepper — everything it DECIDES is a pure
@@ -199,6 +203,12 @@ function recolorNodesByCategory(ir) {
199
203
  // unpriced count has to agree with what is on the canvas.
200
204
  if (plan.mode !== "drift" && paint.unpriced) g.setAttribute("data-unpriced", "1");
201
205
  else g.removeAttribute("data-unpriced");
206
+ // #429: and the card an overlay never observed because its member has no
207
+ // live half. Only on an overlay: on the source graph NO card has a status
208
+ // and the mark would be on every one of them, saying nothing.
209
+ const onOverlay = !!(lastMeta && lastMeta.env);
210
+ const node = graphIr.nodes.find((n) => n.id === id);
211
+ g.toggleAttribute("data-no-live", onOverlay && !!node && !(node.attrs && node.attrs._status));
202
212
  // Classify each element ONCE by the pinhole token it rode on (data-cat role), then apply
203
213
  // the role's colour on every pass. This is what makes it recolour on theme switch: after
204
214
  // the first pass the fill is a hex (not a --pin-* var), so we must key off the marker, not
@@ -282,11 +292,11 @@ if (staticMode) {
282
292
 
283
293
  /** Canonical key for a read URL — path + the lens params (whitelisted, sorted)
284
294
  * that select a distinct snapshot. MUST match src/export.ts `canonicalKey`. */
285
- const LENS_PARAMS = ["collapse", "components", "detail", "env", "logical", "ops", "radial", "runtime", "tier"];
295
+ const LENS_PARAMS = ["collapse", "components", "detail", "env", "logical", "ops", "radial", "runtime", "stacks", "tier"];
286
296
  function canonicalKey(path, params) {
287
297
  // Components + logical + ops views ignore detail/radial — drop them so they
288
298
  // 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";
299
+ const flat = params.get("components") === "1" || params.get("logical") === "1" || params.get("ops") === "1" || params.get("stacks") === "1";
290
300
  const q = LENS_PARAMS.filter((k) => params.has(k) && !(flat && (k === "detail" || k === "radial")))
291
301
  .map((k) => `${k}=${params.get(k)}`)
292
302
  .join("&");
@@ -329,6 +339,29 @@ function vocabularyNote() {
329
339
  const v = lastMeta && lastMeta.vocabulary;
330
340
  return (v && v.note) || "";
331
341
  }
342
+ // #396 item 5: the inspect pane's LIVE section prints `ownership` raw, and
343
+ // `ownership` is a word two different readers write into the same field.
344
+ // chant's live reader answers it from the resource's own marker — `owned` /
345
+ // `foreign`, which is chant's contract for that field (chant graph-ir.d.ts).
346
+ // src/choudoufu-live.ts has no marker to read: it writes the same two words for
347
+ // choudoufu's `bound[]` and `unowned[]`, so an unowned card read `status:
348
+ // unowned` and, two rows below it, `ownership: foreign` — chant's word for
349
+ // choudoufu's state, the last of the four #393 item 8 did not reach.
350
+ //
351
+ // So this row joins the legend, the counts and the status row in being rendered
352
+ // through the estate's own vocabulary rather than printed. Translated ONLY when
353
+ // the estate's members are all of a kind that has its own words for these
354
+ // colours (`vocabulary.of`): on a chant estate, and on a mixed one — which
355
+ // keeps chant's words on the legend by design and says so in its tooltip — the
356
+ // value is printed exactly as it is today, which is the point. The mapping is
357
+ // value → the colour that value means → that colour's word, so it needs no
358
+ // table of its own beyond the two words chant's field can hold.
359
+ const OWNERSHIP_STATUS = { owned: "good", foreign: "warn" };
360
+ function ownershipLabel(value) {
361
+ const v = lastMeta && lastMeta.vocabulary;
362
+ if (!v || !v.labels || !v.of || v.of === "chant" || v.of === "mixed") return value;
363
+ return v.labels[OWNERSHIP_STATUS[value]] || value;
364
+ }
332
365
  // #404: whether a plan was read for this overlay, and how many cards it would
333
366
  // change. `{read: false}` and `{read: true, drifted: 0}` are DIFFERENT answers
334
367
  // — "nobody looked" versus "looked, nothing drifted" — and the legend must not
@@ -695,6 +728,15 @@ function inspect(node) {
695
728
  // statement about this entity is legible — and where a figure without its
696
729
  // provenance would be visible as the contract violation it is.
697
730
  renderBehaviourSection(section, blockOf(node));
731
+ // #402: what the FILE says this entity costs, beside what the account does.
732
+ // On a card the account no longer holds this is the only behaviour row, and
733
+ // it says so rather than reading as the card's cost.
734
+ const declared = node.attrs && node.attrs._behaviourDeclared;
735
+ if (declared && declared.cost) {
736
+ const add = section("behaviour · declared");
737
+ add("cost", figureWithBadge(fmtCost(declared.cost.perHour, declared.cost.currency), [declared], behaviourMeta()));
738
+ if (!blockOf(node)) add("live", "no live figure — the prediction of the account does not include this entity");
739
+ }
698
740
 
699
741
  // Render diff (#146's deferred half): what `chant helm diff <digest> <env>
700
742
  // --live --json` said about this chart's PINNED render. Never rely on the
@@ -708,7 +750,7 @@ function inspect(node) {
708
750
  if (node.physicalId || node.ownership) {
709
751
  const live = section("live");
710
752
  if (node.physicalId) live("physical id", node.physicalId);
711
- if (node.ownership) live("ownership", node.ownership);
753
+ if (node.ownership) live("ownership", ownershipLabel(node.ownership)); // #396 item 5: the estate's own word for it
712
754
  } else if (liveStatus) {
713
755
  // The colour alone doesn't carry chant's verdict or its reasoning — spell
714
756
  // both out here (never rely on the node's colour alone, #57 accessibility
@@ -826,15 +868,22 @@ function inspect(node) {
826
868
  const attrKeys = Object.keys(node.attrs || {}).filter((k) => !k.startsWith("_") && !(k === "release" && release));
827
869
  if (attrKeys.length) {
828
870
  const decl = section("declared");
829
- // #393 item 10: an UNOWNED choudoufu card leads with the write that adopts
830
- // the object — the two marker tags choudoufu named (`adopt_tofu_estate` /
831
- // `adopt_tofu_address`), or its own one-line command if a release ever
832
- // sends one. behold composes neither (src/choudoufu-live.ts); this puts
833
- // the line first and makes it copyable, the way the moves panel already
834
- // does for a `live-mv` line. The paragraph explaining the omission — which
835
- // used to be the whole section, with the tags under it as JSON — follows.
871
+ // #393 item 10: a choudoufu card that can be adopted leads with the write
872
+ // that does it — the two marker tags choudoufu named (`adopt_tofu_estate` /
873
+ // `adopt_tofu_address`), or its own one-line command when it sends one.
874
+ // behold composes neither (src/choudoufu-live.ts); this puts the line
875
+ // first and makes it copyable, the way the moves panel already does for a
876
+ // `live-mv` line. The paragraph explaining the omission follows.
877
+ //
878
+ // #413: both kinds reach here now. An UNOWNED row carries the marker pair,
879
+ // and an adoptable one carries choudoufu's own command — so the tooltip
880
+ // says which of the two this is rather than claiming tags either way.
836
881
  const adoptable = node.lexicon === "choudoufu" && typeof node.attrs.adopt === "string" ? "adopt" : null;
837
- if (adoptable) copyableRow(decl, adoptable, node.attrs.adopt, "Copy the tags that adopt this object");
882
+ if (adoptable) {
883
+ const isCommand = /\s/.test(node.attrs.adopt.trim()) && !node.attrs.adopt.startsWith("tofu-estate=");
884
+ copyableRow(decl, adoptable, node.attrs.adopt,
885
+ isCommand ? "Copy the command that adopts this object" : "Copy the tags that adopt this object");
886
+ }
838
887
  for (const k of attrKeys) {
839
888
  if (k === adoptable) continue;
840
889
  decl(k, fmtValue(node.attrs[k]));
@@ -994,7 +1043,7 @@ function renderObserved(panel, o, health, healthDetail) {
994
1043
  if (o.type) add("type", o.type);
995
1044
  if (o.status) add("status", o.status, HEALTH_COLOR[health] || undefined);
996
1045
  if (o.physicalId) add("physical id", o.physicalId);
997
- if (o.ownership) add("ownership", o.ownership);
1046
+ if (o.ownership) add("ownership", ownershipLabel(o.ownership)); // #396 item 5: same, off /api/diff
998
1047
  if (o.lastUpdated) add("last updated", o.lastUpdated);
999
1048
  // What the object's own controller says is wrong with it (#86, chant#1401).
1000
1049
  // Placed with health and status rather than among the attributes below,
@@ -1259,6 +1308,13 @@ const ZOOM_OPTS = [
1259
1308
  // same chant source), so it sits last, and only appears when the served
1260
1309
  // estate has actually emitted Ops — `opsAvailable`, from /api/project.
1261
1310
  ["zoom: ops", "ops"],
1311
+ // The apply order (#426): the cross-stack ordering chant already computes at
1312
+ // synthesis — which lexicon partition must apply before which. A different
1313
+ // axis again, like `ops`: neither coarser nor finer than a resource, and the
1314
+ // only stop whose edges are about TIME rather than reference. Single-project
1315
+ // only and gated on `stacksCapable`, because two members' `aws` stacks are
1316
+ // not one stack.
1317
+ ["zoom: apply order", "stacks"],
1262
1318
  ];
1263
1319
  const ZOOM_DETAIL = { composites: 1, resources: 2, attributes: 3, runtime: 3 };
1264
1320
  /** How many emitted Ops the served estate has (0 = the stop doesn't exist).
@@ -1271,18 +1327,28 @@ let opsAvailable = 0;
1271
1327
  * Kubernetes tier that could only ever answer "nothing below the declaration
1272
1328
  * boundary". */
1273
1329
  let runtimeCapable = false;
1330
+ /** #422: the members whose live read has not landed yet, off the progressive
1331
+ * overlay's `meta.pending`. Empty on every non-progressive load, which is every
1332
+ * single-project serve and every static snapshot. */
1333
+ let pendingMembers = [];
1334
+ let pendingTotal = 0;
1335
+ /** #426: whether the apply-order stop is worth offering — /api/project's
1336
+ * `stacksCapable`, which is false for an estate and for a single-lexicon
1337
+ * project, where the answer could only ever be one box. */
1338
+ let stacksCapable = false;
1274
1339
  /** The zoom stops this project can actually offer — `runtime` needs an env AND
1275
1340
  * a member whose substrate has an owner chain, `ops` needs emitted op.json
1276
1341
  * files. Both the panel and ⌘K read this, so a stop never appears in one
1277
1342
  * surface and not the other. */
1278
1343
  function availableZooms() {
1279
- return ZOOM_OPTS.filter(([, z]) => (z === "runtime" ? Boolean(view.env) && runtimeCapable : z === "ops" ? opsAvailable > 0 : true));
1344
+ return ZOOM_OPTS.filter(([, z]) => (z === "runtime" ? Boolean(view.env) && runtimeCapable : z === "ops" ? opsAvailable > 0 : z === "stacks" ? stacksCapable : true));
1280
1345
  }
1281
1346
  /** Current zoom value from (components, logical, ops, detail). */
1282
1347
  function zoomValue() {
1283
1348
  if (view.components) return "components";
1284
1349
  if (view.logical) return "logical";
1285
1350
  if (view.ops) return "ops";
1351
+ if (view.stacks) return "stacks";
1286
1352
  if (view.runtime) return "runtime";
1287
1353
  return { 1: "composites", 2: "resources", 3: "attributes" }[view.detail] ?? "resources";
1288
1354
  }
@@ -1291,8 +1357,9 @@ function applyZoom(z) {
1291
1357
  view.components = z === "components";
1292
1358
  view.logical = z === "logical";
1293
1359
  view.ops = z === "ops";
1360
+ view.stacks = z === "stacks";
1294
1361
  view.runtime = z === "runtime";
1295
- if (z !== "components" && z !== "logical" && z !== "ops") view.detail = ZOOM_DETAIL[z] ?? 2;
1362
+ if (z !== "components" && z !== "logical" && z !== "ops" && z !== "stacks") view.detail = ZOOM_DETAIL[z] ?? 2;
1296
1363
  }
1297
1364
 
1298
1365
  // --- Floating control panel content ---------------------------------------
@@ -1539,12 +1606,21 @@ function renderCarveState(host, state) {
1539
1606
  */
1540
1607
  function copyableRow(add, key, text, title) {
1541
1608
  const wrap = document.createElement("span");
1542
- wrap.style.cssText = "display:flex;gap:6px;align-items:baseline;min-width:0";
1609
+ // `center`, not `baseline`: an `overflow: hidden` flex item takes its bottom
1610
+ // margin edge as its baseline, so the clipped line below would have dragged
1611
+ // the copy button down with it.
1612
+ wrap.style.cssText = "display:flex;gap:6px;align-items:center;min-width:0";
1543
1613
  const line = document.createElement("code");
1544
1614
  line.className = "grow";
1545
- line.style.cssText = "flex:1;min-width:0;overflow-wrap:anywhere";
1615
+ // #396 item 7c: ONE line, clipped. `tofu-estate=… tofu-address=…` wrapped
1616
+ // onto three lines in the 260px pane and pushed everything the card had to
1617
+ // say off the bottom — for a string nobody reads off the screen anyway,
1618
+ // because the copy button beside it is how it gets used. The whole of it is
1619
+ // on this row's tooltip, on the button's, and in the clipboard.
1620
+ line.style.cssText = "flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis";
1546
1621
  line.textContent = text;
1547
- const copy = actButton("copy", () => copyToClipboard(text, copy), title);
1622
+ line.title = text;
1623
+ const copy = actButton("copy", () => copyToClipboard(text, copy), title ? `${title}:\n${text}` : text);
1548
1624
  wrap.append(line, copy);
1549
1625
  add(key, wrap);
1550
1626
  }
@@ -1676,6 +1752,35 @@ function renderBehaviourTotals(host) {
1676
1752
  // precedence in one place rather than at each call site.
1677
1753
  row(b.total ? "estate · engine total" : "estate", all, b.sum, b.total, b.total ? "the engine stated this estate total itself; behold added nothing" : "behold's sum over the engine's per-entity figures");
1678
1754
  if (mode === "headroom") host.appendChild(panelMuted("min and median computed by behold over the engine's per-entity figures — no engine states a headroom aggregate."));
1755
+ if (mode === "cost") renderBehaviourDelta(host, b);
1756
+ }
1757
+
1758
+ /**
1759
+ * Live versus declared (#402): what the account costs as it stands, what the
1760
+ * file says it should, and the difference. Present only when the overlay was
1761
+ * asked for at a traffic level, because both figures are predictions *at* one.
1762
+ * The wording of every row is `deltaRows`' (web/behaviour-scale.js).
1763
+ */
1764
+ function renderBehaviourDelta(host, b) {
1765
+ const rows = deltaRows(b);
1766
+ if (!rows.length) return;
1767
+ host.appendChild(panelHeading(`live versus declared${b.at ? ` · at ${b.at.traffic}` : ""}`));
1768
+ for (const t of rows) {
1769
+ const r = document.createElement("div");
1770
+ r.className = "behaviour-total";
1771
+ const name = document.createElement("div");
1772
+ name.className = "grow";
1773
+ name.style.fontWeight = "600";
1774
+ name.textContent = t.label;
1775
+ r.appendChild(name);
1776
+ for (const [word, text] of [["live", t.live], ["declared", t.declared], ["delta", t.delta]]) {
1777
+ const line = document.createElement("div");
1778
+ line.textContent = `${word} ${text}`;
1779
+ r.appendChild(line);
1780
+ }
1781
+ host.appendChild(r);
1782
+ }
1783
+ host.appendChild(panelMuted("delta is live minus declared. A negative one means the account holds less than the file declares."));
1679
1784
  }
1680
1785
 
1681
1786
  function renderPanelScope() {
@@ -2073,6 +2178,23 @@ function renderPanelModel() {
2073
2178
  // bound and is already counted on the row above. The row appears only when
2074
2179
  // a plan was actually read; where it was not, the legend says so in words
2075
2180
  // and prints no number, because "0 drifted" would be a claim nobody made.
2181
+ // #429: a card with NO `_status` at all. pinhole's painter falls back to
2182
+ // `neutral` for an unrecognised value, `undefined` included, so a member
2183
+ // with no live half — a Terraform root, whose `live`/`overlay`/`env` are
2184
+ // stripped in src/terraform-member.ts — paints exactly like a card behold
2185
+ // looked at and could not see. It is in no legend bucket either, so the
2186
+ // colour claimed 37 unobserved cards on an estate whose legend counted 2.
2187
+ //
2188
+ // Named rather than folded into `unobserved`, because they are different
2189
+ // claims: "I looked and could not see" against "there is nothing here to
2190
+ // look at". Same argument #399 makes for an unpriced card, and the same
2191
+ // answer — mark it, do not merely colour it.
2192
+ const noLive = ir.nodes.filter((n) => !(n.attrs && n.attrs._status)).length;
2193
+ if (noLive) {
2194
+ const row = panelDotRow("var(--muted)", "no live half", String(noLive));
2195
+ 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.";
2196
+ host.appendChild(row);
2197
+ }
2076
2198
  const dm = driftMeta();
2077
2199
  if (dm && dm.read) {
2078
2200
  host.appendChild(panelDotRow("var(--degraded)", "drifted (the plan would change it)", String(dm.drifted)));
@@ -2462,6 +2584,36 @@ function markOperatorCards(ir) {
2462
2584
  * estates this can appear on the corner is free; the guard below still skips a
2463
2585
  * card that already carries one of those, rather than drawing over it.
2464
2586
  */
2587
+ /**
2588
+ * The cards whose member is still being read (#422).
2589
+ *
2590
+ * The same post-render stamp `markCarvedCards`/`markDriftedCards` use, and for
2591
+ * the same reason: pinhole's SVG arrives as a string and is replaced on every
2592
+ * render, so a mark is re-applied here rather than asked for at layout time.
2593
+ *
2594
+ * A dashed outline and a muted corner rather than a fill, because the fill is
2595
+ * the status channel and this card HAS no status yet — painting one would be
2596
+ * the claim the whole milestone exists to avoid. `_pendingRead` rides its own
2597
+ * attr for the same reason: a fifth `_status` value falls back to neutral in
2598
+ * pinhole's painter, which reads as "unobserved", which is a different and
2599
+ * wrong thing.
2600
+ */
2601
+ function markPendingCards(ir) {
2602
+ const svg = document.querySelector("#graph svg");
2603
+ if (!svg) return;
2604
+ for (const n of (ir && ir.nodes) || []) {
2605
+ const g = svg.querySelector('[data-node-id="' + CSS.escape(n.id) + '"]');
2606
+ if (!g) continue;
2607
+ const pending = !!(n.attrs && n.attrs._pendingRead);
2608
+ g.toggleAttribute("data-pending-read", pending);
2609
+ if (pending && !g.querySelector("title")) {
2610
+ const t = document.createElementNS("http://www.w3.org/2000/svg", "title");
2611
+ t.textContent = "still reading this member — not yet observed";
2612
+ g.appendChild(t);
2613
+ }
2614
+ }
2615
+ }
2616
+
2465
2617
  function markDriftedCards(ir) {
2466
2618
  const svg = document.querySelector("#graph svg");
2467
2619
  if (!svg) return;
@@ -2559,6 +2711,11 @@ function renderStatusbar() {
2559
2711
  if (view.stack) parts.push(`stack: ${view.stack}`);
2560
2712
  if (axes.tier) parts.push(`tier: ${axes.tier}`);
2561
2713
  if (view.radial && !view.components && !view.logical && !view.ops) parts.push("radial");
2714
+ // #422: what the picture is still waiting on, while it is waiting. Ahead of
2715
+ // the zoom note, because a half-read estate is the more urgent fact about
2716
+ // what is on screen.
2717
+ const stillReading = pendingLine(pendingMembers, pendingTotal);
2718
+ if (stillReading) parts.push(stillReading);
2562
2719
  el.textContent = parts.join(" · ");
2563
2720
  // #131: why this level rendered empty, or as the one below it. The server
2564
2721
  // decides (src/zoom-notes.ts) — the SPA never infers it, so the note always
@@ -3388,6 +3545,7 @@ function render(ir, svg, m) {
3388
3545
  markPlayhead(ir); // #284 item 2: same, for the step the run is sitting on
3389
3546
  markOperatorCards(ir); // #234 free rider: same, for the operating loop's home
3390
3547
  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
3548
+ markPendingCards(ir); // #422: and the cards whose member has not answered yet
3391
3549
  applyLayout(); // #228: last, so the hand-placed deltas ride on top of every other pass
3392
3550
  renderDial();
3393
3551
  }
@@ -3483,7 +3641,13 @@ function setupGraphViewBox(svg) {
3483
3641
  const a = (svg.getAttribute("viewBox") || "").split(/\s+/).map(Number);
3484
3642
  if (a.length === 4 && a.every((n) => !Number.isNaN(n))) {
3485
3643
  vbInit = a.slice();
3486
- vb = a.slice();
3644
+ // #396 item 2: land on the fit that keeps the panel's footprint clear, not
3645
+ // on the raw box — a re-render (a zoom change, an env change, the SSE
3646
+ // re-pull) goes through here and never through fitGraph, so the first
3647
+ // column would slide back under the panel at every one of them.
3648
+ vb = fittedViewBox() || a.slice();
3649
+ vbAtFit = true;
3650
+ applyVB();
3487
3651
  } else {
3488
3652
  vbInit = vb = null;
3489
3653
  }
@@ -3494,11 +3658,178 @@ function currentSvg() {
3494
3658
  function applyVB() {
3495
3659
  const s = currentSvg();
3496
3660
  if (s && vb) s.setAttribute("viewBox", vb.join(" "));
3661
+ scaleBoxLabels(); // #396 item 7b: box titles/badges keep a legible size at every zoom
3662
+ }
3663
+
3664
+ // #396 item 2: the panel is `position: fixed` and floats OVER the graph pane
3665
+ // (index.html), so "fit the pane" put the leftmost column of cards underneath
3666
+ // it — a whole column of the terralith, half waterpark's `github` box — at
3667
+ // boot and after every re-render. The panel is furniture the reader cannot see
3668
+ // through, so the fit's target is the pane MINUS whatever the panel is
3669
+ // currently covering.
3670
+ //
3671
+ // Read at fit time, never remembered: the panel drags, docks to either side and
3672
+ // collapses to its tab bar, and a fit computed against where it used to be is
3673
+ // the same bug with a different offset. Collapsed or hidden → no footprint, and
3674
+ // the fit is the whole pane exactly as before.
3675
+ //
3676
+ // Horizontal only. The panel is a tall palette that docks left or right; a
3677
+ // top/bottom dock still leaves the full width, and insetting vertically for it
3678
+ // would shrink the graph for a strip the cards mostly clear anyway.
3679
+ const FIT_PANEL_MAX = 0.45; // the panel can never claim more than this of the pane
3680
+
3681
+ function panelInsets(pane) {
3682
+ const none = { left: 0, right: 0 };
3683
+ const p = document.getElementById("panel");
3684
+ if (!p || p.classList.contains("collapsed")) return none;
3685
+ const cs = getComputedStyle(p);
3686
+ if (cs.display === "none" || cs.visibility === "hidden" || Number(cs.opacity) === 0) return none;
3687
+ const r = p.getBoundingClientRect();
3688
+ if (!r.width || !r.height) return none;
3689
+ // Nothing to inset for a panel that has been dragged clear of the graph pane.
3690
+ if (r.bottom <= pane.top || r.top >= pane.bottom || r.right <= pane.left || r.left >= pane.right) return none;
3691
+ const cap = pane.width * FIT_PANEL_MAX;
3692
+ // Whichever side it is nearer to is the side it eats.
3693
+ return (r.left + r.right) / 2 < (pane.left + pane.right) / 2
3694
+ ? { left: Math.min(cap, Math.max(0, r.right - pane.left)), right: 0 }
3695
+ : { left: 0, right: Math.min(cap, Math.max(0, pane.right - r.left)) };
3696
+ }
3697
+
3698
+ /**
3699
+ * The viewBox that fits the whole graph into the pane's UNCOVERED part.
3700
+ *
3701
+ * The returned box is given the pane's own aspect ratio, so `xMidYMid meet`
3702
+ * letterboxes nothing and the pane's pixels map to viewBox units at one known
3703
+ * scale: the graph is then placed by arithmetic — centred inside the band
3704
+ * between the insets, centred vertically — instead of wherever `meet` would
3705
+ * have dropped it. With no insets the result is pixel-identical to the old
3706
+ * `vb = vbInit`, which is why nothing without a panel over it moves.
3707
+ */
3708
+ function fittedViewBox() {
3709
+ if (!vbInit) return null;
3710
+ const host = document.getElementById("graph");
3711
+ if (!host) return null;
3712
+ const pane = host.getBoundingClientRect();
3713
+ const [x0, y0, w0, h0] = vbInit;
3714
+ if (!(pane.width > 0 && pane.height > 0 && w0 > 0 && h0 > 0)) return null;
3715
+ const { left, right } = panelInsets(pane);
3716
+ const band = Math.max(1, pane.width - left - right);
3717
+ const s = Math.min(band / w0, pane.height / h0);
3718
+ const vw = pane.width / s;
3719
+ const vh = pane.height / s;
3720
+ return [x0 - left / s - (band / s - w0) / 2, y0 - (vh - h0) / 2, vw, vh];
3497
3721
  }
3722
+
3498
3723
  function fitGraph() {
3499
- if (vbInit) {
3500
- vb = vbInit.slice();
3501
- applyVB();
3724
+ if (!vbInit) return;
3725
+ vb = fittedViewBox() || vbInit.slice();
3726
+ vbAtFit = true;
3727
+ applyVB();
3728
+ }
3729
+
3730
+ // #396 item 2: the fit now depends on the pane — its size, and what the panel
3731
+ // covers of it — where `vbInit` alone depended on neither, so a graph sitting
3732
+ // at fit has to be re-fitted when the pane moves under it. It moves more often
3733
+ // than a window resize: the statusbar's note wraps onto a second line, the
3734
+ // inspect pane is dragged wider, a panel tab's content grows. Only while the
3735
+ // view IS the fit: a pan or a zoom takes ownership of the viewBox, and nothing
3736
+ // here may pull it back out from under the reader's hands.
3737
+ let vbAtFit = false;
3738
+ function watchPaneForFit(host) {
3739
+ if (typeof ResizeObserver === "undefined") return;
3740
+ new ResizeObserver(() => {
3741
+ if (vbAtFit) fitGraph();
3742
+ }).observe(host);
3743
+ }
3744
+
3745
+ // --- #396 item 7b: box titles and badges that survive the fit ---------------
3746
+ //
3747
+ // pinhole draws a group box's title at a flat `font-size="12"` and its badge at
3748
+ // `11`, in viewBox UNITS. That reads as 12px on a graph whose fit is 1:1 and as
3749
+ // 7px on terralith-4, whose box is 8000 units wide — the one label that says
3750
+ // which member 301 cards belong to, and the one that counts them, are the two
3751
+ // things on the canvas a person cannot read at the zoom they arrive at.
3752
+ //
3753
+ // So the label is given a floor in SCREEN pixels: its size in units is raised
3754
+ // by the inverse of the current scale until it renders at its natural size, and
3755
+ // never below it, so anything already legible is untouched (zoomed IN, the
3756
+ // attribute is left at 12 and grows with the graph, exactly as before). The
3757
+ // anchor never moves — x, y and `text-anchor` are pinhole's — so a title stays
3758
+ // welded to its box's top-left corner and a badge to its top-right through
3759
+ // every pan and zoom; only the type gets bigger.
3760
+ //
3761
+ // The floor yields to the box: a label may not grow past a fraction of the
3762
+ // box's height, nor past its width. A sub-box too small to hold readable type
3763
+ // at this zoom says so by staying small, rather than by writing across its
3764
+ // neighbours.
3765
+ const BOX_LABEL_GUTTER = 34; // units below a box's top edge that the title row occupies
3766
+ const BOX_LABEL_MAX_H = 8; // a label may be at most box height / this
3767
+ const BOX_LABEL_EM = 0.62; // rough advance width per char, as a fraction of the size
3768
+
3769
+ let boxLabelCache = { svg: null, labels: [] };
3770
+ let boxLabelScale = null;
3771
+
3772
+ /** The `<text>` elements pinhole put in each group box's title gutter, with the
3773
+ * box they belong to and the size it drew them at. Matched by geometry rather
3774
+ * than by sibling order: a box's optional identity mark sits between the title
3775
+ * and the badge, and #228's layout pass lifts the rect and its title into a
3776
+ * wrapper `<g>` after the render, so neither order nor a common parent holds.
3777
+ * What geometry alone would let through — a card's own text, an edge chip's —
3778
+ * is excluded by the ancestor it sits under. */
3779
+ function boxLabelsOf(svg) {
3780
+ if (boxLabelCache.svg === svg) return boxLabelCache.labels;
3781
+ const labels = [];
3782
+ const boxes = [];
3783
+ for (const rect of svg.querySelectorAll("rect[data-group-id]")) {
3784
+ const b = {
3785
+ x: parseFloat(rect.getAttribute("x")),
3786
+ y: parseFloat(rect.getAttribute("y")),
3787
+ w: parseFloat(rect.getAttribute("width")),
3788
+ h: parseFloat(rect.getAttribute("height")),
3789
+ };
3790
+ if ([b.x, b.y, b.w, b.h].every((n) => Number.isFinite(n))) boxes.push(b);
3791
+ }
3792
+ if (boxes.length) {
3793
+ for (const t of svg.querySelectorAll("text")) {
3794
+ const x = parseFloat(t.getAttribute("x"));
3795
+ const y = parseFloat(t.getAttribute("y"));
3796
+ // pinhole's own size, stashed the first time so a re-index can never
3797
+ // read a size this pass already raised as if it were the natural one.
3798
+ const base = parseFloat(t.dataset.pinFont ?? t.getAttribute("font-size"));
3799
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(base)) continue;
3800
+ // Not a card's own text (inside its `g[data-node-id]`), not an edge
3801
+ // chip's (inside the edge's `g`), not a radial wedge label behold adds
3802
+ // itself. What is left at a box's title row IS the box's title row.
3803
+ if (t.closest("[data-node-id], [data-edge-from], [data-edge-to], #radial-labels")) continue;
3804
+ const box = boxes.find((b) => y > b.y && y <= b.y + BOX_LABEL_GUTTER && x >= b.x - 2 && x <= b.x + b.w + 2);
3805
+ if (!box) continue;
3806
+ t.dataset.pinFont = String(base);
3807
+ labels.push({ el: t, box, base });
3808
+ }
3809
+ }
3810
+ boxLabelCache = { svg, labels };
3811
+ boxLabelScale = null;
3812
+ return labels;
3813
+ }
3814
+
3815
+ /** Re-size every box label for the current zoom. Cheap and idempotent: the
3816
+ * scale is remembered, so the pan handler's stream of `applyVB` calls (which
3817
+ * move the viewBox without changing its width) touches no attribute at all. */
3818
+ function scaleBoxLabels() {
3819
+ const svg = currentSvg();
3820
+ if (!svg || !vb) return;
3821
+ const labels = boxLabelsOf(svg);
3822
+ if (!labels.length) return;
3823
+ const pane = svg.getBoundingClientRect();
3824
+ if (!(pane.width > 0 && pane.height > 0)) return;
3825
+ const scale = Math.min(pane.width / vb[2], pane.height / vb[3]);
3826
+ if (!(scale > 0) || scale === boxLabelScale) return;
3827
+ boxLabelScale = scale;
3828
+ for (const l of labels) {
3829
+ const chars = Math.max(4, (l.el.textContent || "").length);
3830
+ const cap = Math.max(l.base, Math.min(l.box.h / BOX_LABEL_MAX_H, l.box.w / (chars * BOX_LABEL_EM)));
3831
+ const size = Math.max(l.base, Math.min(l.base / scale, cap));
3832
+ l.el.setAttribute("font-size", String(Math.round(size * 10) / 10));
3502
3833
  }
3503
3834
  }
3504
3835
 
@@ -3537,6 +3868,7 @@ function revealNode(id) {
3537
3868
  const w = Math.min(vbInit[2], Math.max(box.width * REVEAL_CARDS_ACROSS, vbInit[2] / 60));
3538
3869
  const h = w * (vbInit[3] / vbInit[2]);
3539
3870
  vb = [box.x + box.width / 2 - w / 2, box.y + box.height / 2 - h / 2, w, h];
3871
+ vbAtFit = false;
3540
3872
  applyVB();
3541
3873
  return true;
3542
3874
  }
@@ -3555,6 +3887,7 @@ function ensureZoomControls(host) {
3555
3887
  }
3556
3888
  if (zoomWired) return;
3557
3889
  zoomWired = true;
3890
+ watchPaneForFit(host);
3558
3891
  host.addEventListener(
3559
3892
  "wheel",
3560
3893
  (e) => {
@@ -3576,6 +3909,7 @@ function ensureZoomControls(host) {
3576
3909
  vb[1] = cy - ((cy - vb[1]) * nh) / vb[3];
3577
3910
  vb[2] = nw;
3578
3911
  vb[3] = nh;
3912
+ vbAtFit = false;
3579
3913
  applyVB();
3580
3914
  },
3581
3915
  { passive: false },
@@ -3608,6 +3942,7 @@ function ensureZoomControls(host) {
3608
3942
  }
3609
3943
  vb[0] -= (dx / r.width) * vb[2];
3610
3944
  vb[1] -= (dy / r.height) * vb[3];
3945
+ vbAtFit = false;
3611
3946
  px = e.clientX;
3612
3947
  py = e.clientY;
3613
3948
  applyVB();
@@ -4211,7 +4546,8 @@ function renderPreconditionError(body) {
4211
4546
  }
4212
4547
 
4213
4548
  // Fetch the current view (source graph, or the picked env's live overlay).
4214
- async function load(opts = {}) {
4549
+ const load = createRefreshQueue(loadOnce);
4550
+ async function loadOnce(opts = {}, isCurrent = () => true) {
4215
4551
  const meta = document.getElementById("meta");
4216
4552
  // A background settle re-pull (post-apply) shouldn't flash the meta/overlay.
4217
4553
  if (!opts.quiet) {
@@ -4230,6 +4566,14 @@ async function load(opts = {}) {
4230
4566
  // cross-substrate entity overlay, which components never use.
4231
4567
  q.set("components", "1");
4232
4568
  if (view.env) q.set("env", view.env);
4569
+ } else if (view.stacks) {
4570
+ // The apply-order lens (#426): chant's cross-stack ordering, built fresh
4571
+ // for the same reason the ops lens is — it reads none of the entity
4572
+ // knobs. No env (it is computed at synthesis, with no substrate), no
4573
+ // detail, no radial. Tier rides along because a project's source can
4574
+ // branch on one, which can change which lexicons it partitions into.
4575
+ q = new URLSearchParams({ stacks: "1" });
4576
+ if (view.tier) q.set("tier", view.tier);
4233
4577
  } else if (view.ops) {
4234
4578
  // The ops lens (#284): the declared Ops, read from each Op's emitted
4235
4579
  // op.json. Built fresh rather than layered onto the entity lenses,
@@ -4263,6 +4607,11 @@ async function load(opts = {}) {
4263
4607
  } else if (view.env) {
4264
4608
  endpoint = "/api/overlay";
4265
4609
  q.set("env", view.env);
4610
+ // #422: draw the estate from source at once and recolour each member as
4611
+ // its own live read lands, instead of a blank canvas until the slowest
4612
+ // one finishes. The server only honours it for a composed estate, and
4613
+ // never in a static snapshot, where there is no stream to follow.
4614
+ if (!staticMode) q.set("progressive", "1");
4266
4615
  // The runtime tier (#86) descends below the declaration boundary. It is
4267
4616
  // live-only by nature — owner-referenced children exist in the cluster,
4268
4617
  // never in your source — so it rides the overlay and means nothing
@@ -4294,6 +4643,7 @@ async function load(opts = {}) {
4294
4643
  }
4295
4644
  const res = await apiFetch(`${endpoint}?${q}`);
4296
4645
  const body = await res.json();
4646
+ if (!isCurrent()) return; // a newer view/event is pending; do not paint the old result
4297
4647
  if (!res.ok) {
4298
4648
  // #72: a classified precondition failure (lint gate, not installed, a
4299
4649
  // tier that needs credentials) gets the calmer entry/error card instead
@@ -4314,13 +4664,19 @@ async function load(opts = {}) {
4314
4664
  autoZoomFallback = false;
4315
4665
  applyZoom("resources");
4316
4666
  renderStatusbar();
4317
- return load(opts);
4667
+ return loadOnce(opts, isCurrent);
4318
4668
  }
4319
4669
  autoZoomFallback = false;
4670
+ // #422: a progressive answer names the members it is still reading, and
4671
+ // the `member` stream above clears them one at a time. Any other answer
4672
+ // resets it, so a switch away from a progressive load never leaves a stale
4673
+ // "still reading" claim on screen.
4674
+ pendingMembers = body.meta && body.meta.mode === "progressive" ? [...(body.meta.pending || [])] : [];
4675
+ pendingTotal = pendingMembers.length;
4320
4676
  render(body.ir, body.svg, body.meta);
4321
4677
  } catch (err) {
4322
4678
  // A background settle poll must not blow away a good graph on a transient error.
4323
- if (!opts.quiet) {
4679
+ if (!opts.quiet && isCurrent()) {
4324
4680
  // Text, never innerHTML — `err.message` embeds chant's own stderr, which
4325
4681
  // is not ours to interpolate as markup (the sibling precondition card has
4326
4682
  // said so since #72; this branch had been left behind).
@@ -4434,6 +4790,7 @@ async function initPickers() {
4434
4790
  // honestly open. Both are read before the first load() below, so a non-chant
4435
4791
  // estate never paints the components apology on its way to a picture.
4436
4792
  runtimeCapable = !!info.runtimeCapable;
4793
+ stacksCapable = !!info.stacksCapable;
4437
4794
  const boot = bootZoom(info.memberKinds);
4438
4795
  if (boot !== "components") {
4439
4796
  applyZoom(boot);
@@ -4460,20 +4817,39 @@ initPickers();
4460
4817
  // No backend in a static export → no live event stream; a no-op keeps the
4461
4818
  // `events.addEventListener(...)` wiring below harmless.
4462
4819
  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
- }
4820
+ // One notification requests one refresh. Further notifications during a slow
4821
+ // read collapse to one follow-up; no wall-clock timers multiply estate scans.
4822
+ // #422: one frame per member as its live read settles. Payload-carrying, like
4823
+ // `apply` and `run` — the node set is already on screen, so this is id ->
4824
+ // status and the client repaints in place. Nothing is re-fetched: a refetch
4825
+ // here would re-run the whole estate read this stream exists to narrate.
4826
+ events.addEventListener("member", (e) => {
4827
+ let frame;
4828
+ try {
4829
+ frame = JSON.parse(e.data);
4830
+ } catch {
4831
+ return;
4832
+ }
4833
+ // The live pass itself failed. Say so on the now-line and stop claiming the
4834
+ // remaining members are still arriving, because nothing more is coming.
4835
+ if (frame.error) {
4836
+ pendingMembers = [];
4837
+ nowline(`the estate read failed — ${frame.error}`);
4838
+ renderStatusbar();
4839
+ return;
4840
+ }
4841
+ if (!lastGraphIr) return;
4842
+ const moved = applyMemberFrame(lastGraphIr, frame);
4843
+ pendingMembers = stillPending(pendingMembers, frame);
4844
+ if (moved.length) recolorNodesByCategory(lastGraphIr);
4845
+ markPendingCards(lastGraphIr);
4846
+ renderStatusbar();
4847
+ });
4848
+
4472
4849
  events.addEventListener("changed", () => {
4473
4850
  bulkDiffCache = null; // an op ran → per-node live state may have changed
4474
4851
  load();
4475
4852
  loadSubstrates(); // a bring-up (or any op) finished → re-detect readiness
4476
- scheduleSettle();
4477
4853
  });
4478
4854
 
4479
4855
  // Substrate readiness (M5, #54): is each substrate the project needs actually
@@ -4610,11 +4986,19 @@ setInterval(() => {
4610
4986
  // can't fire a second pull while one's in flight. Ref-counted — nested loads
4611
4987
  // (graph + CI + resources) only lift the scrim when the last finishes.
4612
4988
  let loadingDepth = 0;
4989
+ // #421: what the LAST read cost, shown while this one runs. The person watching
4990
+ // the scrim wants to know whether to wait or go away, and behold has never told
4991
+ // them. Fetched after a load settles rather than during one — asking the server
4992
+ // how slow it is while it is being slow helps nobody — so the line always
4993
+ // describes the previous read and never guesses at this one.
4994
+ let lastReadCost = null;
4613
4995
  function showLoading(msg) {
4614
4996
  loadingDepth++;
4615
4997
  const o = document.getElementById("loading-overlay");
4616
4998
  if (!o) return;
4617
4999
  document.getElementById("loading-msg").textContent = msg || "loading…";
5000
+ const cost = document.getElementById("loading-cost");
5001
+ if (cost) cost.textContent = lastReadCost || "";
4618
5002
  o.hidden = false;
4619
5003
  }
4620
5004
  function hideLoading() {
@@ -4622,6 +5006,27 @@ function hideLoading() {
4622
5006
  if (loadingDepth > 0) return;
4623
5007
  const o = document.getElementById("loading-overlay");
4624
5008
  if (o) o.hidden = true;
5009
+ void refreshReadCost();
5010
+ }
5011
+
5012
+ // One cheap call on counters, never on the read path. `?reads=1` is what makes
5013
+ // that true: the bare route also runs the full doctor diagnosis, whose substrate
5014
+ // probes spawn docker/gh/helm/k3d with no timeout, and this runs after every
5015
+ // load settles. A cost line is never worth breaking a load over, so every
5016
+ // failure here leaves the previous line standing.
5017
+ //
5018
+ // A static export has no backend to ask and no reads to report, so it does not
5019
+ // ask. Without this guard the bundle logs a 404 on every page load and relies on
5020
+ // the fetch failing to stay silent, which is an outcome rather than a rule.
5021
+ async function refreshReadCost() {
5022
+ if (staticMode) return;
5023
+ try {
5024
+ const res = await fetch("/api/doctor?reads=1");
5025
+ if (!res.ok) return;
5026
+ lastReadCost = readCostLine((await res.json()).reads);
5027
+ } catch {
5028
+ /* no line rather than a wrong one */
5029
+ }
4625
5030
  }
4626
5031
 
4627
5032
  // #259: the op stream is chant's own stdout, and an Op that reports as JSON