@intentius/behold 0.17.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 (60) hide show
  1. package/AGENTS.md +757 -26
  2. package/LICENSE +190 -0
  3. package/README.md +251 -187
  4. package/THIRD_PARTY.md +75 -0
  5. package/demos.json +80 -9
  6. package/dist/cli.js +5479 -1673
  7. package/example-argo-estate/package-lock.json +6 -6
  8. package/example-carve/app/package-lock.json +3 -3
  9. package/example-choudoufu-estate/.behold.json +8 -0
  10. package/example-choudoufu-estate/README.md +48 -0
  11. package/example-choudoufu-estate/monolith/behaviour.live.json +495 -0
  12. package/example-choudoufu-estate/monolith/carve.json +12 -0
  13. package/example-choudoufu-estate/monolith/main.tf +127 -0
  14. package/example-choudoufu-estate/scripts/choudoufu-down.sh +8 -0
  15. package/example-choudoufu-estate/scripts/choudoufu-up.sh +62 -0
  16. package/example-choudoufu-estate/team-a/behaviour.live.json +179 -0
  17. package/example-choudoufu-estate/team-a/main.tf +58 -0
  18. package/example-choudoufu-estate/team-b/behaviour.live.json +179 -0
  19. package/example-choudoufu-estate/team-b/main.tf +73 -0
  20. package/example-choudoufu-estate/team-c/behaviour.live.json +179 -0
  21. package/example-choudoufu-estate/team-c/main.tf +51 -0
  22. package/example-flux-estate/package-lock.json +6 -6
  23. package/example-k8s/package-lock.json +6 -6
  24. package/example-terraform-estate/README.md +109 -0
  25. package/example-terraform-estate/backends/backend.s3.tf +10 -0
  26. package/example-terraform-estate/envs/dev/README.md +5 -0
  27. package/example-terraform-estate/envs/prod/data.tf +18 -0
  28. package/example-terraform-estate/envs/prod/network.tf +80 -0
  29. package/example-terraform-estate/envs/prod/outputs.tf +13 -0
  30. package/example-terraform-estate/envs/prod/provider.tf +10 -0
  31. package/example-terraform-estate/envs/prod/services.tf +21 -0
  32. package/example-terraform-estate/envs/prod/storage.tf +31 -0
  33. package/example-terraform-estate/envs/prod/variables.tf +17 -0
  34. package/example-terraform-estate/envs/prod/versions.tf +10 -0
  35. package/example-terraform-estate/modules/service/main.tf +58 -0
  36. package/example-terraform-estate/modules/service/outputs.tf +7 -0
  37. package/example-terraform-estate/modules/service/variables.tf +16 -0
  38. package/example-terraform-estate/modules/service/versions.tf +10 -0
  39. package/example-terraform-estate/platform/iam.tf +40 -0
  40. package/example-terraform-estate/platform/kms.tf +16 -0
  41. package/example-terraform-estate/platform/outputs.tf +7 -0
  42. package/example-terraform-estate/platform/variables.tf +10 -0
  43. package/example-terraform-estate/platform/versions.tf +10 -0
  44. package/example-writes/package-lock.json +3 -3
  45. package/licenses/cncf-artwork-LICENSE.md +13 -0
  46. package/licenses/iTerm2-Color-Schemes-LICENSE +31 -0
  47. package/licenses/kubernetes-icons-LICENSE +201 -0
  48. package/package.json +25 -7
  49. package/web/app.js +1309 -41
  50. package/web/behaviour-scale.js +380 -0
  51. package/web/behaviour-scale.test.js +307 -0
  52. package/web/cross-env.js +58 -0
  53. package/web/cross-env.test.js +61 -0
  54. package/web/index.html +62 -2
  55. package/web/pending.js +67 -0
  56. package/web/pending.test.js +62 -0
  57. package/web/read-cost.js +49 -0
  58. package/web/read-cost.test.js +76 -0
  59. package/web/refresh-queue.js +26 -0
  60. package/web/refresh-queue.test.js +54 -0
package/web/app.js CHANGED
@@ -8,7 +8,34 @@
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 { initTheme, mountThemePicker, readableOn, colorForCategory, onThemeChange, getTokens } from "./theme.js";
11
+ import { createRefreshQueue } from "./refresh-queue.js";
12
+ import { readCostLine } from "./read-cost.js";
13
+ import { applyMemberFrame, pendingLine, stillPending } from "./pending.js";
14
+ import { initTheme, mountThemePicker, readableOn, colorForCategory, onThemeChange, getTokens, getTheme, pinTokensFor } from "./theme.js";
15
+ // #399 M2 / #401 M4 of #397: the colour-by modes' arithmetic and the
16
+ // provenance badge's wording — every decision the behaviour overlay makes,
17
+ // pure and unit-tested beside the file. This module owns the fetches, the SVG
18
+ // and the panels; nothing below states a figure of its own.
19
+ import {
20
+ COLOUR_MODES,
21
+ badgeFor,
22
+ blockOf,
23
+ countFigures,
24
+ countsText,
25
+ domainFor,
26
+ effectiveMode,
27
+ figureOf,
28
+ fillFor,
29
+ fmtCost,
30
+ fmtHeadroom,
31
+ headroomOf,
32
+ modeAvailability,
33
+ rampFor,
34
+ rampGradient,
35
+ scaleEnds,
36
+ totalsFor,
37
+ totalsText,
38
+ } from "./behaviour-scale.js";
12
39
  import { addPanelTab, initPanel, setPanelTab, togglePanelCollapsed, isPanelCollapsed } from "./panel.js";
13
40
  // #254: the carve walkthrough's stepper — everything it DECIDES is a pure
14
41
  // function in there; this file owns the fetches, the graph selection, and the
@@ -56,6 +83,7 @@ import { hasOperator, renderOperator, APPROVED_SEMANTICS } from "./operator.js";
56
83
  // which commit, approved by whom — and, when the ledger's run id cannot be
57
84
  // followed, the reason in words rather than a guessed link.
58
85
  import { releaseRows } from "./release.js";
86
+ import { comparisonRow, comparisonSummary } from "./cross-env.js";
59
87
  initTheme();
60
88
  initPanel();
61
89
  mountThemePicker(document.getElementById("panel-theme"));
@@ -65,16 +93,121 @@ mountThemePicker(document.getElementById("panel-theme"));
65
93
  // labels/icons get readable ink (black/white) on the category fill. Re-runs on theme switch,
66
94
  // since the categorical hues come from the active theme's palette.
67
95
  let lastGraphIr = null;
96
+
97
+ // --- colour by drift, cost or headroom (#399, M2 of #397) -------------------
98
+ //
99
+ // The mode is CLIENT-SIDE state, deliberately. It changes no fetch: the server
100
+ // hands the same overlay whichever mode is picked — `attrs._behaviour` rides
101
+ // every priced card already (src/behaviour.ts explains why the block stays on
102
+ // the node), so a mode switch is a repaint of the SVG that is already on
103
+ // screen, with no round trip and nothing for a slow chant read to hold up. It
104
+ // is therefore NOT in `LENS_PARAMS`/`canonicalKey`: a lens param there means "a
105
+ // different snapshot comes back", and adding one would give a static export
106
+ // three identical bundles of the same graph under three keys.
107
+ //
108
+ // It survives a reload the way the theme does, and for the same reason — the
109
+ // colour you left the graph in is part of how you read it.
110
+ const COLOUR_STORE_KEY = "behold.colourBy";
111
+ let colourMode = "drift";
112
+ try {
113
+ const saved = localStorage.getItem(COLOUR_STORE_KEY);
114
+ if (COLOUR_MODES.includes(saved)) colourMode = saved;
115
+ } catch {
116
+ /* private mode */
117
+ }
118
+
119
+ const COLOUR_MODE_TITLE = {
120
+ drift: "Colour each card by what chant observed live — the overlay behold has always drawn",
121
+ cost: "Colour each card by the engine's cost per hour, low to high across this estate. An entity nothing priced draws neutral, never the cheap end.",
122
+ headroom: "Colour each card by how far it is from saturation — the LOWER of the axes the engine reported. An entity nothing priced draws neutral.",
123
+ };
124
+
125
+ /** `meta.behaviour` off the last render, or null. */
126
+ function behaviourMeta() {
127
+ return (lastMeta && lastMeta.behaviour) || null;
128
+ }
129
+
130
+ /** The mode actually painted: the pick, unless this graph cannot serve it (no
131
+ * overlay, a refusal, an absence), in which case drift — which never depended
132
+ * on an engine. The PICK is kept either way, so switching back to a live env
133
+ * lands where you left off. */
134
+ function activeColourMode() {
135
+ return effectiveMode(colourMode, lastMeta);
136
+ }
137
+
138
+ function setColourMode(mode) {
139
+ colourMode = mode;
140
+ try {
141
+ localStorage.setItem(COLOUR_STORE_KEY, mode);
142
+ } catch {
143
+ /* private mode */
144
+ }
145
+ // No load(): the mode changes nothing the server would answer differently,
146
+ // so the SVG already on screen is repainted in place. renderStatusbar() is
147
+ // the one entry point that redraws the strip AND all three panel tabs, which
148
+ // is exactly the surface a mode switch moves.
149
+ recolorNodesByCategory();
150
+ renderStatusbar();
151
+ }
152
+
153
+ /**
154
+ * What each card is painted, in the active mode.
155
+ *
156
+ * Drift keeps the categorical hue #62 gives a card (the drift state stays on
157
+ * the bar and the stroke, where pinhole puts it). The two behaviour modes
158
+ * replace the FILL with the mode's scale, so the drift bar underneath is
159
+ * untouched and both facts stay on the card at once.
160
+ *
161
+ * The scale's domain is taken over every entity in the graph that carries a
162
+ * figure — the same single walk of `ir.nodes` M1's design note promised, with
163
+ * no join against a second index.
164
+ */
165
+ function colourPlan(graphIr) {
166
+ const mode = activeColourMode();
167
+ const tokens = getTokens();
168
+ const kindOf = new Map(graphIr.nodes.map((n) => [n.id, n.kind || n.lexicon || "node"]));
169
+ if (mode === "drift" || !tokens) {
170
+ return { mode, fillOf: (id) => ({ fill: colorForCategory(kindOf.get(id)), unpriced: false }) };
171
+ }
172
+ const theme = getTheme();
173
+ // The drift overlay's own neutral card ground — the colour a card wears when
174
+ // chant could not read it. #399: an entity with no block draws THIS, never
175
+ // the zero end of the scale, which would say "free" or "saturated" about an
176
+ // entity nothing priced.
177
+ const neutralFill = theme ? pinTokensFor(theme).neutralFill : tokens.neutral;
178
+ const ramp = rampFor(mode, tokens);
179
+ const domain = domainFor(
180
+ mode,
181
+ graphIr.nodes.map((n) => figureOf(mode, blockOf(n))),
182
+ );
183
+ const figures = new Map(graphIr.nodes.map((n) => [n.id, figureOf(mode, blockOf(n))]));
184
+ return { mode, domain, ramp, fillOf: (id) => fillFor(figures.get(id), domain, ramp, neutralFill) };
185
+ }
186
+
68
187
  function recolorNodesByCategory(ir) {
69
188
  if (ir) lastGraphIr = ir;
70
189
  const graphIr = ir || lastGraphIr;
71
190
  const svg = document.querySelector("#graph svg");
72
191
  if (!svg || !graphIr) return;
73
192
  const kindOf = new Map(graphIr.nodes.map((n) => [n.id, n.kind || n.lexicon || "node"]));
193
+ const plan = colourPlan(graphIr);
74
194
  for (const g of svg.querySelectorAll("[data-node-id]")) {
75
- const kind = kindOf.get(g.getAttribute("data-node-id"));
195
+ const id = g.getAttribute("data-node-id");
196
+ const kind = kindOf.get(id);
76
197
  if (!kind) continue;
77
- const cat = colorForCategory(kind), ink = readableOn(cat);
198
+ const paint = plan.fillOf(id);
199
+ const cat = paint.fill, ink = readableOn(cat);
200
+ // #399: an unpriced card is marked, not merely coloured — a hex cannot be
201
+ // read back out into "the engine did not price this", and the legend's
202
+ // unpriced count has to agree with what is on the canvas.
203
+ if (plan.mode !== "drift" && paint.unpriced) g.setAttribute("data-unpriced", "1");
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));
78
211
  // Classify each element ONCE by the pinhole token it rode on (data-cat role), then apply
79
212
  // the role's colour on every pass. This is what makes it recolour on theme switch: after
80
213
  // the first pass the fill is a hex (not a --pin-* var), so we must key off the marker, not
@@ -158,11 +291,11 @@ if (staticMode) {
158
291
 
159
292
  /** Canonical key for a read URL — path + the lens params (whitelisted, sorted)
160
293
  * that select a distinct snapshot. MUST match src/export.ts `canonicalKey`. */
161
- const LENS_PARAMS = ["components", "detail", "env", "logical", "ops", "radial", "runtime", "tier"];
294
+ const LENS_PARAMS = ["collapse", "components", "detail", "env", "logical", "ops", "radial", "runtime", "stacks", "tier"];
162
295
  function canonicalKey(path, params) {
163
296
  // Components + logical + ops views ignore detail/radial — drop them so they
164
297
  // match the single captured snapshot (MUST match src/export.ts).
165
- 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";
166
299
  const q = LENS_PARAMS.filter((k) => params.has(k) && !(flat && (k === "detail" || k === "radial")))
167
300
  .map((k) => `${k}=${params.get(k)}`)
168
301
  .join("&");
@@ -187,6 +320,55 @@ function apiFetch(url) {
187
320
  // reaches a declared entity — expected runtime (a Pod its Deployment
188
321
  // created), never foreign and never drift. Additive the same way.
189
322
  const STATUS_LABEL = { good: "managed", warn: "foreign", accent: "pending", neutral: "unobserved", runtime: "runtime child" };
323
+ // #393 item 8: the same four colours, in the words of whatever the estate's
324
+ // members ARE. The server derives the set from the member kinds and puts it on
325
+ // the overlay meta (`vocabulary`, src/status-vocabulary.ts) — on a choudoufu
326
+ // estate the legend reads bound / unowned / pending / not observed, which is
327
+ // what choudoufu itself calls those states. A mixed estate keeps chant's words
328
+ // and carries a `note` the legend shows as its tooltip. No meta, or a meta from
329
+ // a behold that predates this: chant's words, exactly as before.
330
+ function statusLabels() {
331
+ const v = lastMeta && lastMeta.vocabulary;
332
+ return (v && v.labels) || STATUS_LABEL;
333
+ }
334
+ function statusLabel(status) {
335
+ return statusLabels()[status];
336
+ }
337
+ function vocabularyNote() {
338
+ const v = lastMeta && lastMeta.vocabulary;
339
+ return (v && v.note) || "";
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
+ }
364
+ // #404: whether a plan was read for this overlay, and how many cards it would
365
+ // change. `{read: false}` and `{read: true, drifted: 0}` are DIFFERENT answers
366
+ // — "nobody looked" versus "looked, nothing drifted" — and the legend must not
367
+ // print a count for the first one. Null from a behold that predates this, and
368
+ // every reader below treats null as "not read".
369
+ function driftMeta() {
370
+ return (lastMeta && lastMeta.drift) || null;
371
+ }
190
372
  // M1.1 (#57), palette hardened M2 (#54): the component-DAG live-status join
191
373
  // paints the same `_status` vocabulary (good/warn/accent/neutral) but with
192
374
  // different meaning — a stack-health reading, not "managed" — so the inspect
@@ -384,6 +566,37 @@ function renderDriftSection(section, r) {
384
566
  pr("rendered", `${p.renderedAt} · helm ${p.helmVersion} · chant ${p.chantVersion}`);
385
567
  }
386
568
 
569
+ const RESILIENCE_WORD = { survives: "survives", degrades: "degrades", fails: "fails" };
570
+
571
+ /**
572
+ * The `behaviour` inspect section (#401, M4 of #397) — one entity's whole
573
+ * block, as rows, with the provenance badge beside every figure.
574
+ *
575
+ * Every row here is the engine's claim, echoed. The currency is the engine's
576
+ * (behold converts none), the traffic level is the string the engine named
577
+ * (there is no default one), a headroom axis the engine did not report is
578
+ * simply not a row, and `rightSize` appears only when the engine suggested
579
+ * something. The badge on each figure says who stated it, to what tolerance,
580
+ * and whether it was modeled from list prices or validated against a bill.
581
+ */
582
+ function renderBehaviourSection(section, b) {
583
+ if (!b) return;
584
+ const meta = behaviourMeta();
585
+ const one = [b];
586
+ const add = section("behaviour");
587
+ add("at", b.at.traffic);
588
+ add("cost", figureWithBadge(fmtCost(b.cost.perHour, b.cost.currency), one, meta));
589
+ const axes = [];
590
+ if (typeof b.headroom.cpu === "number") axes.push(`cpu ${fmtHeadroom(b.headroom.cpu)}`);
591
+ if (typeof b.headroom.latency === "number") axes.push(`latency ${fmtHeadroom(b.headroom.latency)}`);
592
+ const worst = headroomOf(b);
593
+ add("headroom", figureWithBadge(`${axes.join(" · ")} — colours on ${fmtHeadroom(worst)}`, one, meta));
594
+ add("error rate", figureWithBadge(`${(b.errorRate * 100).toFixed(2)}% of requests at ${b.at.traffic}`, one, meta));
595
+ add("resilience", figureWithBadge(`${RESILIENCE_WORD[b.resilience.verdict] || b.resilience.verdict} — ${b.resilience.failure}`, one, meta));
596
+ if (b.resilience.note) add("resilience note", b.resilience.note);
597
+ if (b.rightSize) add("right-size", figureWithBadge(b.rightSize.suggestion + (b.rightSize.reason ? ` — ${b.rightSize.reason}` : ""), one, meta));
598
+ }
599
+
387
600
  function inspect(node) {
388
601
  const panel = document.getElementById("inspect-body");
389
602
  panel.innerHTML = "<h2>inspect</h2>";
@@ -431,7 +644,7 @@ function inspect(node) {
431
644
  "status",
432
645
  driftLabel ||
433
646
  (node.lexicon === "op" ? opStatusLabel(node) : undefined) ||
434
- (isArtifact ? ARTIFACT_STATUS_LABEL[st] : liveStatus || tickStatus ? COMPONENT_STATUS_LABEL[st] : STATUS_LABEL[st]) ||
647
+ (isArtifact ? ARTIFACT_STATUS_LABEL[st] : liveStatus || tickStatus ? COMPONENT_STATUS_LABEL[st] : statusLabel(st)) ||
435
648
  st,
436
649
  );
437
650
  if (node.attrs && node.attrs._artifact) {
@@ -508,6 +721,13 @@ function inspect(node) {
508
721
  });
509
722
  }
510
723
 
724
+ // #401: the behaviour block, field by field, with a badge on every figure.
725
+ // The colour on the card carries one number at a time (whichever mode is
726
+ // picked) and carries it as a hue; this is where the engine's whole
727
+ // statement about this entity is legible — and where a figure without its
728
+ // provenance would be visible as the contract violation it is.
729
+ renderBehaviourSection(section, blockOf(node));
730
+
511
731
  // Render diff (#146's deferred half): what `chant helm diff <digest> <env>
512
732
  // --live --json` said about this chart's PINNED render. Never rely on the
513
733
  // colour alone (#57's accessibility note) — and here that matters twice
@@ -520,7 +740,7 @@ function inspect(node) {
520
740
  if (node.physicalId || node.ownership) {
521
741
  const live = section("live");
522
742
  if (node.physicalId) live("physical id", node.physicalId);
523
- 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
524
744
  } else if (liveStatus) {
525
745
  // The colour alone doesn't carry chant's verdict or its reasoning — spell
526
746
  // both out here (never rely on the node's colour alone, #57 accessibility
@@ -638,7 +858,19 @@ function inspect(node) {
638
858
  const attrKeys = Object.keys(node.attrs || {}).filter((k) => !k.startsWith("_") && !(k === "release" && release));
639
859
  if (attrKeys.length) {
640
860
  const decl = section("declared");
641
- for (const k of attrKeys) decl(k, fmtValue(node.attrs[k]));
861
+ // #393 item 10: an UNOWNED choudoufu card leads with the write that adopts
862
+ // the object — the two marker tags choudoufu named (`adopt_tofu_estate` /
863
+ // `adopt_tofu_address`), or its own one-line command if a release ever
864
+ // sends one. behold composes neither (src/choudoufu-live.ts); this puts
865
+ // the line first and makes it copyable, the way the moves panel already
866
+ // does for a `live-mv` line. The paragraph explaining the omission — which
867
+ // used to be the whole section, with the tags under it as JSON — follows.
868
+ const adoptable = node.lexicon === "choudoufu" && typeof node.attrs.adopt === "string" ? "adopt" : null;
869
+ if (adoptable) copyableRow(decl, adoptable, node.attrs.adopt, "Copy the tags that adopt this object");
870
+ for (const k of attrKeys) {
871
+ if (k === adoptable) continue;
872
+ decl(k, fmtValue(node.attrs[k]));
873
+ }
642
874
  }
643
875
 
644
876
  // CI projection facet (M1.2, #56/#58): loomster's GitLab CI is the SAME
@@ -738,6 +970,13 @@ function inspect(node) {
738
970
  // node is instant (no per-node query). Via apiFetch, so a static export replays
739
971
  // the captured snapshot. Cache is per env; cleared on lens change / after an op.
740
972
  let bulkDiffCache = null; // { env, nodes: { <id>: { observed, diff, health } } }
973
+ // #404: ONE-SHOT. Set by the "Re-check live with plan" palette row and cleared
974
+ // by the request that carries it, so `plan=1` is exactly as opt-in as the
975
+ // person's keystroke — an ordinary load, a lens change or a reconnect never
976
+ // spawns a plan. The server still serves the change set that read produced
977
+ // from its own cache on the loads that follow, so the drift stays on screen
978
+ // without anyone paying for a second full pass over the account.
979
+ let planDriftOnce = false;
741
980
  async function loadNodeDiff(id) {
742
981
  if (!bulkDiffCache || bulkDiffCache.env !== view.env) {
743
982
  bulkDiffCache = null;
@@ -787,7 +1026,7 @@ function renderObserved(panel, o, health, healthDetail) {
787
1026
  if (o.type) add("type", o.type);
788
1027
  if (o.status) add("status", o.status, HEALTH_COLOR[health] || undefined);
789
1028
  if (o.physicalId) add("physical id", o.physicalId);
790
- if (o.ownership) add("ownership", o.ownership);
1029
+ if (o.ownership) add("ownership", ownershipLabel(o.ownership)); // #396 item 5: same, off /api/diff
791
1030
  if (o.lastUpdated) add("last updated", o.lastUpdated);
792
1031
  // What the object's own controller says is wrong with it (#86, chant#1401).
793
1032
  // Placed with health and status rather than among the attributes below,
@@ -802,11 +1041,15 @@ function renderObserved(panel, o, health, healthDetail) {
802
1041
  // sends the raw condition object gets the tree instead of `[object Object]`.
803
1042
  for (const c of conditions) add("condition", valueCell(c), "var(--degraded)");
804
1043
  }
1044
+ // #393 item 10: the adoption line is a line to run, here as in the declared
1045
+ // section — first, and with the same copy button.
1046
+ if (typeof o.attributes?.adopt === "string") copyableRow(add, "adopt", o.attributes.adopt, "Copy the tags that adopt this object");
805
1047
  // #259: an observed attribute is whatever the substrate reported — a k8s
806
1048
  // `spec`, a nested `loadBalancer`, an array of ports. All of it collapsible
807
1049
  // now, instead of one flat JSON.stringify line per key.
808
1050
  for (const [k, v] of Object.entries(o.attributes || {})) {
809
1051
  if (k === "conditions") continue; // rendered above, one line each
1052
+ if (k === "adopt" && typeof v === "string") continue; // led with, above
810
1053
  add(k, valueCell(v));
811
1054
  }
812
1055
  panel.appendChild(dl);
@@ -814,6 +1057,11 @@ function renderObserved(panel, o, health, healthDetail) {
814
1057
 
815
1058
  const DIFF_LABEL = {
816
1059
  drifted: "drifted since snapshot",
1060
+ // #404: a choudoufu member has no snapshot to drift FROM — the category is
1061
+ // what a plan says it would do to the live object right now, so it does not
1062
+ // borrow chant's "since snapshot" wording. The rows underneath are the same
1063
+ // `{path, oldValue, newValue}` triples, printed `before → after`.
1064
+ planned: "the plan would change this",
817
1065
  missing: "declared, not in cloud",
818
1066
  orphan: "in cloud, not declared",
819
1067
  disappeared: "gone since snapshot",
@@ -987,7 +1235,7 @@ function wire(ir) {
987
1235
  // null on a project that declares no `stacks[]` at all — the picker (and the
988
1236
  // status strip's stack tag) then never renders. Every fetch reads this, so
989
1237
  // the `changed` SSE re-pull and a palette lens change go through the same path.
990
- const view = { env: null, detail: 2, components: true, logical: false, runtime: false, ops: false, tier: null, target: null, stack: null, radial: false };
1238
+ const view = { env: null, detail: 2, components: true, logical: false, runtime: false, ops: false, tier: null, target: null, stack: null, radial: false, collapse: false, compareTo: null };
991
1239
 
992
1240
  // #182: `components` is the boot default, but a project that declares no
993
1241
  // components renders it as ZERO nodes — the first screen was a blank graph
@@ -997,6 +1245,20 @@ const view = { env: null, detail: 2, components: true, logical: false, runtime:
997
1245
  // or ⌘K) still shows the honest empty view + the server's note.
998
1246
  let autoZoomFallback = true;
999
1247
 
1248
+ // #393 item 1: for an estate whose members are NONE of them chant projects, the
1249
+ // fallback above is a round-trip too late and one degree too vague. The
1250
+ // components zoom is a chant projection of a chant project's own component DAG;
1251
+ // a choudoufu estate or a bare Terraform directory has no components to
1252
+ // project, so the first screen was "the components lens doesn't apply to a
1253
+ // composed estate yet" — an apology, every time, before the person had picked
1254
+ // anything. The kinds come from /api/project (`memberKinds`, in composition
1255
+ // order), so the decision is made from what the server already knows rather
1256
+ // than from a zero-node answer. A chant member anywhere in the estate keeps
1257
+ // #182's behaviour exactly, and picking components later still shows the note.
1258
+ function bootZoom(memberKinds) {
1259
+ return memberKinds && memberKinds.length && !memberKinds.includes("chant") ? "resources" : "components";
1260
+ }
1261
+
1000
1262
  // v0.1.0 preview lock (set from /api/project in initActions): hides the git/PR
1001
1263
  // write ops (Rollback, Sync, Adopt, Run ▾) — the server also 403s them. Local
1002
1264
  // deploy (Apply all / dial), Reset, Bring up, Approve, and reads stay on.
@@ -1029,22 +1291,47 @@ const ZOOM_OPTS = [
1029
1291
  // same chant source), so it sits last, and only appears when the served
1030
1292
  // estate has actually emitted Ops — `opsAvailable`, from /api/project.
1031
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"],
1032
1301
  ];
1033
1302
  const ZOOM_DETAIL = { composites: 1, resources: 2, attributes: 3, runtime: 3 };
1034
1303
  /** How many emitted Ops the served estate has (0 = the stop doesn't exist).
1035
1304
  * Seeded from /api/project in initPickers(). */
1036
1305
  let opsAvailable = 0;
1037
- /** The zoom stops this project can actually offer — `runtime` needs an env,
1038
- * `ops` needs emitted op.json files. Both the panel and ⌘K read this, so a stop
1039
- * never appears in one surface and not the other. */
1306
+ /** #393 item 2: whether ANY served member could have owner-referenced children
1307
+ * — /api/project's `runtimeCapable`, decided from the members' declared
1308
+ * lexicons (src/server.ts's RUNTIME_LEXICONS). Before this the stop needed only
1309
+ * an env, so every choudoufu estate (which declares `live`) offered a
1310
+ * Kubernetes tier that could only ever answer "nothing below the declaration
1311
+ * boundary". */
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;
1322
+ /** The zoom stops this project can actually offer — `runtime` needs an env AND
1323
+ * a member whose substrate has an owner chain, `ops` needs emitted op.json
1324
+ * files. Both the panel and ⌘K read this, so a stop never appears in one
1325
+ * surface and not the other. */
1040
1326
  function availableZooms() {
1041
- return ZOOM_OPTS.filter(([, z]) => (z === "runtime" ? Boolean(view.env) : 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));
1042
1328
  }
1043
1329
  /** Current zoom value from (components, logical, ops, detail). */
1044
1330
  function zoomValue() {
1045
1331
  if (view.components) return "components";
1046
1332
  if (view.logical) return "logical";
1047
1333
  if (view.ops) return "ops";
1334
+ if (view.stacks) return "stacks";
1048
1335
  if (view.runtime) return "runtime";
1049
1336
  return { 1: "composites", 2: "resources", 3: "attributes" }[view.detail] ?? "resources";
1050
1337
  }
@@ -1053,8 +1340,9 @@ function applyZoom(z) {
1053
1340
  view.components = z === "components";
1054
1341
  view.logical = z === "logical";
1055
1342
  view.ops = z === "ops";
1343
+ view.stacks = z === "stacks";
1056
1344
  view.runtime = z === "runtime";
1057
- 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;
1058
1346
  }
1059
1347
 
1060
1348
  // --- Floating control panel content ---------------------------------------
@@ -1109,6 +1397,28 @@ function renderPanelView() {
1109
1397
  }),
1110
1398
  );
1111
1399
  }
1400
+ // #399: the three-state colour switch, beside the zoom — the other thing
1401
+ // this tab is for is "what am I looking at", and colour is half that answer.
1402
+ // Sits below the stops because it is not a granularity: every zoom paints in
1403
+ // whichever of the three you picked.
1404
+ zoom.appendChild(panelHeading("colour by"));
1405
+ for (const mode of COLOUR_MODES) {
1406
+ const gate = mode === "drift" ? { available: true, reason: "" } : modeAvailability(lastMeta, mode);
1407
+ const b = panelOpt(
1408
+ mode,
1409
+ mode === activeColourMode(),
1410
+ () => {
1411
+ if (!gate.available) return;
1412
+ setColourMode(mode);
1413
+ },
1414
+ gate.available ? COLOUR_MODE_TITLE[mode] : gate.reason,
1415
+ );
1416
+ // #401: disabled VISIBLY, with the lexicon's own reason on it. A control
1417
+ // greyed out is where a person asks "why not", so that is where the answer
1418
+ // goes — never hidden, which would leave the question unasked.
1419
+ if (!gate.available) b.disabled = true;
1420
+ zoom.appendChild(b);
1421
+ }
1112
1422
  // Radial toggle — entity zooms only, same gate the ⌘K entry has
1113
1423
  // (components/logical/ops all lay themselves out: waves / nested arch boxes /
1114
1424
  // phase boxes).
@@ -1270,6 +1580,163 @@ function renderCarveState(host, state) {
1270
1580
  host.appendChild(panelMuted(state.apply.note));
1271
1581
  }
1272
1582
 
1583
+ /**
1584
+ * One inspect row whose value is a line to run: the line itself, monospaced,
1585
+ * and a copy button beside it — the affordance the moves panel already gives a
1586
+ * `live-mv` line (#371), reused here for the tags that adopt an unowned object
1587
+ * (#393 item 10). `add` is a section's own row adder, so the row sits in the
1588
+ * section it belongs to rather than in a box of its own.
1589
+ */
1590
+ function copyableRow(add, key, text, title) {
1591
+ const wrap = document.createElement("span");
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";
1596
+ const line = document.createElement("code");
1597
+ line.className = "grow";
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";
1604
+ line.textContent = text;
1605
+ line.title = text;
1606
+ const copy = actButton("copy", () => copyToClipboard(text, copy), title ? `${title}:\n${text}` : text);
1607
+ wrap.append(line, copy);
1608
+ add(key, wrap);
1609
+ }
1610
+
1611
+ /** Copy `text` to the clipboard and say so on the button for a moment. */
1612
+ function copyToClipboard(text, el) {
1613
+ const done = () => {
1614
+ const was = el.textContent;
1615
+ el.textContent = "copied ✓";
1616
+ setTimeout(() => {
1617
+ el.textContent = was;
1618
+ }, 1200);
1619
+ };
1620
+ if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(done, done);
1621
+ else done();
1622
+ }
1623
+
1624
+ /**
1625
+ * The choudoufu move plan (#371), as the scope panel shows it: one row per
1626
+ * move (`address: from → to`), the line a person runs in the destination
1627
+ * estate's directory with a copy button, and a link to the morph page. The
1628
+ * plan is read from `/api/choudoufu/moves`, which previews with choudoufu's
1629
+ * own `-dry-run` when asked and never runs the write — `apply.note` carries
1630
+ * that sentence here verbatim. A no-op when no served member carries a
1631
+ * `carve.json`.
1632
+ */
1633
+ function renderChoudoufuMoves(host, info) {
1634
+ if (!info || !info.plans || !info.plans.length || staticMode) return;
1635
+ host.appendChild(panelHeading("moves (choudoufu)"));
1636
+ for (const plan of info.plans) {
1637
+ const head = document.createElement("div");
1638
+ head.className = "count-row";
1639
+ const label = document.createElement("span");
1640
+ label.className = "grow";
1641
+ label.textContent = plan;
1642
+ const morph = document.createElement("a");
1643
+ morph.className = "tag";
1644
+ morph.textContent = "morph ↗";
1645
+ morph.href = "/choudoufu/morph?plan=" + encodeURIComponent(plan);
1646
+ morph.target = "_blank";
1647
+ morph.title = "Every member a box, the moved cards gliding to their destination";
1648
+ head.append(label, morph);
1649
+ host.appendChild(head);
1650
+ const rows = document.createElement("div");
1651
+ host.appendChild(rows);
1652
+ apiFetch("/api/choudoufu/moves?plan=" + encodeURIComponent(plan))
1653
+ .then((r) => r.json())
1654
+ .then((body) => {
1655
+ if (!body || !Array.isArray(body.moves)) {
1656
+ rows.appendChild(panelMuted((body && body.error) || "plan unreadable"));
1657
+ return;
1658
+ }
1659
+ for (const m of body.moves) {
1660
+ const row = document.createElement("div");
1661
+ row.className = "count-row";
1662
+ const name = document.createElement("span");
1663
+ name.className = "grow";
1664
+ name.textContent = m.address + (m.newAddress ? " → " + m.newAddress : "");
1665
+ name.title = "run in " + m.runIn;
1666
+ const tag = document.createElement("span");
1667
+ tag.className = "tag";
1668
+ tag.textContent = m.from + " → " + m.to;
1669
+ const copy = actButton("copy", () => copyToClipboard(m.command, copy), "Copy the live-mv line to the clipboard");
1670
+ row.append(name, tag, copy);
1671
+ rows.appendChild(row);
1672
+ rows.appendChild(panelMuted(m.command));
1673
+ }
1674
+ if (body.apply && body.apply.note) rows.appendChild(panelMuted(body.apply.note));
1675
+ })
1676
+ .catch(() => rows.appendChild(panelMuted("plan unreadable — is the behold server still running?")));
1677
+ }
1678
+ }
1679
+
1680
+ /**
1681
+ * The totals, per box and per estate, in the active mode (#399).
1682
+ *
1683
+ * Sits in the Scope tab because Scope is already the tab that says what this
1684
+ * estate IS — its members, its moves, its environments — and a per-box figure
1685
+ * is a fact about a member, not about the picture.
1686
+ *
1687
+ * In `drift` mode there is no row at all. Drift counts states; a sum of states
1688
+ * is not a thing, and the Model tab's legend already carries the counts.
1689
+ *
1690
+ * Where the figure comes from is on the row, every time. `cost` quotes the
1691
+ * server: `meta.behaviour.boxes[<key>]` per box and `.sum` for the estate, both
1692
+ * additions of engine figures — or `meta.behaviour.total`, the engine's own
1693
+ * estate figure, which outranks the sum and is labelled "engine total" so the
1694
+ * two can never be confused. `headroom` has no server-side aggregate, because
1695
+ * no engine states one and adding fractions of capacity would mean nothing, so
1696
+ * the SPA takes the worst and middle card of the scope itself and the row says
1697
+ * "computed".
1698
+ */
1699
+ function renderBehaviourTotals(host) {
1700
+ const mode = activeColourMode();
1701
+ if (mode === "drift") return;
1702
+ const b = behaviourMeta();
1703
+ const ir = lastGraphIr;
1704
+ if (!b || !ir) return;
1705
+ const byId = new Map(ir.nodes.map((n) => [n.id, n]));
1706
+ const boxes = (ir.groups && ir.groups.byStack) || {};
1707
+ host.appendChild(panelHeading(`${mode} totals`));
1708
+
1709
+ const row = (label, blocks, sum, engineTotal, title) => {
1710
+ const totals = totalsFor(mode, blocks, sum, engineTotal);
1711
+ if (!totals) return;
1712
+ // Stacked, not a name/value row: the panel is 260px and one of these
1713
+ // lines is "0.5669 USD/h · 41 priced · 4 unpriced" — side by side, the
1714
+ // figure takes the width and the box's name ellipses away to nothing,
1715
+ // which is the one part of the row that says WHAT is being totalled.
1716
+ const r = document.createElement("div");
1717
+ r.className = "behaviour-total";
1718
+ const name = document.createElement("div");
1719
+ name.className = "grow";
1720
+ name.style.fontWeight = "600";
1721
+ name.textContent = label;
1722
+ if (title) name.title = title;
1723
+ r.appendChild(name);
1724
+ r.appendChild(figureWithBadge(totalsText(totals), blocks.filter(Boolean), b));
1725
+ host.appendChild(r);
1726
+ };
1727
+
1728
+ for (const [key, ids] of Object.entries(boxes).sort(([a], [c]) => a.localeCompare(c))) {
1729
+ const blocks = ids.map((id) => blockOf(byId.get(id)));
1730
+ row(key, blocks, (b.boxes || {})[key], undefined, `${ids.length} entities in this box`);
1731
+ }
1732
+ const all = ir.nodes.map((n) => blockOf(n));
1733
+ // `total` is the engine's word and only ever holds an engine's own figure;
1734
+ // `sum` is behold's addition. Passing both lets totalsFor apply #398's
1735
+ // precedence in one place rather than at each call site.
1736
+ 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");
1737
+ 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."));
1738
+ }
1739
+
1273
1740
  function renderPanelScope() {
1274
1741
  const host = document.getElementById("tab-scope");
1275
1742
  if (!host) return;
@@ -1301,6 +1768,13 @@ function renderPanelScope() {
1301
1768
  // one — and it renders identically in carve mode and on an ordinary project
1302
1769
  // serve, because /api/project publishes one shape for both.
1303
1770
  renderCarveState(host, info.carve && info.carve.state);
1771
+ // #399: what the estate costs, or how much room it has left, per member box
1772
+ // and for the whole estate — right under the member list the figures are
1773
+ // about, and above the moves that would change them.
1774
+ renderBehaviourTotals(host);
1775
+ // #371: a choudoufu member's move plan — the handoff lines with copy
1776
+ // buttons, and the morph page. Read-only: behold never runs live-mv.
1777
+ renderChoudoufuMoves(host, info.choudoufu);
1304
1778
  // #195: switching — recents first (server-persisted, validated), then a
1305
1779
  // free path input. Locked in preview mode (the demo's contract) and
1306
1780
  // meaningless in a static export.
@@ -1362,6 +1836,57 @@ function renderPanelScope() {
1362
1836
  );
1363
1837
  }
1364
1838
  if (!environments.length) host.appendChild(panelMuted("no environments declared"));
1839
+
1840
+ // #234's last remainder — "is prod running what staging tested". Only when
1841
+ // an env is picked and there is another to compare against; ledger-only, so
1842
+ // it never adds a cloud read to opening the panel (fetch happens on pick).
1843
+ if (view.env && environments.length > 1 && !staticMode) {
1844
+ if (view.compareTo === view.env) view.compareTo = null; // env re-picked onto the compared one
1845
+ host.appendChild(panelHeading("compare releases"));
1846
+ const sel = document.createElement("select");
1847
+ sel.add(new Option("(off)", "", view.compareTo == null, view.compareTo == null));
1848
+ for (const e of environments) {
1849
+ if (e === view.env) continue;
1850
+ sel.add(new Option(`vs ${e}`, e, false, view.compareTo === e));
1851
+ }
1852
+ sel.title = "Compare this environment's recorded releases with another's — per component, on the comparable identity (inputs for pinned deploys)";
1853
+ const result = document.createElement("div");
1854
+ const render = (payload) => {
1855
+ result.replaceChildren();
1856
+ const s = comparisonSummary(payload);
1857
+ const line = document.createElement("div");
1858
+ line.style.cssText = `font-size:var(--t-caption);color:${COMPONENT_STATUS_VAR[s.tone === "warn" ? "warn" : s.tone === "good" ? "good" : "neutral"]};margin:4px 0`;
1859
+ line.textContent = s.text;
1860
+ result.appendChild(line);
1861
+ for (const c of payload.comparisons || []) {
1862
+ const r = comparisonRow(c);
1863
+ const row = panelDotRow(COMPONENT_STATUS_VAR[r.tone === "warn" ? "warn" : r.tone === "good" ? "good" : "neutral"], r.component, r.verdict, () => selectNode(r.component));
1864
+ row.title = r.detail;
1865
+ result.appendChild(row);
1866
+ }
1867
+ };
1868
+ sel.addEventListener("change", () => {
1869
+ view.compareTo = sel.value || null;
1870
+ result.replaceChildren();
1871
+ if (!view.compareTo) return;
1872
+ result.appendChild(panelMuted("comparing…"));
1873
+ fetch(`/api/components/compare?env=${encodeURIComponent(view.env)}&to=${encodeURIComponent(view.compareTo)}`)
1874
+ .then((r) => r.json())
1875
+ .then((payload) => {
1876
+ if (view.compareTo !== sel.value) return; // re-picked while loading
1877
+ if (payload.error) {
1878
+ result.replaceChildren(panelMuted(payload.error));
1879
+ return;
1880
+ }
1881
+ render(payload);
1882
+ })
1883
+ .catch((err) => result.replaceChildren(panelMuted(`compare failed: ${err && err.message ? err.message : err}`)));
1884
+ });
1885
+ host.appendChild(sel);
1886
+ host.appendChild(result);
1887
+ // Restore an active comparison across panel re-renders.
1888
+ if (view.compareTo) sel.dispatchEvent(new Event("change"));
1889
+ }
1365
1890
  if (stacks.length) {
1366
1891
  host.appendChild(panelHeading("stack"));
1367
1892
  for (const s of stacks) {
@@ -1410,6 +1935,103 @@ function renderPanelScope() {
1410
1935
  const DRIFT_STATUS_VAR = { good: "var(--managed)", warn: "var(--foreign)", accent: "var(--pending)", neutral: "var(--muted)", runtime: "var(--runtime)" };
1411
1936
  const COMPONENT_STATUS_VAR = { good: "var(--managed)", accent: "var(--pending)", warn: "var(--degraded)", neutral: "var(--muted)" };
1412
1937
 
1938
+ /**
1939
+ * The provenance badge (#401, M4 of #397) — `{engine} {version} · {tolerance}
1940
+ * · {basis}`, beside a figure rather than once per page.
1941
+ *
1942
+ * #397's one prohibition is a prediction presented as a bill, and this element
1943
+ * is where that is prevented: the word `modeled` is on the badge and the
1944
+ * sentence "modeled, not billed" is on its hover, on every figure that carries
1945
+ * one. `blocks` is the entities the figure was derived from — one for an
1946
+ * inspect row, a whole box or estate for a totals row — because #398 puts
1947
+ * provenance on the ENTITY, so a badge over a set can only say what the set
1948
+ * agrees on (badgeFor says `mixed` where it does not).
1949
+ */
1950
+ function badgeEl(blocks, meta) {
1951
+ const b = badgeFor(blocks, meta);
1952
+ if (!b) return null;
1953
+ const el = document.createElement("span");
1954
+ el.className = "tag behaviour-badge";
1955
+ el.textContent = b.text;
1956
+ el.title = b.title;
1957
+ return el;
1958
+ }
1959
+
1960
+ /** A figure and its badge on one line — the shape every behaviour figure the
1961
+ * SPA shows takes, so none of them can end up without one. */
1962
+ function figureWithBadge(text, blocks, meta) {
1963
+ const wrap = document.createElement("span");
1964
+ wrap.style.cssText = "display:flex;gap:6px;align-items:baseline;flex-wrap:wrap;min-width:0";
1965
+ const val = document.createElement("span");
1966
+ val.textContent = text;
1967
+ wrap.appendChild(val);
1968
+ const badge = badgeEl(blocks, meta);
1969
+ if (badge) wrap.appendChild(badge);
1970
+ return wrap;
1971
+ }
1972
+
1973
+ /**
1974
+ * One mode's legend (#399: one legend per mode).
1975
+ *
1976
+ * `drift` is not here — it keeps #393's vocabulary legend, the estate's own
1977
+ * words for the four states, which is the whole point of that issue. The two
1978
+ * behaviour modes get a ramp with its two ends labelled, the priced/unpriced
1979
+ * split, the neutral swatch that says what an unpriced card is drawn in, and
1980
+ * the badge for the figures the ramp spans.
1981
+ */
1982
+ function renderBehaviourLegend(host, mode) {
1983
+ const b = behaviourMeta();
1984
+ const ir = lastGraphIr;
1985
+ if (!b || !ir) return;
1986
+ const blocks = ir.nodes.map((n) => blockOf(n));
1987
+ const values = ir.nodes.map((n) => figureOf(mode, blockOf(n)));
1988
+ const domain = domainFor(mode, values);
1989
+ const tokens = getTokens();
1990
+ const ramp = rampFor(mode, tokens);
1991
+ const currency = (blocks.find((x) => x && x.cost) || { cost: {} }).cost.currency || "";
1992
+ const heading = panelHeading(`${mode} · ${lastMeta.env}`);
1993
+ heading.title = COLOUR_MODE_TITLE[mode];
1994
+ host.appendChild(heading);
1995
+
1996
+ const bar = document.createElement("div");
1997
+ bar.className = "behaviour-ramp";
1998
+ bar.style.cssText = `height:10px;border-radius:5px;border:1px solid var(--line);background:${rampGradient(ramp)}`;
1999
+ host.appendChild(bar);
2000
+ const ends = scaleEnds(mode, domain, currency);
2001
+ if (ends) {
2002
+ const row = document.createElement("div");
2003
+ row.className = "count-row";
2004
+ const lo = document.createElement("span");
2005
+ lo.className = "grow";
2006
+ lo.textContent = ends.low;
2007
+ const hi = document.createElement("span");
2008
+ hi.className = "tag";
2009
+ hi.textContent = ends.high;
2010
+ row.append(lo, hi);
2011
+ host.appendChild(row);
2012
+ }
2013
+
2014
+ const counts = countFigures(mode, blocks);
2015
+ const priced = blocks.filter((x) => x && figureOf(mode, x) !== undefined);
2016
+ const line = document.createElement("div");
2017
+ line.className = "count-row";
2018
+ const txt = document.createElement("span");
2019
+ txt.className = "grow";
2020
+ txt.textContent = countsText(counts);
2021
+ line.appendChild(txt);
2022
+ const badge = badgeEl(priced, b);
2023
+ if (badge) line.appendChild(badge);
2024
+ host.appendChild(line);
2025
+
2026
+ // The neutral, spelled out. Without this row the grey cards read as a third
2027
+ // band of the scale rather than as "nothing priced these".
2028
+ const theme = getTheme();
2029
+ host.appendChild(
2030
+ panelDotRow(theme ? pinTokensFor(theme).neutralFill : "var(--muted)", "unpriced — no behaviour block", String(counts.unpriced)),
2031
+ );
2032
+ if (mode === "headroom") host.appendChild(panelMuted("the lower of the axes the engine reported; a missing axis is absent, not zero"));
2033
+ }
2034
+
1413
2035
  function panelDotRow(color, main, tag, onClick) {
1414
2036
  const row = document.createElement("div");
1415
2037
  row.className = onClick ? "node-row" : "count-row";
@@ -1454,6 +2076,15 @@ function renderPanelModel() {
1454
2076
  host.appendChild(panelMuted("no graph loaded yet"));
1455
2077
  return;
1456
2078
  }
2079
+ // #393 item 7: the note in full, where there is room for it. The strip may be
2080
+ // showing the server's short form (a 260px strip cannot hold five root names,
2081
+ // two skip reasons and five block counts), so the whole sentence has to be
2082
+ // readable somewhere that is not a tooltip — and this tab is where the panel
2083
+ // already explains the picture.
2084
+ if (lastNote && lastNoteShort) {
2085
+ host.appendChild(panelHeading("note"));
2086
+ host.appendChild(panelMuted(lastNote));
2087
+ }
1457
2088
  const drift = m.mode === "overlay" || (m.mode === "logical" && !!m.env);
1458
2089
  const componentStatus = m.mode === "component-status";
1459
2090
  const count = (statuses) => {
@@ -1475,15 +2106,55 @@ function renderPanelModel() {
1475
2106
  const s = n.attrs && n.attrs._status;
1476
2107
  host.appendChild(panelDotRow(COMPONENT_STATUS_VAR[s] || "var(--muted)", n.id, APPLY_STATUS_TAG[s] || "", () => selectNode(n.id)));
1477
2108
  }
2109
+ } else if (drift && activeColourMode() !== "drift") {
2110
+ // #399: one legend per mode. The drift legend below is #393's vocabulary
2111
+ // legend and stays exactly as it was — this branch is the two behaviour
2112
+ // modes, whose legend is a scale rather than a set of states.
2113
+ renderBehaviourLegend(host, activeColourMode());
1478
2114
  } else if (drift) {
1479
- host.appendChild(panelHeading(`drift · ${m.env}`));
1480
- const c = count(STATUS_LABEL);
1481
- for (const [k, label] of Object.entries(STATUS_LABEL)) {
2115
+ // #393 item 8: the legend proper. The heading carries the mixed-estate
2116
+ // sentence as its tooltip — the one case where the words on these rows are
2117
+ // not the words the members would use for themselves.
2118
+ const heading = panelHeading(`drift · ${m.env}`);
2119
+ const why = vocabularyNote();
2120
+ if (why) heading.title = why;
2121
+ host.appendChild(heading);
2122
+ const words = statusLabels();
2123
+ const c = count(words);
2124
+ for (const [k, label] of Object.entries(words)) {
1482
2125
  // The additive buckets (chant#1168 unobserved, chant#1180 runtime) stay
1483
2126
  // hidden until a chant actually emits them — same as the old legend.
1484
2127
  if ((k === "neutral" || k === "runtime") && !c[k]) continue;
1485
2128
  host.appendChild(panelDotRow(DRIFT_STATUS_VAR[k], label, String(c[k])));
1486
2129
  }
2130
+ // #404: attribute drift, on its own row under the ownership counts —
2131
+ // beside them, never mixed into them, because a drifted card is still
2132
+ // bound and is already counted on the row above. The row appears only when
2133
+ // a plan was actually read; where it was not, the legend says so in words
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
+ }
2152
+ const dm = driftMeta();
2153
+ if (dm && dm.read) {
2154
+ host.appendChild(panelDotRow("var(--degraded)", "drifted (the plan would change it)", String(dm.drifted)));
2155
+ } else if (dm) {
2156
+ host.appendChild(panelMuted("attribute drift not read — the overlay is ownership only. ⌘K → “Re-check live with plan”."));
2157
+ }
1487
2158
  // The actionable nodes — foreign (adoptable) and pending (not applied yet).
1488
2159
  const attention = ir.nodes.filter((n) => {
1489
2160
  const s = n.attrs && n.attrs._status;
@@ -1493,7 +2164,7 @@ function renderPanelModel() {
1493
2164
  host.appendChild(panelHeading("needs attention"));
1494
2165
  for (const n of attention.slice(0, 40)) {
1495
2166
  const s = n.attrs._status;
1496
- host.appendChild(panelDotRow(DRIFT_STATUS_VAR[s], n.id, STATUS_LABEL[s], () => selectNode(n.id)));
2167
+ host.appendChild(panelDotRow(DRIFT_STATUS_VAR[s], n.id, statusLabel(s), () => selectNode(n.id)));
1497
2168
  }
1498
2169
  if (attention.length > 40) host.appendChild(panelMuted(`+ ${attention.length - 40} more — click nodes in the graph`));
1499
2170
  }
@@ -1505,6 +2176,45 @@ function renderPanelModel() {
1505
2176
  host.appendChild(actButton("→ Scope", () => setPanelTab("scope")));
1506
2177
  }
1507
2178
  }
2179
+ renderBehaviourRefusal(host);
2180
+ renderBehaviourDiagnostics(host);
2181
+ }
2182
+
2183
+ /**
2184
+ * The refusal, where the legend would be (#401).
2185
+ *
2186
+ * The words are the lexicon's, in its own house style — an env-var chain and
2187
+ * the variable it wants — and behold prints them as they came. It does not
2188
+ * paraphrase them, does not soften them, and above all does not substitute a
2189
+ * number: "a missing or unreachable engine means no overlay and a refusal that
2190
+ * names why, never a locally faked number" is #397's rule, and this is the one
2191
+ * surface where a person finds out it fired.
2192
+ *
2193
+ * An ABSENCE prints nothing here. Nothing was configured, so nothing refused —
2194
+ * and a panel that announced every estate with no behavioural engine would be
2195
+ * shouting about a feature nobody asked for. The absent line is on the disabled
2196
+ * modes' tooltips instead, where the question actually gets asked.
2197
+ */
2198
+ function renderBehaviourRefusal(host) {
2199
+ const b = behaviourMeta();
2200
+ if (!b || !b.refusal) return;
2201
+ host.appendChild(panelHeading("behaviour — refused"));
2202
+ const reason = panelMuted(b.refusal.reason);
2203
+ reason.style.color = "var(--degraded)";
2204
+ host.appendChild(reason);
2205
+ host.appendChild(panelMuted(b.refusal.remedy));
2206
+ host.appendChild(panelMuted("the drift overlay above is unaffected — it never depended on an engine."));
2207
+ }
2208
+
2209
+ /** M1's diagnostics (`meta.behaviour.diagnostics`): every block behold dropped
2210
+ * and every thing it could not reconcile, under the legend where the figures
2211
+ * they are about are. They were reaching the wire and nothing was reading
2212
+ * them. */
2213
+ function renderBehaviourDiagnostics(host) {
2214
+ const b = behaviourMeta();
2215
+ if (!b || !b.diagnostics || !b.diagnostics.length) return;
2216
+ host.appendChild(panelHeading(`behaviour diagnostics · ${b.diagnostics.length}`));
2217
+ for (const d of b.diagnostics) host.appendChild(panelMuted(d));
1508
2218
  }
1509
2219
 
1510
2220
  // ---------------------------------------------------------------------------
@@ -1811,6 +2521,88 @@ function markOperatorCards(ir) {
1811
2521
  }
1812
2522
  }
1813
2523
 
2524
+ /**
2525
+ * #404: the second signal on a bound choudoufu card — the plan would change an
2526
+ * attribute on a resource the estate demonstrably owns.
2527
+ *
2528
+ * The ownership verdict keeps the card's FILL: bound is still green, and this
2529
+ * only adds a stroke and a corner glyph, the same post-render stamp
2530
+ * `markCarvedCards`/`markOperatorCards` use (pinhole's SVG arrives from the
2531
+ * server as a string and is replaced on every render, so a mark has to be
2532
+ * re-applied here rather than asked for at layout time).
2533
+ *
2534
+ * `~` is OpenTofu's own symbol for update-in-place, so the card and the
2535
+ * terminal read the same. The corner is the bottom-right one the carve and
2536
+ * operator marks also use — a choudoufu card is never carved (that is chant
2537
+ * source) and never the operating loop's home (that is Kubernetes), so on the
2538
+ * estates this can appear on the corner is free; the guard below still skips a
2539
+ * card that already carries one of those, rather than drawing over it.
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
+
2571
+ function markDriftedCards(ir) {
2572
+ const svg = document.querySelector("#graph svg");
2573
+ if (!svg) return;
2574
+ for (const n of ir.nodes || []) {
2575
+ const mark = n.attrs && n.attrs._planDrift;
2576
+ if (!mark || !mark.attributes || !mark.attributes.length) continue;
2577
+ const g = svg.querySelector('[data-node-id="' + CSS.escape(n.id) + '"]');
2578
+ if (!g || g.querySelector('[data-plan-drift="1"]')) continue;
2579
+ if (g.querySelector('[data-carved="1"]') || g.querySelector('[data-operator="1"]')) continue;
2580
+ g.classList.add("plan-drifted");
2581
+ // Measured, not read off attributes — markCarvedCards()'s note applies
2582
+ // here verbatim (pinhole sizes a card from its content).
2583
+ const rect = g.querySelector("rect");
2584
+ const box = rect && rect.getBBox ? rect.getBBox() : null;
2585
+ const tag = document.createElementNS("http://www.w3.org/2000/svg", "text");
2586
+ tag.setAttribute("data-plan-drift", "1");
2587
+ tag.setAttribute("x", String((box ? box.x + box.width : 150) - 10));
2588
+ tag.setAttribute("y", String((box ? box.y + box.height : 60) - 9));
2589
+ tag.setAttribute("text-anchor", "end");
2590
+ tag.setAttribute("font-size", "11");
2591
+ tag.setAttribute("font-weight", "600");
2592
+ tag.setAttribute("fill", "var(--degraded)");
2593
+ // A COUNT, not the attribute names: a name like `image_tag_mutability` is
2594
+ // wider than the corner and runs into the card's own subtitle (seen in the
2595
+ // browser). The count is bounded whatever the provider calls its fields,
2596
+ // and the names are one hover — or one click, in the pane — away.
2597
+ const count_ = mark.attributes.length;
2598
+ tag.textContent = `~ ${count_} attribute${count_ === 1 ? "" : "s"}`;
2599
+ const title = document.createElementNS("http://www.w3.org/2000/svg", "title");
2600
+ title.textContent = `The plan would ${(mark.actions || ["update"]).join("+")} this: ${mark.attributes.join(", ")}. Ownership is unchanged — the card's colour is still its ownership verdict.`;
2601
+ g.appendChild(title);
2602
+ g.appendChild(tag);
2603
+ }
2604
+ }
2605
+
1814
2606
  function renderPanelCarve() {
1815
2607
  if (!carveMode()) return;
1816
2608
  if (!carveHost) {
@@ -1866,9 +2658,18 @@ function renderStatusbar() {
1866
2658
  // Pure state — the strip echoes the axes whose controls live on the floating
1867
2659
  // panel and in ⌘K, so the current view stays legible with the panel collapsed.
1868
2660
  const parts = [`zoom: ${zoomValue()}`, view.env ? `env: ${view.env}` : "env: (source)"];
2661
+ // #399: which colour the graph is in is state, not a control, so it belongs
2662
+ // on the strip the same way the zoom and the env do. Only when it is not
2663
+ // drift — drift is what the strip has always implied.
2664
+ if (activeColourMode() !== "drift") parts.push(`colour: ${activeColourMode()}`);
1869
2665
  if (view.stack) parts.push(`stack: ${view.stack}`);
1870
2666
  if (axes.tier) parts.push(`tier: ${axes.tier}`);
1871
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);
1872
2673
  el.textContent = parts.join(" · ");
1873
2674
  // #131: why this level rendered empty, or as the one below it. The server
1874
2675
  // decides (src/zoom-notes.ts) — the SPA never infers it, so the note always
@@ -1878,7 +2679,15 @@ function renderStatusbar() {
1878
2679
  if (lastNote) {
1879
2680
  const note = document.createElement("span");
1880
2681
  note.className = "statusbar-note";
1881
- note.textContent = " — " + lastNote;
2682
+ // #393 item 7: the strip is a strip. A Terraform estate's note names every
2683
+ // root, every skipped directory with its reason, and every block class the
2684
+ // zoom left out — ~60 words, in a 260px panel, at every zoom. The server
2685
+ // sends the short form beside the long one (`meta.noteShort`) when it has
2686
+ // one; the full text stays one hover away here and is printed in full on
2687
+ // the panel's Model tab. Nothing is truncated client-side: the SPA does not
2688
+ // write notes, and a note with no short form is already a line.
2689
+ note.textContent = " — " + (lastNoteShort || lastNote);
2690
+ if (lastNoteShort) note.title = lastNote;
1882
2691
  el.appendChild(note);
1883
2692
  }
1884
2693
  }
@@ -1887,6 +2696,9 @@ function renderStatusbar() {
1887
2696
  // or null. Set on every load so a level that stops degrading stops explaining
1888
2697
  // itself.
1889
2698
  let lastNote = null;
2699
+ // #393 item 7: the same note as the strip can hold (`meta.noteShort`), or null
2700
+ // when the long one already fits. Never derived here — see renderStatusbar().
2701
+ let lastNoteShort = null;
1890
2702
 
1891
2703
  // The `meta` of the last rendered graph — the panel's Model tab reads its
1892
2704
  // mode/env to pick the status vocabulary (drift vs component live status).
@@ -2560,6 +3372,7 @@ function render(ir, svg, m) {
2560
3372
  // #131: set before anything can early-return, so a level that stopped
2561
3373
  // degrading stops explaining itself on the very next render.
2562
3374
  lastNote = m.note || null;
3375
+ lastNoteShort = m.noteShort || null;
2563
3376
  // The panel's Model tab reads both of these via renderStatusbar() →
2564
3377
  // renderPanel() below — set them first so it renders THIS graph, not the
2565
3378
  // previous one (recolorNodesByCategory also sets lastGraphIr; harmless).
@@ -2606,9 +3419,18 @@ function render(ir, svg, m) {
2606
3419
  const s = n.attrs && n.attrs._status;
2607
3420
  if (s in c) c[s]++;
2608
3421
  }
2609
- tail = ` · ${c.good} managed · ${c.warn} foreign · ${c.accent} pending`;
2610
- if (c.neutral) tail += ` · ${c.neutral} unobserved`;
3422
+ // #393 item 8: the estate's own words for these four colours — chant's
3423
+ // managed/foreign/pending/unobserved on a chant estate, choudoufu's
3424
+ // bound/unowned/pending/not observed on one whose members are choudoufu.
3425
+ const w = statusLabels();
3426
+ tail = ` · ${c.good} ${w.good} · ${c.warn} ${w.warn} · ${c.accent} ${w.accent}`;
3427
+ if (c.neutral) tail += ` · ${c.neutral} ${w.neutral}`;
2611
3428
  if (c.runtime) tail += ` · ${c.runtime} runtime`;
3429
+ // #404: beside the ownership counts, never folded into them — a drifted
3430
+ // card is bound and is already inside `c.good`. Silent when no plan was
3431
+ // read, so the strip never implies the question was asked and answered.
3432
+ const dm = driftMeta();
3433
+ if (dm && dm.read) tail += ` · ${dm.drifted} drifted`;
2612
3434
  // Nothing observed live in this env — explain the all-blue rather than let it
2613
3435
  // read as a bug (#32).
2614
3436
  if (c.good === 0 && c.warn === 0 && c.accent > 0) tail += ` — nothing deployed in ${m.env} yet`;
@@ -2625,8 +3447,9 @@ function render(ir, svg, m) {
2625
3447
  // Multi-estate (#31): note the composed project count; the graph draws one box per project.
2626
3448
  // #186: the meta line lives in the panel's 272px footer now — the directory
2627
3449
  // basename reads better than an absolute path (full path in the tooltip).
3450
+ // #393 item 4: one member is a project, not "1 projects".
2628
3451
  const scope = m.estate
2629
- ? `estate of ${m.estate} projects`
3452
+ ? `estate of ${m.estate} project${m.estate === 1 ? "" : "s"}`
2630
3453
  : String(m.projectDir || "").replace(/\/+$/, "").split("/").pop() || m.projectDir;
2631
3454
  // The deploy axes (#59 unify, M2 #54 lenses) — tier/target, kept in sync with
2632
3455
  // what this response actually observed (falls back to the launch-time value
@@ -2675,6 +3498,8 @@ function render(ir, svg, m) {
2675
3498
  markCarvedCards(); // #254: the SVG is replaced per render — re-stamp the marker
2676
3499
  markPlayhead(ir); // #284 item 2: same, for the step the run is sitting on
2677
3500
  markOperatorCards(ir); // #234 free rider: same, for the operating loop's home
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
2678
3503
  applyLayout(); // #228: last, so the hand-placed deltas ride on top of every other pass
2679
3504
  renderDial();
2680
3505
  }
@@ -2770,7 +3595,13 @@ function setupGraphViewBox(svg) {
2770
3595
  const a = (svg.getAttribute("viewBox") || "").split(/\s+/).map(Number);
2771
3596
  if (a.length === 4 && a.every((n) => !Number.isNaN(n))) {
2772
3597
  vbInit = a.slice();
2773
- 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();
2774
3605
  } else {
2775
3606
  vbInit = vb = null;
2776
3607
  }
@@ -2781,12 +3612,219 @@ function currentSvg() {
2781
3612
  function applyVB() {
2782
3613
  const s = currentSvg();
2783
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];
2784
3675
  }
3676
+
2785
3677
  function fitGraph() {
2786
- if (vbInit) {
2787
- vb = vbInit.slice();
2788
- 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
+ }
2789
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));
3787
+ }
3788
+ }
3789
+
3790
+ // How much of the viewBox one card should take when ⌘K lands on it (#393): the
3791
+ // window is this many card-widths across, so a 301-card estate arrives readable
3792
+ // rather than at whatever zoom the last gesture left. Never wider than the fit,
3793
+ // which is the whole graph — there is nothing beyond it to show.
3794
+ const REVEAL_CARDS_ACROSS = 9;
3795
+
3796
+ /**
3797
+ * Pan (and zoom) the graph so one card sits in the middle of the pane (#393
3798
+ * item 4). The same viewBox the wheel/drag handlers drive and "⤢ fit" resets —
3799
+ * there was no programmatic pan before this, only the reset.
3800
+ *
3801
+ * The card's rectangle comes from the DOM rather than from the IR: pinhole
3802
+ * paints a card as a `<g data-node-id>` whose first `<rect>` carries absolute
3803
+ * viewBox coordinates (no transform on the estate view, unlike the radial one),
3804
+ * and `getBBox()` covers whichever it is. Returns false when there is no such
3805
+ * card on the canvas — a node the current zoom elided — so the caller can say
3806
+ * so instead of panning to nowhere.
3807
+ */
3808
+ function revealNode(id) {
3809
+ const svg = currentSvg();
3810
+ if (!svg || !vb || !vbInit) return false;
3811
+ const g = svg.querySelector(`[data-node-id="${CSS.escape(id)}"]`);
3812
+ if (!g) return false;
3813
+ let box;
3814
+ try {
3815
+ box = g.getBBox();
3816
+ } catch {
3817
+ const r = g.querySelector("rect");
3818
+ if (!r) return false;
3819
+ box = { x: +r.getAttribute("x"), y: +r.getAttribute("y"), width: +r.getAttribute("width"), height: +r.getAttribute("height") };
3820
+ }
3821
+ if (!box.width || !box.height) return false;
3822
+ const w = Math.min(vbInit[2], Math.max(box.width * REVEAL_CARDS_ACROSS, vbInit[2] / 60));
3823
+ const h = w * (vbInit[3] / vbInit[2]);
3824
+ vb = [box.x + box.width / 2 - w / 2, box.y + box.height / 2 - h / 2, w, h];
3825
+ vbAtFit = false;
3826
+ applyVB();
3827
+ return true;
2790
3828
  }
2791
3829
  function ensureZoomControls(host) {
2792
3830
  let btn = document.getElementById("zoom-toggle");
@@ -2803,6 +3841,7 @@ function ensureZoomControls(host) {
2803
3841
  }
2804
3842
  if (zoomWired) return;
2805
3843
  zoomWired = true;
3844
+ watchPaneForFit(host);
2806
3845
  host.addEventListener(
2807
3846
  "wheel",
2808
3847
  (e) => {
@@ -2824,6 +3863,7 @@ function ensureZoomControls(host) {
2824
3863
  vb[1] = cy - ((cy - vb[1]) * nh) / vb[3];
2825
3864
  vb[2] = nw;
2826
3865
  vb[3] = nh;
3866
+ vbAtFit = false;
2827
3867
  applyVB();
2828
3868
  },
2829
3869
  { passive: false },
@@ -2856,6 +3896,7 @@ function ensureZoomControls(host) {
2856
3896
  }
2857
3897
  vb[0] -= (dx / r.width) * vb[2];
2858
3898
  vb[1] -= (dy / r.height) * vb[3];
3899
+ vbAtFit = false;
2859
3900
  px = e.clientX;
2860
3901
  py = e.clientY;
2861
3902
  applyVB();
@@ -3459,7 +4500,8 @@ function renderPreconditionError(body) {
3459
4500
  }
3460
4501
 
3461
4502
  // Fetch the current view (source graph, or the picked env's live overlay).
3462
- async function load(opts = {}) {
4503
+ const load = createRefreshQueue(loadOnce);
4504
+ async function loadOnce(opts = {}, isCurrent = () => true) {
3463
4505
  const meta = document.getElementById("meta");
3464
4506
  // A background settle re-pull (post-apply) shouldn't flash the meta/overlay.
3465
4507
  if (!opts.quiet) {
@@ -3478,6 +4520,14 @@ async function load(opts = {}) {
3478
4520
  // cross-substrate entity overlay, which components never use.
3479
4521
  q.set("components", "1");
3480
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);
3481
4531
  } else if (view.ops) {
3482
4532
  // The ops lens (#284): the declared Ops, read from each Op's emitted
3483
4533
  // op.json. Built fresh rather than layered onto the entity lenses,
@@ -3511,14 +4561,30 @@ async function load(opts = {}) {
3511
4561
  } else if (view.env) {
3512
4562
  endpoint = "/api/overlay";
3513
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");
3514
4569
  // The runtime tier (#86) descends below the declaration boundary. It is
3515
4570
  // live-only by nature — owner-referenced children exist in the cluster,
3516
4571
  // never in your source — so it rides the overlay and means nothing
3517
4572
  // without an env.
3518
4573
  if (view.runtime) q.set("runtime", "1");
4574
+ // #404: the attribute-drift read, when this load is the one the person
4575
+ // asked for it on. Consumed here — the next load goes without it.
4576
+ if (planDriftOnce) {
4577
+ q.set("plan", "1");
4578
+ planDriftOnce = false;
4579
+ }
3519
4580
  }
3520
4581
  // Radial layout (entity view only) — curl the wide DAG onto concentric rings.
3521
4582
  if (view.radial && !view.components && !view.logical && !view.ops) q.set("radial", "1");
4583
+ // Collapse large boxes (#393): a member box over the server's limit draws
4584
+ // as one card carrying its counts. An estate-box lens — the component DAG
4585
+ // and the ops view have no member boxes to shut — and it rides both the
4586
+ // source graph and the overlay, so picking an env doesn't undo it.
4587
+ if (view.collapse && !view.components && !view.ops) q.set("collapse", "1");
3522
4588
  // The CI + resources facets are component-DAG-mode-only details. Load
3523
4589
  // both whenever components mode is on, env picked or not — #59 unifies
3524
4590
  // the CI facet (#58), the live-status join (#57), and resources (#59) so
@@ -3531,6 +4597,7 @@ async function load(opts = {}) {
3531
4597
  }
3532
4598
  const res = await apiFetch(`${endpoint}?${q}`);
3533
4599
  const body = await res.json();
4600
+ if (!isCurrent()) return; // a newer view/event is pending; do not paint the old result
3534
4601
  if (!res.ok) {
3535
4602
  // #72: a classified precondition failure (lint gate, not installed, a
3536
4603
  // tier that needs credentials) gets the calmer entry/error card instead
@@ -3551,13 +4618,19 @@ async function load(opts = {}) {
3551
4618
  autoZoomFallback = false;
3552
4619
  applyZoom("resources");
3553
4620
  renderStatusbar();
3554
- return load(opts);
4621
+ return loadOnce(opts, isCurrent);
3555
4622
  }
3556
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;
3557
4630
  render(body.ir, body.svg, body.meta);
3558
4631
  } catch (err) {
3559
4632
  // A background settle poll must not blow away a good graph on a transient error.
3560
- if (!opts.quiet) {
4633
+ if (!opts.quiet && isCurrent()) {
3561
4634
  // Text, never innerHTML — `err.message` embeds chant's own stderr, which
3562
4635
  // is not ours to interpolate as markup (the sibling precondition card has
3563
4636
  // said so since #72; this branch had been left behind).
@@ -3574,6 +4647,35 @@ async function load(opts = {}) {
3574
4647
  }
3575
4648
  }
3576
4649
 
4650
+ /**
4651
+ * #404: re-read the overlay AND ask for the attribute-drift plan.
4652
+ *
4653
+ * A second row rather than a flag on `refresh()` above, for two reasons.
4654
+ *
4655
+ * The costs are different in kind. The ownership read a refresh already makes
4656
+ * (`live-ls` + `live-plan`) is answered out of the account's tagging index —
4657
+ * a handful of calls whatever the estate's size. `plan` refreshes every
4658
+ * resource, which is one provider read per card: on the workbench's
4659
+ * terralith-4 that is 301 reads for one keypress. Putting them on one button
4660
+ * would make the cheap, repeatable "did anything move?" silently cost the
4661
+ * expensive question every time.
4662
+ *
4663
+ * And they are not even the same request. `refresh()` is `POST /api/refresh`,
4664
+ * which re-observes the PRIMARY project and captures a lanes frame; it never
4665
+ * composes the estate. A choudoufu member is served through the estate branch
4666
+ * of `GET /api/overlay`, which is where the drift read lives — so this row
4667
+ * goes through `load()`, the path that actually renders those cards.
4668
+ */
4669
+ async function refreshWithPlan() {
4670
+ planDriftOnce = true;
4671
+ bulkDiffCache = null; // the pane's rows come from /api/diff — re-fetch them against the new plan
4672
+ nowline("↻ planning — a full read of the account…");
4673
+ await load();
4674
+ const dm = driftMeta();
4675
+ if (dm && dm.read) nowline(dm.drifted ? `↻ plan read — ${dm.drifted} drifted` : "↻ plan read — nothing drifted");
4676
+ else nowline("↻ the plan could not be read — the overlay is ownership only");
4677
+ }
4678
+
3577
4679
  // Refresh (#24): re-check live drift now and capture a lanes frame, in one
3578
4680
  // round-trip. Renders the returned graph directly (no second pull); the server's
3579
4681
  // `frames` event updates the lanes view.
@@ -3638,6 +4740,16 @@ async function initPickers() {
3638
4740
  // way it gates `tiers`/`stacks`), so an unbuilt project or a chant < 0.50
3639
4741
  // never grows a stop that would refuse.
3640
4742
  opsAvailable = info.ops || 0;
4743
+ // #393: whether the runtime stop exists here, and where this estate can
4744
+ // honestly open. Both are read before the first load() below, so a non-chant
4745
+ // estate never paints the components apology on its way to a picture.
4746
+ runtimeCapable = !!info.runtimeCapable;
4747
+ stacksCapable = !!info.stacksCapable;
4748
+ const boot = bootZoom(info.memberKinds);
4749
+ if (boot !== "components") {
4750
+ applyZoom(boot);
4751
+ autoZoomFallback = false;
4752
+ }
3641
4753
  // #254: carve mode declares itself here. The Carve tab is mounted at runtime
3642
4754
  // (panel.js's addPanelTab), so nothing else grows a dead tab.
3643
4755
  carveInfo = info.carve || null;
@@ -3659,20 +4771,39 @@ initPickers();
3659
4771
  // No backend in a static export → no live event stream; a no-op keeps the
3660
4772
  // `events.addEventListener(...)` wiring below harmless.
3661
4773
  const events = staticMode ? { addEventListener() {} } : new EventSource("/api/events");
3662
- // Post-op settle re-pull: an apply's CLI can exit while the last stacks are still
3663
- // flipping to *_COMPLETE, so the immediate reload catches a few components mid-
3664
- // deploy ("all done, 3 still pending"). Quietly re-pull a couple more times so
3665
- // the graph lands on the final colours without a manual Re-check live.
3666
- let settleTimers = [];
3667
- function scheduleSettle() {
3668
- settleTimers.forEach(clearTimeout);
3669
- settleTimers = [3000, 8000, 15000].map((ms) => setTimeout(() => load({ quiet: true }), ms));
3670
- }
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
+
3671
4803
  events.addEventListener("changed", () => {
3672
4804
  bulkDiffCache = null; // an op ran → per-node live state may have changed
3673
4805
  load();
3674
4806
  loadSubstrates(); // a bring-up (or any op) finished → re-detect readiness
3675
- scheduleSettle();
3676
4807
  });
3677
4808
 
3678
4809
  // Substrate readiness (M5, #54): is each substrate the project needs actually
@@ -3809,11 +4940,19 @@ setInterval(() => {
3809
4940
  // can't fire a second pull while one's in flight. Ref-counted — nested loads
3810
4941
  // (graph + CI + resources) only lift the scrim when the last finishes.
3811
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;
3812
4949
  function showLoading(msg) {
3813
4950
  loadingDepth++;
3814
4951
  const o = document.getElementById("loading-overlay");
3815
4952
  if (!o) return;
3816
4953
  document.getElementById("loading-msg").textContent = msg || "loading…";
4954
+ const cost = document.getElementById("loading-cost");
4955
+ if (cost) cost.textContent = lastReadCost || "";
3817
4956
  o.hidden = false;
3818
4957
  }
3819
4958
  function hideLoading() {
@@ -3821,6 +4960,27 @@ function hideLoading() {
3821
4960
  if (loadingDepth > 0) return;
3822
4961
  const o = document.getElementById("loading-overlay");
3823
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
+ }
3824
4984
  }
3825
4985
 
3826
4986
  // #259: the op stream is chant's own stdout, and an Op that reports as JSON
@@ -4409,6 +5569,17 @@ function paletteCommands() {
4409
5569
 
4410
5570
  // Reads — always available, even in a static export or the preview lock.
4411
5571
  if (!staticMode) c.push(["Re-check live (refresh drift)", () => refresh()]);
5572
+ // #404: the expensive sibling, listed beside it and named for what it costs.
5573
+ // Offered only with an env picked — there is no overlay to plan against
5574
+ // without one — and disabled-with-its-reason otherwise, the convention the
5575
+ // colour-mode and carve rows below already follow.
5576
+ if (!staticMode) {
5577
+ c.push(
5578
+ view.env
5579
+ ? ["Re-check live with plan (attribute drift) — a full read of the account", () => refreshWithPlan()]
5580
+ : ["Re-check live with plan (attribute drift) — pick an environment first", () => showToast("attribute drift is read from a plan, which needs an environment to plan against", false)],
5581
+ );
5582
+ }
4412
5583
  c.push(["Fit graph to view", () => fitGraph()]);
4413
5584
  c.push(["Export: current graph as SVG", () => exportSvg()]);
4414
5585
  const inspectCollapsed = document.getElementById("app").classList.contains("inspect-collapsed");
@@ -4439,12 +5610,39 @@ function paletteCommands() {
4439
5610
  for (const [label, v] of availableZooms()) {
4440
5611
  c.push([label + (v === zoomValue() ? " ✓" : ""), () => { applyZoom(v); load(); }]);
4441
5612
  }
5613
+ // #399: the colour-by modes, with the same ✓ every other lens row carries.
5614
+ // A disabled mode is LISTED with its reason rather than hidden — same rule
5615
+ // #254's blocked carve steps follow, and the same reason: "why can't I do
5616
+ // that" is a question the palette should be able to answer.
5617
+ for (const mode of COLOUR_MODES) {
5618
+ const gate = mode === "drift" ? { available: true, reason: "" } : modeAvailability(lastMeta, mode);
5619
+ const why = gate.available ? "" : ` — ${gate.reason}`;
5620
+ c.push([
5621
+ `colour by: ${mode}${mode === activeColourMode() ? " ✓" : ""}${why}`,
5622
+ () => {
5623
+ if (!gate.available) return showToast(gate.reason, false);
5624
+ setColourMode(mode);
5625
+ },
5626
+ ]);
5627
+ }
4442
5628
  // Radial toggle — entity zooms only, same gate the removed checkbox had
4443
5629
  // (components/logical/ops all lay themselves out: waves / nested arch boxes /
4444
5630
  // phase boxes).
4445
5631
  if (!view.components && !view.logical && !view.ops) {
4446
5632
  c.push([(view.radial ? "Disable" : "Enable") + " radial layout", () => { view.radial = !view.radial; load(); }]);
4447
5633
  }
5634
+ // #393: 301 cards in one box read as a strip at any zoom. This shuts every
5635
+ // box over the server's limit down to a card carrying its own counts, so an
5636
+ // estate can be read at the level of its members first.
5637
+ //
5638
+ // Not in a static export: `captureKeys` (src/export.ts) captures the estate
5639
+ // once, expanded, and a bundle that offers a stop it has no snapshot behind
5640
+ // answers the click with "not in this static export". `collapse` is in
5641
+ // `canonicalKey`'s LENS_PARAMS all the same, so the day a bundle does carry
5642
+ // both, the two keys are already distinct.
5643
+ if (!staticMode && !view.components && !view.ops) {
5644
+ c.push([view.collapse ? "Expand all" : "Collapse large boxes", () => { view.collapse = !view.collapse; load(); }]);
5645
+ }
4448
5646
 
4449
5647
  // Env/stack/tier/target selection — replaces the old header pickers.
4450
5648
  c.push(["env: (source)" + (!view.env ? " ✓" : ""), () => { view.env = null; resetDialCaches(); load(); }]);
@@ -4511,9 +5709,73 @@ function paletteCommands() {
4511
5709
  return c.map(([label, run]) => ({ label, run }));
4512
5710
  }
4513
5711
 
5712
+ // --- ⌘K takes an address (#393 item 4) ---------------------------------------
5713
+ // The audit's finding was that the palette had zoom, env, panel and deploy
5714
+ // commands and nothing that took a node. The SPA already holds the IR it
5715
+ // painted (`lastGraphIr`), so the whole feature is a filter over it.
5716
+ //
5717
+ // Built per keystroke rather than baked into `paletteCommands()` on open: a
5718
+ // 301-card estate would otherwise put 301 rows in front of somebody who typed
5719
+ // nothing, and push every command below them. Nothing at all until two
5720
+ // characters are typed, and at most {@link PAL_NODE_ROWS} rows after that.
5721
+ //
5722
+ // Outside `paletteCommands()` for a second reason: that function returns early
5723
+ // in a static export (no writes there at all), and finding a card is a READ.
5724
+ // Nothing below fetches anything — the IR is the one the page painted and the
5725
+ // pan is a viewBox — so a bundle answers ⌘K exactly as a served project does.
5726
+
5727
+ /** How many node rows the palette will show. A cap, not a ranking: past a
5728
+ * dozen the reader is scrolling a list rather than recognising a name, and the
5729
+ * answer is a longer query. */
5730
+ const PAL_NODE_ROWS = 12;
5731
+
5732
+ /** The address inside a composed id — `terralith-4/aws_iam_role.x` is the id,
5733
+ * `aws_iam_role.x` is what a person types. A member name cannot hold a slash
5734
+ * (composeStacks' `shortStackNames`), so the FIRST one splits it. */
5735
+ function nodeAddress(id) {
5736
+ const slash = id.indexOf("/");
5737
+ return slash >= 0 ? id.slice(slash + 1) : id;
5738
+ }
5739
+ function nodeMember(id) {
5740
+ const slash = id.indexOf("/");
5741
+ return slash >= 0 ? id.slice(0, slash) : "";
5742
+ }
5743
+
5744
+ /** The node rows for a query: matches on the composed id and on the address,
5745
+ * with anything that STARTS with the query first — typing `aws_iam_role.team_00`
5746
+ * should reach `aws_iam_role.team_0007_role` before a card that merely mentions
5747
+ * it. Stable within each half (the IR's own order), so the list does not
5748
+ * reshuffle as a query grows. */
5749
+ function paletteNodes(q) {
5750
+ if (!lastGraphIr || q.length < 2) return [];
5751
+ const prefix = [];
5752
+ const rest = [];
5753
+ for (const n of lastGraphIr.nodes) {
5754
+ const address = nodeAddress(n.id);
5755
+ const id = n.id.toLowerCase();
5756
+ const a = address.toLowerCase();
5757
+ if (a.startsWith(q) || id.startsWith(q)) prefix.push({ n, address });
5758
+ else if (a.includes(q) || id.includes(q)) rest.push({ n, address });
5759
+ if (prefix.length >= PAL_NODE_ROWS) break;
5760
+ }
5761
+ return [...prefix, ...rest].slice(0, PAL_NODE_ROWS).map(({ n, address }) => ({
5762
+ label: `node: ${address}`,
5763
+ // What the address alone does not say: which member it is in, and what it
5764
+ // is. Two members of one estate can declare the same address.
5765
+ sub: [nodeMember(n.id), n.kind, n.lexicon].filter(Boolean).join(" · "),
5766
+ run: () => {
5767
+ selectNode(n.id); // the same path a graph click takes — inspect included
5768
+ if (!revealNode(n.id)) showToast(`${address} is not on this view — it may be elided at this zoom`, false);
5769
+ },
5770
+ }));
5771
+ }
5772
+
4514
5773
  function palRender() {
4515
5774
  const q = palInput.value.toLowerCase().trim();
4516
- palCurrent = q ? palCmds.filter((c) => c.label.toLowerCase().includes(q)) : palCmds;
5775
+ const cmds = q ? palCmds.filter((c) => c.label.toLowerCase().includes(q)) : palCmds;
5776
+ // Nodes first: a query that matches a card is nearly always somebody looking
5777
+ // for that card, and a query that matches no card costs nothing.
5778
+ palCurrent = [...paletteNodes(q), ...cmds];
4517
5779
  palSel = Math.max(0, Math.min(palSel, palCurrent.length - 1));
4518
5780
  palList.replaceChildren();
4519
5781
  if (!palCurrent.length) {
@@ -4527,6 +5789,12 @@ function palRender() {
4527
5789
  const d = document.createElement("div");
4528
5790
  d.className = "row" + (i === palSel ? " sel" : "");
4529
5791
  d.textContent = c.label;
5792
+ if (c.sub) {
5793
+ const sub = document.createElement("div");
5794
+ sub.className = "sub";
5795
+ sub.textContent = c.sub;
5796
+ d.appendChild(sub);
5797
+ }
4530
5798
  d.onmousedown = (ev) => {
4531
5799
  ev.preventDefault();
4532
5800
  closePalette();