@intentius/behold 0.17.0 → 0.18.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.
package/web/app.js CHANGED
@@ -8,7 +8,31 @@
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 { initTheme, mountThemePicker, readableOn, colorForCategory, onThemeChange, getTokens, getTheme, pinTokensFor } from "./theme.js";
12
+ // #399 M2 / #401 M4 of #397: the colour-by modes' arithmetic and the
13
+ // provenance badge's wording — every decision the behaviour overlay makes,
14
+ // pure and unit-tested beside the file. This module owns the fetches, the SVG
15
+ // and the panels; nothing below states a figure of its own.
16
+ import {
17
+ COLOUR_MODES,
18
+ badgeFor,
19
+ blockOf,
20
+ countFigures,
21
+ countsText,
22
+ domainFor,
23
+ effectiveMode,
24
+ figureOf,
25
+ fillFor,
26
+ fmtCost,
27
+ fmtHeadroom,
28
+ headroomOf,
29
+ modeAvailability,
30
+ rampFor,
31
+ rampGradient,
32
+ scaleEnds,
33
+ totalsFor,
34
+ totalsText,
35
+ } from "./behaviour-scale.js";
12
36
  import { addPanelTab, initPanel, setPanelTab, togglePanelCollapsed, isPanelCollapsed } from "./panel.js";
13
37
  // #254: the carve walkthrough's stepper — everything it DECIDES is a pure
14
38
  // function in there; this file owns the fetches, the graph selection, and the
@@ -56,6 +80,7 @@ import { hasOperator, renderOperator, APPROVED_SEMANTICS } from "./operator.js";
56
80
  // which commit, approved by whom — and, when the ledger's run id cannot be
57
81
  // followed, the reason in words rather than a guessed link.
58
82
  import { releaseRows } from "./release.js";
83
+ import { comparisonRow, comparisonSummary } from "./cross-env.js";
59
84
  initTheme();
60
85
  initPanel();
61
86
  mountThemePicker(document.getElementById("panel-theme"));
@@ -65,16 +90,115 @@ mountThemePicker(document.getElementById("panel-theme"));
65
90
  // labels/icons get readable ink (black/white) on the category fill. Re-runs on theme switch,
66
91
  // since the categorical hues come from the active theme's palette.
67
92
  let lastGraphIr = null;
93
+
94
+ // --- colour by drift, cost or headroom (#399, M2 of #397) -------------------
95
+ //
96
+ // The mode is CLIENT-SIDE state, deliberately. It changes no fetch: the server
97
+ // hands the same overlay whichever mode is picked — `attrs._behaviour` rides
98
+ // every priced card already (src/behaviour.ts explains why the block stays on
99
+ // the node), so a mode switch is a repaint of the SVG that is already on
100
+ // screen, with no round trip and nothing for a slow chant read to hold up. It
101
+ // is therefore NOT in `LENS_PARAMS`/`canonicalKey`: a lens param there means "a
102
+ // different snapshot comes back", and adding one would give a static export
103
+ // three identical bundles of the same graph under three keys.
104
+ //
105
+ // It survives a reload the way the theme does, and for the same reason — the
106
+ // colour you left the graph in is part of how you read it.
107
+ const COLOUR_STORE_KEY = "behold.colourBy";
108
+ let colourMode = "drift";
109
+ try {
110
+ const saved = localStorage.getItem(COLOUR_STORE_KEY);
111
+ if (COLOUR_MODES.includes(saved)) colourMode = saved;
112
+ } catch {
113
+ /* private mode */
114
+ }
115
+
116
+ const COLOUR_MODE_TITLE = {
117
+ drift: "Colour each card by what chant observed live — the overlay behold has always drawn",
118
+ 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.",
119
+ 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.",
120
+ };
121
+
122
+ /** `meta.behaviour` off the last render, or null. */
123
+ function behaviourMeta() {
124
+ return (lastMeta && lastMeta.behaviour) || null;
125
+ }
126
+
127
+ /** The mode actually painted: the pick, unless this graph cannot serve it (no
128
+ * overlay, a refusal, an absence), in which case drift — which never depended
129
+ * on an engine. The PICK is kept either way, so switching back to a live env
130
+ * lands where you left off. */
131
+ function activeColourMode() {
132
+ return effectiveMode(colourMode, lastMeta);
133
+ }
134
+
135
+ function setColourMode(mode) {
136
+ colourMode = mode;
137
+ try {
138
+ localStorage.setItem(COLOUR_STORE_KEY, mode);
139
+ } catch {
140
+ /* private mode */
141
+ }
142
+ // No load(): the mode changes nothing the server would answer differently,
143
+ // so the SVG already on screen is repainted in place. renderStatusbar() is
144
+ // the one entry point that redraws the strip AND all three panel tabs, which
145
+ // is exactly the surface a mode switch moves.
146
+ recolorNodesByCategory();
147
+ renderStatusbar();
148
+ }
149
+
150
+ /**
151
+ * What each card is painted, in the active mode.
152
+ *
153
+ * Drift keeps the categorical hue #62 gives a card (the drift state stays on
154
+ * the bar and the stroke, where pinhole puts it). The two behaviour modes
155
+ * replace the FILL with the mode's scale, so the drift bar underneath is
156
+ * untouched and both facts stay on the card at once.
157
+ *
158
+ * The scale's domain is taken over every entity in the graph that carries a
159
+ * figure — the same single walk of `ir.nodes` M1's design note promised, with
160
+ * no join against a second index.
161
+ */
162
+ function colourPlan(graphIr) {
163
+ const mode = activeColourMode();
164
+ const tokens = getTokens();
165
+ const kindOf = new Map(graphIr.nodes.map((n) => [n.id, n.kind || n.lexicon || "node"]));
166
+ if (mode === "drift" || !tokens) {
167
+ return { mode, fillOf: (id) => ({ fill: colorForCategory(kindOf.get(id)), unpriced: false }) };
168
+ }
169
+ const theme = getTheme();
170
+ // The drift overlay's own neutral card ground — the colour a card wears when
171
+ // chant could not read it. #399: an entity with no block draws THIS, never
172
+ // the zero end of the scale, which would say "free" or "saturated" about an
173
+ // entity nothing priced.
174
+ const neutralFill = theme ? pinTokensFor(theme).neutralFill : tokens.neutral;
175
+ const ramp = rampFor(mode, tokens);
176
+ const domain = domainFor(
177
+ mode,
178
+ graphIr.nodes.map((n) => figureOf(mode, blockOf(n))),
179
+ );
180
+ const figures = new Map(graphIr.nodes.map((n) => [n.id, figureOf(mode, blockOf(n))]));
181
+ return { mode, domain, ramp, fillOf: (id) => fillFor(figures.get(id), domain, ramp, neutralFill) };
182
+ }
183
+
68
184
  function recolorNodesByCategory(ir) {
69
185
  if (ir) lastGraphIr = ir;
70
186
  const graphIr = ir || lastGraphIr;
71
187
  const svg = document.querySelector("#graph svg");
72
188
  if (!svg || !graphIr) return;
73
189
  const kindOf = new Map(graphIr.nodes.map((n) => [n.id, n.kind || n.lexicon || "node"]));
190
+ const plan = colourPlan(graphIr);
74
191
  for (const g of svg.querySelectorAll("[data-node-id]")) {
75
- const kind = kindOf.get(g.getAttribute("data-node-id"));
192
+ const id = g.getAttribute("data-node-id");
193
+ const kind = kindOf.get(id);
76
194
  if (!kind) continue;
77
- const cat = colorForCategory(kind), ink = readableOn(cat);
195
+ const paint = plan.fillOf(id);
196
+ const cat = paint.fill, ink = readableOn(cat);
197
+ // #399: an unpriced card is marked, not merely coloured — a hex cannot be
198
+ // read back out into "the engine did not price this", and the legend's
199
+ // unpriced count has to agree with what is on the canvas.
200
+ if (plan.mode !== "drift" && paint.unpriced) g.setAttribute("data-unpriced", "1");
201
+ else g.removeAttribute("data-unpriced");
78
202
  // Classify each element ONCE by the pinhole token it rode on (data-cat role), then apply
79
203
  // the role's colour on every pass. This is what makes it recolour on theme switch: after
80
204
  // the first pass the fill is a hex (not a --pin-* var), so we must key off the marker, not
@@ -158,7 +282,7 @@ if (staticMode) {
158
282
 
159
283
  /** Canonical key for a read URL — path + the lens params (whitelisted, sorted)
160
284
  * that select a distinct snapshot. MUST match src/export.ts `canonicalKey`. */
161
- const LENS_PARAMS = ["components", "detail", "env", "logical", "ops", "radial", "runtime", "tier"];
285
+ const LENS_PARAMS = ["collapse", "components", "detail", "env", "logical", "ops", "radial", "runtime", "tier"];
162
286
  function canonicalKey(path, params) {
163
287
  // Components + logical + ops views ignore detail/radial — drop them so they
164
288
  // match the single captured snapshot (MUST match src/export.ts).
@@ -187,6 +311,32 @@ function apiFetch(url) {
187
311
  // reaches a declared entity — expected runtime (a Pod its Deployment
188
312
  // created), never foreign and never drift. Additive the same way.
189
313
  const STATUS_LABEL = { good: "managed", warn: "foreign", accent: "pending", neutral: "unobserved", runtime: "runtime child" };
314
+ // #393 item 8: the same four colours, in the words of whatever the estate's
315
+ // members ARE. The server derives the set from the member kinds and puts it on
316
+ // the overlay meta (`vocabulary`, src/status-vocabulary.ts) — on a choudoufu
317
+ // estate the legend reads bound / unowned / pending / not observed, which is
318
+ // what choudoufu itself calls those states. A mixed estate keeps chant's words
319
+ // and carries a `note` the legend shows as its tooltip. No meta, or a meta from
320
+ // a behold that predates this: chant's words, exactly as before.
321
+ function statusLabels() {
322
+ const v = lastMeta && lastMeta.vocabulary;
323
+ return (v && v.labels) || STATUS_LABEL;
324
+ }
325
+ function statusLabel(status) {
326
+ return statusLabels()[status];
327
+ }
328
+ function vocabularyNote() {
329
+ const v = lastMeta && lastMeta.vocabulary;
330
+ return (v && v.note) || "";
331
+ }
332
+ // #404: whether a plan was read for this overlay, and how many cards it would
333
+ // change. `{read: false}` and `{read: true, drifted: 0}` are DIFFERENT answers
334
+ // — "nobody looked" versus "looked, nothing drifted" — and the legend must not
335
+ // print a count for the first one. Null from a behold that predates this, and
336
+ // every reader below treats null as "not read".
337
+ function driftMeta() {
338
+ return (lastMeta && lastMeta.drift) || null;
339
+ }
190
340
  // M1.1 (#57), palette hardened M2 (#54): the component-DAG live-status join
191
341
  // paints the same `_status` vocabulary (good/warn/accent/neutral) but with
192
342
  // different meaning — a stack-health reading, not "managed" — so the inspect
@@ -384,6 +534,37 @@ function renderDriftSection(section, r) {
384
534
  pr("rendered", `${p.renderedAt} · helm ${p.helmVersion} · chant ${p.chantVersion}`);
385
535
  }
386
536
 
537
+ const RESILIENCE_WORD = { survives: "survives", degrades: "degrades", fails: "fails" };
538
+
539
+ /**
540
+ * The `behaviour` inspect section (#401, M4 of #397) — one entity's whole
541
+ * block, as rows, with the provenance badge beside every figure.
542
+ *
543
+ * Every row here is the engine's claim, echoed. The currency is the engine's
544
+ * (behold converts none), the traffic level is the string the engine named
545
+ * (there is no default one), a headroom axis the engine did not report is
546
+ * simply not a row, and `rightSize` appears only when the engine suggested
547
+ * something. The badge on each figure says who stated it, to what tolerance,
548
+ * and whether it was modeled from list prices or validated against a bill.
549
+ */
550
+ function renderBehaviourSection(section, b) {
551
+ if (!b) return;
552
+ const meta = behaviourMeta();
553
+ const one = [b];
554
+ const add = section("behaviour");
555
+ add("at", b.at.traffic);
556
+ add("cost", figureWithBadge(fmtCost(b.cost.perHour, b.cost.currency), one, meta));
557
+ const axes = [];
558
+ if (typeof b.headroom.cpu === "number") axes.push(`cpu ${fmtHeadroom(b.headroom.cpu)}`);
559
+ if (typeof b.headroom.latency === "number") axes.push(`latency ${fmtHeadroom(b.headroom.latency)}`);
560
+ const worst = headroomOf(b);
561
+ add("headroom", figureWithBadge(`${axes.join(" · ")} — colours on ${fmtHeadroom(worst)}`, one, meta));
562
+ add("error rate", figureWithBadge(`${(b.errorRate * 100).toFixed(2)}% of requests at ${b.at.traffic}`, one, meta));
563
+ add("resilience", figureWithBadge(`${RESILIENCE_WORD[b.resilience.verdict] || b.resilience.verdict} — ${b.resilience.failure}`, one, meta));
564
+ if (b.resilience.note) add("resilience note", b.resilience.note);
565
+ if (b.rightSize) add("right-size", figureWithBadge(b.rightSize.suggestion + (b.rightSize.reason ? ` — ${b.rightSize.reason}` : ""), one, meta));
566
+ }
567
+
387
568
  function inspect(node) {
388
569
  const panel = document.getElementById("inspect-body");
389
570
  panel.innerHTML = "<h2>inspect</h2>";
@@ -431,7 +612,7 @@ function inspect(node) {
431
612
  "status",
432
613
  driftLabel ||
433
614
  (node.lexicon === "op" ? opStatusLabel(node) : undefined) ||
434
- (isArtifact ? ARTIFACT_STATUS_LABEL[st] : liveStatus || tickStatus ? COMPONENT_STATUS_LABEL[st] : STATUS_LABEL[st]) ||
615
+ (isArtifact ? ARTIFACT_STATUS_LABEL[st] : liveStatus || tickStatus ? COMPONENT_STATUS_LABEL[st] : statusLabel(st)) ||
435
616
  st,
436
617
  );
437
618
  if (node.attrs && node.attrs._artifact) {
@@ -508,6 +689,13 @@ function inspect(node) {
508
689
  });
509
690
  }
510
691
 
692
+ // #401: the behaviour block, field by field, with a badge on every figure.
693
+ // The colour on the card carries one number at a time (whichever mode is
694
+ // picked) and carries it as a hue; this is where the engine's whole
695
+ // statement about this entity is legible — and where a figure without its
696
+ // provenance would be visible as the contract violation it is.
697
+ renderBehaviourSection(section, blockOf(node));
698
+
511
699
  // Render diff (#146's deferred half): what `chant helm diff <digest> <env>
512
700
  // --live --json` said about this chart's PINNED render. Never rely on the
513
701
  // colour alone (#57's accessibility note) — and here that matters twice
@@ -638,7 +826,19 @@ function inspect(node) {
638
826
  const attrKeys = Object.keys(node.attrs || {}).filter((k) => !k.startsWith("_") && !(k === "release" && release));
639
827
  if (attrKeys.length) {
640
828
  const decl = section("declared");
641
- for (const k of attrKeys) decl(k, fmtValue(node.attrs[k]));
829
+ // #393 item 10: an UNOWNED choudoufu card leads with the write that adopts
830
+ // the object — the two marker tags choudoufu named (`adopt_tofu_estate` /
831
+ // `adopt_tofu_address`), or its own one-line command if a release ever
832
+ // sends one. behold composes neither (src/choudoufu-live.ts); this puts
833
+ // the line first and makes it copyable, the way the moves panel already
834
+ // does for a `live-mv` line. The paragraph explaining the omission — which
835
+ // used to be the whole section, with the tags under it as JSON — follows.
836
+ const adoptable = node.lexicon === "choudoufu" && typeof node.attrs.adopt === "string" ? "adopt" : null;
837
+ if (adoptable) copyableRow(decl, adoptable, node.attrs.adopt, "Copy the tags that adopt this object");
838
+ for (const k of attrKeys) {
839
+ if (k === adoptable) continue;
840
+ decl(k, fmtValue(node.attrs[k]));
841
+ }
642
842
  }
643
843
 
644
844
  // CI projection facet (M1.2, #56/#58): loomster's GitLab CI is the SAME
@@ -738,6 +938,13 @@ function inspect(node) {
738
938
  // node is instant (no per-node query). Via apiFetch, so a static export replays
739
939
  // the captured snapshot. Cache is per env; cleared on lens change / after an op.
740
940
  let bulkDiffCache = null; // { env, nodes: { <id>: { observed, diff, health } } }
941
+ // #404: ONE-SHOT. Set by the "Re-check live with plan" palette row and cleared
942
+ // by the request that carries it, so `plan=1` is exactly as opt-in as the
943
+ // person's keystroke — an ordinary load, a lens change or a reconnect never
944
+ // spawns a plan. The server still serves the change set that read produced
945
+ // from its own cache on the loads that follow, so the drift stays on screen
946
+ // without anyone paying for a second full pass over the account.
947
+ let planDriftOnce = false;
741
948
  async function loadNodeDiff(id) {
742
949
  if (!bulkDiffCache || bulkDiffCache.env !== view.env) {
743
950
  bulkDiffCache = null;
@@ -802,11 +1009,15 @@ function renderObserved(panel, o, health, healthDetail) {
802
1009
  // sends the raw condition object gets the tree instead of `[object Object]`.
803
1010
  for (const c of conditions) add("condition", valueCell(c), "var(--degraded)");
804
1011
  }
1012
+ // #393 item 10: the adoption line is a line to run, here as in the declared
1013
+ // section — first, and with the same copy button.
1014
+ if (typeof o.attributes?.adopt === "string") copyableRow(add, "adopt", o.attributes.adopt, "Copy the tags that adopt this object");
805
1015
  // #259: an observed attribute is whatever the substrate reported — a k8s
806
1016
  // `spec`, a nested `loadBalancer`, an array of ports. All of it collapsible
807
1017
  // now, instead of one flat JSON.stringify line per key.
808
1018
  for (const [k, v] of Object.entries(o.attributes || {})) {
809
1019
  if (k === "conditions") continue; // rendered above, one line each
1020
+ if (k === "adopt" && typeof v === "string") continue; // led with, above
810
1021
  add(k, valueCell(v));
811
1022
  }
812
1023
  panel.appendChild(dl);
@@ -814,6 +1025,11 @@ function renderObserved(panel, o, health, healthDetail) {
814
1025
 
815
1026
  const DIFF_LABEL = {
816
1027
  drifted: "drifted since snapshot",
1028
+ // #404: a choudoufu member has no snapshot to drift FROM — the category is
1029
+ // what a plan says it would do to the live object right now, so it does not
1030
+ // borrow chant's "since snapshot" wording. The rows underneath are the same
1031
+ // `{path, oldValue, newValue}` triples, printed `before → after`.
1032
+ planned: "the plan would change this",
817
1033
  missing: "declared, not in cloud",
818
1034
  orphan: "in cloud, not declared",
819
1035
  disappeared: "gone since snapshot",
@@ -987,7 +1203,7 @@ function wire(ir) {
987
1203
  // null on a project that declares no `stacks[]` at all — the picker (and the
988
1204
  // status strip's stack tag) then never renders. Every fetch reads this, so
989
1205
  // 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 };
1206
+ 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
1207
 
992
1208
  // #182: `components` is the boot default, but a project that declares no
993
1209
  // components renders it as ZERO nodes — the first screen was a blank graph
@@ -997,6 +1213,20 @@ const view = { env: null, detail: 2, components: true, logical: false, runtime:
997
1213
  // or ⌘K) still shows the honest empty view + the server's note.
998
1214
  let autoZoomFallback = true;
999
1215
 
1216
+ // #393 item 1: for an estate whose members are NONE of them chant projects, the
1217
+ // fallback above is a round-trip too late and one degree too vague. The
1218
+ // components zoom is a chant projection of a chant project's own component DAG;
1219
+ // a choudoufu estate or a bare Terraform directory has no components to
1220
+ // project, so the first screen was "the components lens doesn't apply to a
1221
+ // composed estate yet" — an apology, every time, before the person had picked
1222
+ // anything. The kinds come from /api/project (`memberKinds`, in composition
1223
+ // order), so the decision is made from what the server already knows rather
1224
+ // than from a zero-node answer. A chant member anywhere in the estate keeps
1225
+ // #182's behaviour exactly, and picking components later still shows the note.
1226
+ function bootZoom(memberKinds) {
1227
+ return memberKinds && memberKinds.length && !memberKinds.includes("chant") ? "resources" : "components";
1228
+ }
1229
+
1000
1230
  // v0.1.0 preview lock (set from /api/project in initActions): hides the git/PR
1001
1231
  // write ops (Rollback, Sync, Adopt, Run ▾) — the server also 403s them. Local
1002
1232
  // deploy (Apply all / dial), Reset, Bring up, Approve, and reads stay on.
@@ -1034,11 +1264,19 @@ const ZOOM_DETAIL = { composites: 1, resources: 2, attributes: 3, runtime: 3 };
1034
1264
  /** How many emitted Ops the served estate has (0 = the stop doesn't exist).
1035
1265
  * Seeded from /api/project in initPickers(). */
1036
1266
  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. */
1267
+ /** #393 item 2: whether ANY served member could have owner-referenced children
1268
+ * — /api/project's `runtimeCapable`, decided from the members' declared
1269
+ * lexicons (src/server.ts's RUNTIME_LEXICONS). Before this the stop needed only
1270
+ * an env, so every choudoufu estate (which declares `live`) offered a
1271
+ * Kubernetes tier that could only ever answer "nothing below the declaration
1272
+ * boundary". */
1273
+ let runtimeCapable = false;
1274
+ /** The zoom stops this project can actually offer — `runtime` needs an env AND
1275
+ * a member whose substrate has an owner chain, `ops` needs emitted op.json
1276
+ * files. Both the panel and ⌘K read this, so a stop never appears in one
1277
+ * surface and not the other. */
1040
1278
  function availableZooms() {
1041
- return ZOOM_OPTS.filter(([, z]) => (z === "runtime" ? Boolean(view.env) : z === "ops" ? opsAvailable > 0 : true));
1279
+ return ZOOM_OPTS.filter(([, z]) => (z === "runtime" ? Boolean(view.env) && runtimeCapable : z === "ops" ? opsAvailable > 0 : true));
1042
1280
  }
1043
1281
  /** Current zoom value from (components, logical, ops, detail). */
1044
1282
  function zoomValue() {
@@ -1109,6 +1347,28 @@ function renderPanelView() {
1109
1347
  }),
1110
1348
  );
1111
1349
  }
1350
+ // #399: the three-state colour switch, beside the zoom — the other thing
1351
+ // this tab is for is "what am I looking at", and colour is half that answer.
1352
+ // Sits below the stops because it is not a granularity: every zoom paints in
1353
+ // whichever of the three you picked.
1354
+ zoom.appendChild(panelHeading("colour by"));
1355
+ for (const mode of COLOUR_MODES) {
1356
+ const gate = mode === "drift" ? { available: true, reason: "" } : modeAvailability(lastMeta, mode);
1357
+ const b = panelOpt(
1358
+ mode,
1359
+ mode === activeColourMode(),
1360
+ () => {
1361
+ if (!gate.available) return;
1362
+ setColourMode(mode);
1363
+ },
1364
+ gate.available ? COLOUR_MODE_TITLE[mode] : gate.reason,
1365
+ );
1366
+ // #401: disabled VISIBLY, with the lexicon's own reason on it. A control
1367
+ // greyed out is where a person asks "why not", so that is where the answer
1368
+ // goes — never hidden, which would leave the question unasked.
1369
+ if (!gate.available) b.disabled = true;
1370
+ zoom.appendChild(b);
1371
+ }
1112
1372
  // Radial toggle — entity zooms only, same gate the ⌘K entry has
1113
1373
  // (components/logical/ops all lay themselves out: waves / nested arch boxes /
1114
1374
  // phase boxes).
@@ -1270,6 +1530,154 @@ function renderCarveState(host, state) {
1270
1530
  host.appendChild(panelMuted(state.apply.note));
1271
1531
  }
1272
1532
 
1533
+ /**
1534
+ * One inspect row whose value is a line to run: the line itself, monospaced,
1535
+ * and a copy button beside it — the affordance the moves panel already gives a
1536
+ * `live-mv` line (#371), reused here for the tags that adopt an unowned object
1537
+ * (#393 item 10). `add` is a section's own row adder, so the row sits in the
1538
+ * section it belongs to rather than in a box of its own.
1539
+ */
1540
+ function copyableRow(add, key, text, title) {
1541
+ const wrap = document.createElement("span");
1542
+ wrap.style.cssText = "display:flex;gap:6px;align-items:baseline;min-width:0";
1543
+ const line = document.createElement("code");
1544
+ line.className = "grow";
1545
+ line.style.cssText = "flex:1;min-width:0;overflow-wrap:anywhere";
1546
+ line.textContent = text;
1547
+ const copy = actButton("copy", () => copyToClipboard(text, copy), title);
1548
+ wrap.append(line, copy);
1549
+ add(key, wrap);
1550
+ }
1551
+
1552
+ /** Copy `text` to the clipboard and say so on the button for a moment. */
1553
+ function copyToClipboard(text, el) {
1554
+ const done = () => {
1555
+ const was = el.textContent;
1556
+ el.textContent = "copied ✓";
1557
+ setTimeout(() => {
1558
+ el.textContent = was;
1559
+ }, 1200);
1560
+ };
1561
+ if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(done, done);
1562
+ else done();
1563
+ }
1564
+
1565
+ /**
1566
+ * The choudoufu move plan (#371), as the scope panel shows it: one row per
1567
+ * move (`address: from → to`), the line a person runs in the destination
1568
+ * estate's directory with a copy button, and a link to the morph page. The
1569
+ * plan is read from `/api/choudoufu/moves`, which previews with choudoufu's
1570
+ * own `-dry-run` when asked and never runs the write — `apply.note` carries
1571
+ * that sentence here verbatim. A no-op when no served member carries a
1572
+ * `carve.json`.
1573
+ */
1574
+ function renderChoudoufuMoves(host, info) {
1575
+ if (!info || !info.plans || !info.plans.length || staticMode) return;
1576
+ host.appendChild(panelHeading("moves (choudoufu)"));
1577
+ for (const plan of info.plans) {
1578
+ const head = document.createElement("div");
1579
+ head.className = "count-row";
1580
+ const label = document.createElement("span");
1581
+ label.className = "grow";
1582
+ label.textContent = plan;
1583
+ const morph = document.createElement("a");
1584
+ morph.className = "tag";
1585
+ morph.textContent = "morph ↗";
1586
+ morph.href = "/choudoufu/morph?plan=" + encodeURIComponent(plan);
1587
+ morph.target = "_blank";
1588
+ morph.title = "Every member a box, the moved cards gliding to their destination";
1589
+ head.append(label, morph);
1590
+ host.appendChild(head);
1591
+ const rows = document.createElement("div");
1592
+ host.appendChild(rows);
1593
+ apiFetch("/api/choudoufu/moves?plan=" + encodeURIComponent(plan))
1594
+ .then((r) => r.json())
1595
+ .then((body) => {
1596
+ if (!body || !Array.isArray(body.moves)) {
1597
+ rows.appendChild(panelMuted((body && body.error) || "plan unreadable"));
1598
+ return;
1599
+ }
1600
+ for (const m of body.moves) {
1601
+ const row = document.createElement("div");
1602
+ row.className = "count-row";
1603
+ const name = document.createElement("span");
1604
+ name.className = "grow";
1605
+ name.textContent = m.address + (m.newAddress ? " → " + m.newAddress : "");
1606
+ name.title = "run in " + m.runIn;
1607
+ const tag = document.createElement("span");
1608
+ tag.className = "tag";
1609
+ tag.textContent = m.from + " → " + m.to;
1610
+ const copy = actButton("copy", () => copyToClipboard(m.command, copy), "Copy the live-mv line to the clipboard");
1611
+ row.append(name, tag, copy);
1612
+ rows.appendChild(row);
1613
+ rows.appendChild(panelMuted(m.command));
1614
+ }
1615
+ if (body.apply && body.apply.note) rows.appendChild(panelMuted(body.apply.note));
1616
+ })
1617
+ .catch(() => rows.appendChild(panelMuted("plan unreadable — is the behold server still running?")));
1618
+ }
1619
+ }
1620
+
1621
+ /**
1622
+ * The totals, per box and per estate, in the active mode (#399).
1623
+ *
1624
+ * Sits in the Scope tab because Scope is already the tab that says what this
1625
+ * estate IS — its members, its moves, its environments — and a per-box figure
1626
+ * is a fact about a member, not about the picture.
1627
+ *
1628
+ * In `drift` mode there is no row at all. Drift counts states; a sum of states
1629
+ * is not a thing, and the Model tab's legend already carries the counts.
1630
+ *
1631
+ * Where the figure comes from is on the row, every time. `cost` quotes the
1632
+ * server: `meta.behaviour.boxes[<key>]` per box and `.sum` for the estate, both
1633
+ * additions of engine figures — or `meta.behaviour.total`, the engine's own
1634
+ * estate figure, which outranks the sum and is labelled "engine total" so the
1635
+ * two can never be confused. `headroom` has no server-side aggregate, because
1636
+ * no engine states one and adding fractions of capacity would mean nothing, so
1637
+ * the SPA takes the worst and middle card of the scope itself and the row says
1638
+ * "computed".
1639
+ */
1640
+ function renderBehaviourTotals(host) {
1641
+ const mode = activeColourMode();
1642
+ if (mode === "drift") return;
1643
+ const b = behaviourMeta();
1644
+ const ir = lastGraphIr;
1645
+ if (!b || !ir) return;
1646
+ const byId = new Map(ir.nodes.map((n) => [n.id, n]));
1647
+ const boxes = (ir.groups && ir.groups.byStack) || {};
1648
+ host.appendChild(panelHeading(`${mode} totals`));
1649
+
1650
+ const row = (label, blocks, sum, engineTotal, title) => {
1651
+ const totals = totalsFor(mode, blocks, sum, engineTotal);
1652
+ if (!totals) return;
1653
+ // Stacked, not a name/value row: the panel is 260px and one of these
1654
+ // lines is "0.5669 USD/h · 41 priced · 4 unpriced" — side by side, the
1655
+ // figure takes the width and the box's name ellipses away to nothing,
1656
+ // which is the one part of the row that says WHAT is being totalled.
1657
+ const r = document.createElement("div");
1658
+ r.className = "behaviour-total";
1659
+ const name = document.createElement("div");
1660
+ name.className = "grow";
1661
+ name.style.fontWeight = "600";
1662
+ name.textContent = label;
1663
+ if (title) name.title = title;
1664
+ r.appendChild(name);
1665
+ r.appendChild(figureWithBadge(totalsText(totals), blocks.filter(Boolean), b));
1666
+ host.appendChild(r);
1667
+ };
1668
+
1669
+ for (const [key, ids] of Object.entries(boxes).sort(([a], [c]) => a.localeCompare(c))) {
1670
+ const blocks = ids.map((id) => blockOf(byId.get(id)));
1671
+ row(key, blocks, (b.boxes || {})[key], undefined, `${ids.length} entities in this box`);
1672
+ }
1673
+ const all = ir.nodes.map((n) => blockOf(n));
1674
+ // `total` is the engine's word and only ever holds an engine's own figure;
1675
+ // `sum` is behold's addition. Passing both lets totalsFor apply #398's
1676
+ // precedence in one place rather than at each call site.
1677
+ row(b.total ? "estate · engine total" : "estate", all, b.sum, b.total, b.total ? "the engine stated this estate total itself; behold added nothing" : "behold's sum over the engine's per-entity figures");
1678
+ 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."));
1679
+ }
1680
+
1273
1681
  function renderPanelScope() {
1274
1682
  const host = document.getElementById("tab-scope");
1275
1683
  if (!host) return;
@@ -1301,6 +1709,13 @@ function renderPanelScope() {
1301
1709
  // one — and it renders identically in carve mode and on an ordinary project
1302
1710
  // serve, because /api/project publishes one shape for both.
1303
1711
  renderCarveState(host, info.carve && info.carve.state);
1712
+ // #399: what the estate costs, or how much room it has left, per member box
1713
+ // and for the whole estate — right under the member list the figures are
1714
+ // about, and above the moves that would change them.
1715
+ renderBehaviourTotals(host);
1716
+ // #371: a choudoufu member's move plan — the handoff lines with copy
1717
+ // buttons, and the morph page. Read-only: behold never runs live-mv.
1718
+ renderChoudoufuMoves(host, info.choudoufu);
1304
1719
  // #195: switching — recents first (server-persisted, validated), then a
1305
1720
  // free path input. Locked in preview mode (the demo's contract) and
1306
1721
  // meaningless in a static export.
@@ -1362,6 +1777,57 @@ function renderPanelScope() {
1362
1777
  );
1363
1778
  }
1364
1779
  if (!environments.length) host.appendChild(panelMuted("no environments declared"));
1780
+
1781
+ // #234's last remainder — "is prod running what staging tested". Only when
1782
+ // an env is picked and there is another to compare against; ledger-only, so
1783
+ // it never adds a cloud read to opening the panel (fetch happens on pick).
1784
+ if (view.env && environments.length > 1 && !staticMode) {
1785
+ if (view.compareTo === view.env) view.compareTo = null; // env re-picked onto the compared one
1786
+ host.appendChild(panelHeading("compare releases"));
1787
+ const sel = document.createElement("select");
1788
+ sel.add(new Option("(off)", "", view.compareTo == null, view.compareTo == null));
1789
+ for (const e of environments) {
1790
+ if (e === view.env) continue;
1791
+ sel.add(new Option(`vs ${e}`, e, false, view.compareTo === e));
1792
+ }
1793
+ sel.title = "Compare this environment's recorded releases with another's — per component, on the comparable identity (inputs for pinned deploys)";
1794
+ const result = document.createElement("div");
1795
+ const render = (payload) => {
1796
+ result.replaceChildren();
1797
+ const s = comparisonSummary(payload);
1798
+ const line = document.createElement("div");
1799
+ line.style.cssText = `font-size:var(--t-caption);color:${COMPONENT_STATUS_VAR[s.tone === "warn" ? "warn" : s.tone === "good" ? "good" : "neutral"]};margin:4px 0`;
1800
+ line.textContent = s.text;
1801
+ result.appendChild(line);
1802
+ for (const c of payload.comparisons || []) {
1803
+ const r = comparisonRow(c);
1804
+ const row = panelDotRow(COMPONENT_STATUS_VAR[r.tone === "warn" ? "warn" : r.tone === "good" ? "good" : "neutral"], r.component, r.verdict, () => selectNode(r.component));
1805
+ row.title = r.detail;
1806
+ result.appendChild(row);
1807
+ }
1808
+ };
1809
+ sel.addEventListener("change", () => {
1810
+ view.compareTo = sel.value || null;
1811
+ result.replaceChildren();
1812
+ if (!view.compareTo) return;
1813
+ result.appendChild(panelMuted("comparing…"));
1814
+ fetch(`/api/components/compare?env=${encodeURIComponent(view.env)}&to=${encodeURIComponent(view.compareTo)}`)
1815
+ .then((r) => r.json())
1816
+ .then((payload) => {
1817
+ if (view.compareTo !== sel.value) return; // re-picked while loading
1818
+ if (payload.error) {
1819
+ result.replaceChildren(panelMuted(payload.error));
1820
+ return;
1821
+ }
1822
+ render(payload);
1823
+ })
1824
+ .catch((err) => result.replaceChildren(panelMuted(`compare failed: ${err && err.message ? err.message : err}`)));
1825
+ });
1826
+ host.appendChild(sel);
1827
+ host.appendChild(result);
1828
+ // Restore an active comparison across panel re-renders.
1829
+ if (view.compareTo) sel.dispatchEvent(new Event("change"));
1830
+ }
1365
1831
  if (stacks.length) {
1366
1832
  host.appendChild(panelHeading("stack"));
1367
1833
  for (const s of stacks) {
@@ -1410,6 +1876,103 @@ function renderPanelScope() {
1410
1876
  const DRIFT_STATUS_VAR = { good: "var(--managed)", warn: "var(--foreign)", accent: "var(--pending)", neutral: "var(--muted)", runtime: "var(--runtime)" };
1411
1877
  const COMPONENT_STATUS_VAR = { good: "var(--managed)", accent: "var(--pending)", warn: "var(--degraded)", neutral: "var(--muted)" };
1412
1878
 
1879
+ /**
1880
+ * The provenance badge (#401, M4 of #397) — `{engine} {version} · {tolerance}
1881
+ * · {basis}`, beside a figure rather than once per page.
1882
+ *
1883
+ * #397's one prohibition is a prediction presented as a bill, and this element
1884
+ * is where that is prevented: the word `modeled` is on the badge and the
1885
+ * sentence "modeled, not billed" is on its hover, on every figure that carries
1886
+ * one. `blocks` is the entities the figure was derived from — one for an
1887
+ * inspect row, a whole box or estate for a totals row — because #398 puts
1888
+ * provenance on the ENTITY, so a badge over a set can only say what the set
1889
+ * agrees on (badgeFor says `mixed` where it does not).
1890
+ */
1891
+ function badgeEl(blocks, meta) {
1892
+ const b = badgeFor(blocks, meta);
1893
+ if (!b) return null;
1894
+ const el = document.createElement("span");
1895
+ el.className = "tag behaviour-badge";
1896
+ el.textContent = b.text;
1897
+ el.title = b.title;
1898
+ return el;
1899
+ }
1900
+
1901
+ /** A figure and its badge on one line — the shape every behaviour figure the
1902
+ * SPA shows takes, so none of them can end up without one. */
1903
+ function figureWithBadge(text, blocks, meta) {
1904
+ const wrap = document.createElement("span");
1905
+ wrap.style.cssText = "display:flex;gap:6px;align-items:baseline;flex-wrap:wrap;min-width:0";
1906
+ const val = document.createElement("span");
1907
+ val.textContent = text;
1908
+ wrap.appendChild(val);
1909
+ const badge = badgeEl(blocks, meta);
1910
+ if (badge) wrap.appendChild(badge);
1911
+ return wrap;
1912
+ }
1913
+
1914
+ /**
1915
+ * One mode's legend (#399: one legend per mode).
1916
+ *
1917
+ * `drift` is not here — it keeps #393's vocabulary legend, the estate's own
1918
+ * words for the four states, which is the whole point of that issue. The two
1919
+ * behaviour modes get a ramp with its two ends labelled, the priced/unpriced
1920
+ * split, the neutral swatch that says what an unpriced card is drawn in, and
1921
+ * the badge for the figures the ramp spans.
1922
+ */
1923
+ function renderBehaviourLegend(host, mode) {
1924
+ const b = behaviourMeta();
1925
+ const ir = lastGraphIr;
1926
+ if (!b || !ir) return;
1927
+ const blocks = ir.nodes.map((n) => blockOf(n));
1928
+ const values = ir.nodes.map((n) => figureOf(mode, blockOf(n)));
1929
+ const domain = domainFor(mode, values);
1930
+ const tokens = getTokens();
1931
+ const ramp = rampFor(mode, tokens);
1932
+ const currency = (blocks.find((x) => x && x.cost) || { cost: {} }).cost.currency || "";
1933
+ const heading = panelHeading(`${mode} · ${lastMeta.env}`);
1934
+ heading.title = COLOUR_MODE_TITLE[mode];
1935
+ host.appendChild(heading);
1936
+
1937
+ const bar = document.createElement("div");
1938
+ bar.className = "behaviour-ramp";
1939
+ bar.style.cssText = `height:10px;border-radius:5px;border:1px solid var(--line);background:${rampGradient(ramp)}`;
1940
+ host.appendChild(bar);
1941
+ const ends = scaleEnds(mode, domain, currency);
1942
+ if (ends) {
1943
+ const row = document.createElement("div");
1944
+ row.className = "count-row";
1945
+ const lo = document.createElement("span");
1946
+ lo.className = "grow";
1947
+ lo.textContent = ends.low;
1948
+ const hi = document.createElement("span");
1949
+ hi.className = "tag";
1950
+ hi.textContent = ends.high;
1951
+ row.append(lo, hi);
1952
+ host.appendChild(row);
1953
+ }
1954
+
1955
+ const counts = countFigures(mode, blocks);
1956
+ const priced = blocks.filter((x) => x && figureOf(mode, x) !== undefined);
1957
+ const line = document.createElement("div");
1958
+ line.className = "count-row";
1959
+ const txt = document.createElement("span");
1960
+ txt.className = "grow";
1961
+ txt.textContent = countsText(counts);
1962
+ line.appendChild(txt);
1963
+ const badge = badgeEl(priced, b);
1964
+ if (badge) line.appendChild(badge);
1965
+ host.appendChild(line);
1966
+
1967
+ // The neutral, spelled out. Without this row the grey cards read as a third
1968
+ // band of the scale rather than as "nothing priced these".
1969
+ const theme = getTheme();
1970
+ host.appendChild(
1971
+ panelDotRow(theme ? pinTokensFor(theme).neutralFill : "var(--muted)", "unpriced — no behaviour block", String(counts.unpriced)),
1972
+ );
1973
+ if (mode === "headroom") host.appendChild(panelMuted("the lower of the axes the engine reported; a missing axis is absent, not zero"));
1974
+ }
1975
+
1413
1976
  function panelDotRow(color, main, tag, onClick) {
1414
1977
  const row = document.createElement("div");
1415
1978
  row.className = onClick ? "node-row" : "count-row";
@@ -1454,6 +2017,15 @@ function renderPanelModel() {
1454
2017
  host.appendChild(panelMuted("no graph loaded yet"));
1455
2018
  return;
1456
2019
  }
2020
+ // #393 item 7: the note in full, where there is room for it. The strip may be
2021
+ // showing the server's short form (a 260px strip cannot hold five root names,
2022
+ // two skip reasons and five block counts), so the whole sentence has to be
2023
+ // readable somewhere that is not a tooltip — and this tab is where the panel
2024
+ // already explains the picture.
2025
+ if (lastNote && lastNoteShort) {
2026
+ host.appendChild(panelHeading("note"));
2027
+ host.appendChild(panelMuted(lastNote));
2028
+ }
1457
2029
  const drift = m.mode === "overlay" || (m.mode === "logical" && !!m.env);
1458
2030
  const componentStatus = m.mode === "component-status";
1459
2031
  const count = (statuses) => {
@@ -1475,15 +2047,38 @@ function renderPanelModel() {
1475
2047
  const s = n.attrs && n.attrs._status;
1476
2048
  host.appendChild(panelDotRow(COMPONENT_STATUS_VAR[s] || "var(--muted)", n.id, APPLY_STATUS_TAG[s] || "", () => selectNode(n.id)));
1477
2049
  }
2050
+ } else if (drift && activeColourMode() !== "drift") {
2051
+ // #399: one legend per mode. The drift legend below is #393's vocabulary
2052
+ // legend and stays exactly as it was — this branch is the two behaviour
2053
+ // modes, whose legend is a scale rather than a set of states.
2054
+ renderBehaviourLegend(host, activeColourMode());
1478
2055
  } 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)) {
2056
+ // #393 item 8: the legend proper. The heading carries the mixed-estate
2057
+ // sentence as its tooltip — the one case where the words on these rows are
2058
+ // not the words the members would use for themselves.
2059
+ const heading = panelHeading(`drift · ${m.env}`);
2060
+ const why = vocabularyNote();
2061
+ if (why) heading.title = why;
2062
+ host.appendChild(heading);
2063
+ const words = statusLabels();
2064
+ const c = count(words);
2065
+ for (const [k, label] of Object.entries(words)) {
1482
2066
  // The additive buckets (chant#1168 unobserved, chant#1180 runtime) stay
1483
2067
  // hidden until a chant actually emits them — same as the old legend.
1484
2068
  if ((k === "neutral" || k === "runtime") && !c[k]) continue;
1485
2069
  host.appendChild(panelDotRow(DRIFT_STATUS_VAR[k], label, String(c[k])));
1486
2070
  }
2071
+ // #404: attribute drift, on its own row under the ownership counts —
2072
+ // beside them, never mixed into them, because a drifted card is still
2073
+ // bound and is already counted on the row above. The row appears only when
2074
+ // a plan was actually read; where it was not, the legend says so in words
2075
+ // and prints no number, because "0 drifted" would be a claim nobody made.
2076
+ const dm = driftMeta();
2077
+ if (dm && dm.read) {
2078
+ host.appendChild(panelDotRow("var(--degraded)", "drifted (the plan would change it)", String(dm.drifted)));
2079
+ } else if (dm) {
2080
+ host.appendChild(panelMuted("attribute drift not read — the overlay is ownership only. ⌘K → “Re-check live with plan”."));
2081
+ }
1487
2082
  // The actionable nodes — foreign (adoptable) and pending (not applied yet).
1488
2083
  const attention = ir.nodes.filter((n) => {
1489
2084
  const s = n.attrs && n.attrs._status;
@@ -1493,7 +2088,7 @@ function renderPanelModel() {
1493
2088
  host.appendChild(panelHeading("needs attention"));
1494
2089
  for (const n of attention.slice(0, 40)) {
1495
2090
  const s = n.attrs._status;
1496
- host.appendChild(panelDotRow(DRIFT_STATUS_VAR[s], n.id, STATUS_LABEL[s], () => selectNode(n.id)));
2091
+ host.appendChild(panelDotRow(DRIFT_STATUS_VAR[s], n.id, statusLabel(s), () => selectNode(n.id)));
1497
2092
  }
1498
2093
  if (attention.length > 40) host.appendChild(panelMuted(`+ ${attention.length - 40} more — click nodes in the graph`));
1499
2094
  }
@@ -1505,6 +2100,45 @@ function renderPanelModel() {
1505
2100
  host.appendChild(actButton("→ Scope", () => setPanelTab("scope")));
1506
2101
  }
1507
2102
  }
2103
+ renderBehaviourRefusal(host);
2104
+ renderBehaviourDiagnostics(host);
2105
+ }
2106
+
2107
+ /**
2108
+ * The refusal, where the legend would be (#401).
2109
+ *
2110
+ * The words are the lexicon's, in its own house style — an env-var chain and
2111
+ * the variable it wants — and behold prints them as they came. It does not
2112
+ * paraphrase them, does not soften them, and above all does not substitute a
2113
+ * number: "a missing or unreachable engine means no overlay and a refusal that
2114
+ * names why, never a locally faked number" is #397's rule, and this is the one
2115
+ * surface where a person finds out it fired.
2116
+ *
2117
+ * An ABSENCE prints nothing here. Nothing was configured, so nothing refused —
2118
+ * and a panel that announced every estate with no behavioural engine would be
2119
+ * shouting about a feature nobody asked for. The absent line is on the disabled
2120
+ * modes' tooltips instead, where the question actually gets asked.
2121
+ */
2122
+ function renderBehaviourRefusal(host) {
2123
+ const b = behaviourMeta();
2124
+ if (!b || !b.refusal) return;
2125
+ host.appendChild(panelHeading("behaviour — refused"));
2126
+ const reason = panelMuted(b.refusal.reason);
2127
+ reason.style.color = "var(--degraded)";
2128
+ host.appendChild(reason);
2129
+ host.appendChild(panelMuted(b.refusal.remedy));
2130
+ host.appendChild(panelMuted("the drift overlay above is unaffected — it never depended on an engine."));
2131
+ }
2132
+
2133
+ /** M1's diagnostics (`meta.behaviour.diagnostics`): every block behold dropped
2134
+ * and every thing it could not reconcile, under the legend where the figures
2135
+ * they are about are. They were reaching the wire and nothing was reading
2136
+ * them. */
2137
+ function renderBehaviourDiagnostics(host) {
2138
+ const b = behaviourMeta();
2139
+ if (!b || !b.diagnostics || !b.diagnostics.length) return;
2140
+ host.appendChild(panelHeading(`behaviour diagnostics · ${b.diagnostics.length}`));
2141
+ for (const d of b.diagnostics) host.appendChild(panelMuted(d));
1508
2142
  }
1509
2143
 
1510
2144
  // ---------------------------------------------------------------------------
@@ -1811,6 +2445,58 @@ function markOperatorCards(ir) {
1811
2445
  }
1812
2446
  }
1813
2447
 
2448
+ /**
2449
+ * #404: the second signal on a bound choudoufu card — the plan would change an
2450
+ * attribute on a resource the estate demonstrably owns.
2451
+ *
2452
+ * The ownership verdict keeps the card's FILL: bound is still green, and this
2453
+ * only adds a stroke and a corner glyph, the same post-render stamp
2454
+ * `markCarvedCards`/`markOperatorCards` use (pinhole's SVG arrives from the
2455
+ * server as a string and is replaced on every render, so a mark has to be
2456
+ * re-applied here rather than asked for at layout time).
2457
+ *
2458
+ * `~` is OpenTofu's own symbol for update-in-place, so the card and the
2459
+ * terminal read the same. The corner is the bottom-right one the carve and
2460
+ * operator marks also use — a choudoufu card is never carved (that is chant
2461
+ * source) and never the operating loop's home (that is Kubernetes), so on the
2462
+ * estates this can appear on the corner is free; the guard below still skips a
2463
+ * card that already carries one of those, rather than drawing over it.
2464
+ */
2465
+ function markDriftedCards(ir) {
2466
+ const svg = document.querySelector("#graph svg");
2467
+ if (!svg) return;
2468
+ for (const n of ir.nodes || []) {
2469
+ const mark = n.attrs && n.attrs._planDrift;
2470
+ if (!mark || !mark.attributes || !mark.attributes.length) continue;
2471
+ const g = svg.querySelector('[data-node-id="' + CSS.escape(n.id) + '"]');
2472
+ if (!g || g.querySelector('[data-plan-drift="1"]')) continue;
2473
+ if (g.querySelector('[data-carved="1"]') || g.querySelector('[data-operator="1"]')) continue;
2474
+ g.classList.add("plan-drifted");
2475
+ // Measured, not read off attributes — markCarvedCards()'s note applies
2476
+ // here verbatim (pinhole sizes a card from its content).
2477
+ const rect = g.querySelector("rect");
2478
+ const box = rect && rect.getBBox ? rect.getBBox() : null;
2479
+ const tag = document.createElementNS("http://www.w3.org/2000/svg", "text");
2480
+ tag.setAttribute("data-plan-drift", "1");
2481
+ tag.setAttribute("x", String((box ? box.x + box.width : 150) - 10));
2482
+ tag.setAttribute("y", String((box ? box.y + box.height : 60) - 9));
2483
+ tag.setAttribute("text-anchor", "end");
2484
+ tag.setAttribute("font-size", "11");
2485
+ tag.setAttribute("font-weight", "600");
2486
+ tag.setAttribute("fill", "var(--degraded)");
2487
+ // A COUNT, not the attribute names: a name like `image_tag_mutability` is
2488
+ // wider than the corner and runs into the card's own subtitle (seen in the
2489
+ // browser). The count is bounded whatever the provider calls its fields,
2490
+ // and the names are one hover — or one click, in the pane — away.
2491
+ const count_ = mark.attributes.length;
2492
+ tag.textContent = `~ ${count_} attribute${count_ === 1 ? "" : "s"}`;
2493
+ const title = document.createElementNS("http://www.w3.org/2000/svg", "title");
2494
+ 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.`;
2495
+ g.appendChild(title);
2496
+ g.appendChild(tag);
2497
+ }
2498
+ }
2499
+
1814
2500
  function renderPanelCarve() {
1815
2501
  if (!carveMode()) return;
1816
2502
  if (!carveHost) {
@@ -1866,6 +2552,10 @@ function renderStatusbar() {
1866
2552
  // Pure state — the strip echoes the axes whose controls live on the floating
1867
2553
  // panel and in ⌘K, so the current view stays legible with the panel collapsed.
1868
2554
  const parts = [`zoom: ${zoomValue()}`, view.env ? `env: ${view.env}` : "env: (source)"];
2555
+ // #399: which colour the graph is in is state, not a control, so it belongs
2556
+ // on the strip the same way the zoom and the env do. Only when it is not
2557
+ // drift — drift is what the strip has always implied.
2558
+ if (activeColourMode() !== "drift") parts.push(`colour: ${activeColourMode()}`);
1869
2559
  if (view.stack) parts.push(`stack: ${view.stack}`);
1870
2560
  if (axes.tier) parts.push(`tier: ${axes.tier}`);
1871
2561
  if (view.radial && !view.components && !view.logical && !view.ops) parts.push("radial");
@@ -1878,7 +2568,15 @@ function renderStatusbar() {
1878
2568
  if (lastNote) {
1879
2569
  const note = document.createElement("span");
1880
2570
  note.className = "statusbar-note";
1881
- note.textContent = " — " + lastNote;
2571
+ // #393 item 7: the strip is a strip. A Terraform estate's note names every
2572
+ // root, every skipped directory with its reason, and every block class the
2573
+ // zoom left out — ~60 words, in a 260px panel, at every zoom. The server
2574
+ // sends the short form beside the long one (`meta.noteShort`) when it has
2575
+ // one; the full text stays one hover away here and is printed in full on
2576
+ // the panel's Model tab. Nothing is truncated client-side: the SPA does not
2577
+ // write notes, and a note with no short form is already a line.
2578
+ note.textContent = " — " + (lastNoteShort || lastNote);
2579
+ if (lastNoteShort) note.title = lastNote;
1882
2580
  el.appendChild(note);
1883
2581
  }
1884
2582
  }
@@ -1887,6 +2585,9 @@ function renderStatusbar() {
1887
2585
  // or null. Set on every load so a level that stops degrading stops explaining
1888
2586
  // itself.
1889
2587
  let lastNote = null;
2588
+ // #393 item 7: the same note as the strip can hold (`meta.noteShort`), or null
2589
+ // when the long one already fits. Never derived here — see renderStatusbar().
2590
+ let lastNoteShort = null;
1890
2591
 
1891
2592
  // The `meta` of the last rendered graph — the panel's Model tab reads its
1892
2593
  // mode/env to pick the status vocabulary (drift vs component live status).
@@ -2560,6 +3261,7 @@ function render(ir, svg, m) {
2560
3261
  // #131: set before anything can early-return, so a level that stopped
2561
3262
  // degrading stops explaining itself on the very next render.
2562
3263
  lastNote = m.note || null;
3264
+ lastNoteShort = m.noteShort || null;
2563
3265
  // The panel's Model tab reads both of these via renderStatusbar() →
2564
3266
  // renderPanel() below — set them first so it renders THIS graph, not the
2565
3267
  // previous one (recolorNodesByCategory also sets lastGraphIr; harmless).
@@ -2606,9 +3308,18 @@ function render(ir, svg, m) {
2606
3308
  const s = n.attrs && n.attrs._status;
2607
3309
  if (s in c) c[s]++;
2608
3310
  }
2609
- tail = ` · ${c.good} managed · ${c.warn} foreign · ${c.accent} pending`;
2610
- if (c.neutral) tail += ` · ${c.neutral} unobserved`;
3311
+ // #393 item 8: the estate's own words for these four colours — chant's
3312
+ // managed/foreign/pending/unobserved on a chant estate, choudoufu's
3313
+ // bound/unowned/pending/not observed on one whose members are choudoufu.
3314
+ const w = statusLabels();
3315
+ tail = ` · ${c.good} ${w.good} · ${c.warn} ${w.warn} · ${c.accent} ${w.accent}`;
3316
+ if (c.neutral) tail += ` · ${c.neutral} ${w.neutral}`;
2611
3317
  if (c.runtime) tail += ` · ${c.runtime} runtime`;
3318
+ // #404: beside the ownership counts, never folded into them — a drifted
3319
+ // card is bound and is already inside `c.good`. Silent when no plan was
3320
+ // read, so the strip never implies the question was asked and answered.
3321
+ const dm = driftMeta();
3322
+ if (dm && dm.read) tail += ` · ${dm.drifted} drifted`;
2612
3323
  // Nothing observed live in this env — explain the all-blue rather than let it
2613
3324
  // read as a bug (#32).
2614
3325
  if (c.good === 0 && c.warn === 0 && c.accent > 0) tail += ` — nothing deployed in ${m.env} yet`;
@@ -2625,8 +3336,9 @@ function render(ir, svg, m) {
2625
3336
  // Multi-estate (#31): note the composed project count; the graph draws one box per project.
2626
3337
  // #186: the meta line lives in the panel's 272px footer now — the directory
2627
3338
  // basename reads better than an absolute path (full path in the tooltip).
3339
+ // #393 item 4: one member is a project, not "1 projects".
2628
3340
  const scope = m.estate
2629
- ? `estate of ${m.estate} projects`
3341
+ ? `estate of ${m.estate} project${m.estate === 1 ? "" : "s"}`
2630
3342
  : String(m.projectDir || "").replace(/\/+$/, "").split("/").pop() || m.projectDir;
2631
3343
  // The deploy axes (#59 unify, M2 #54 lenses) — tier/target, kept in sync with
2632
3344
  // what this response actually observed (falls back to the launch-time value
@@ -2675,6 +3387,7 @@ function render(ir, svg, m) {
2675
3387
  markCarvedCards(); // #254: the SVG is replaced per render — re-stamp the marker
2676
3388
  markPlayhead(ir); // #284 item 2: same, for the step the run is sitting on
2677
3389
  markOperatorCards(ir); // #234 free rider: same, for the operating loop's home
3390
+ 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
2678
3391
  applyLayout(); // #228: last, so the hand-placed deltas ride on top of every other pass
2679
3392
  renderDial();
2680
3393
  }
@@ -2788,6 +3501,45 @@ function fitGraph() {
2788
3501
  applyVB();
2789
3502
  }
2790
3503
  }
3504
+
3505
+ // How much of the viewBox one card should take when ⌘K lands on it (#393): the
3506
+ // window is this many card-widths across, so a 301-card estate arrives readable
3507
+ // rather than at whatever zoom the last gesture left. Never wider than the fit,
3508
+ // which is the whole graph — there is nothing beyond it to show.
3509
+ const REVEAL_CARDS_ACROSS = 9;
3510
+
3511
+ /**
3512
+ * Pan (and zoom) the graph so one card sits in the middle of the pane (#393
3513
+ * item 4). The same viewBox the wheel/drag handlers drive and "⤢ fit" resets —
3514
+ * there was no programmatic pan before this, only the reset.
3515
+ *
3516
+ * The card's rectangle comes from the DOM rather than from the IR: pinhole
3517
+ * paints a card as a `<g data-node-id>` whose first `<rect>` carries absolute
3518
+ * viewBox coordinates (no transform on the estate view, unlike the radial one),
3519
+ * and `getBBox()` covers whichever it is. Returns false when there is no such
3520
+ * card on the canvas — a node the current zoom elided — so the caller can say
3521
+ * so instead of panning to nowhere.
3522
+ */
3523
+ function revealNode(id) {
3524
+ const svg = currentSvg();
3525
+ if (!svg || !vb || !vbInit) return false;
3526
+ const g = svg.querySelector(`[data-node-id="${CSS.escape(id)}"]`);
3527
+ if (!g) return false;
3528
+ let box;
3529
+ try {
3530
+ box = g.getBBox();
3531
+ } catch {
3532
+ const r = g.querySelector("rect");
3533
+ if (!r) return false;
3534
+ box = { x: +r.getAttribute("x"), y: +r.getAttribute("y"), width: +r.getAttribute("width"), height: +r.getAttribute("height") };
3535
+ }
3536
+ if (!box.width || !box.height) return false;
3537
+ const w = Math.min(vbInit[2], Math.max(box.width * REVEAL_CARDS_ACROSS, vbInit[2] / 60));
3538
+ const h = w * (vbInit[3] / vbInit[2]);
3539
+ vb = [box.x + box.width / 2 - w / 2, box.y + box.height / 2 - h / 2, w, h];
3540
+ applyVB();
3541
+ return true;
3542
+ }
2791
3543
  function ensureZoomControls(host) {
2792
3544
  let btn = document.getElementById("zoom-toggle");
2793
3545
  if (!btn || btn.parentElement !== host) {
@@ -3516,9 +4268,20 @@ async function load(opts = {}) {
3516
4268
  // never in your source — so it rides the overlay and means nothing
3517
4269
  // without an env.
3518
4270
  if (view.runtime) q.set("runtime", "1");
4271
+ // #404: the attribute-drift read, when this load is the one the person
4272
+ // asked for it on. Consumed here — the next load goes without it.
4273
+ if (planDriftOnce) {
4274
+ q.set("plan", "1");
4275
+ planDriftOnce = false;
4276
+ }
3519
4277
  }
3520
4278
  // Radial layout (entity view only) — curl the wide DAG onto concentric rings.
3521
4279
  if (view.radial && !view.components && !view.logical && !view.ops) q.set("radial", "1");
4280
+ // Collapse large boxes (#393): a member box over the server's limit draws
4281
+ // as one card carrying its counts. An estate-box lens — the component DAG
4282
+ // and the ops view have no member boxes to shut — and it rides both the
4283
+ // source graph and the overlay, so picking an env doesn't undo it.
4284
+ if (view.collapse && !view.components && !view.ops) q.set("collapse", "1");
3522
4285
  // The CI + resources facets are component-DAG-mode-only details. Load
3523
4286
  // both whenever components mode is on, env picked or not — #59 unifies
3524
4287
  // the CI facet (#58), the live-status join (#57), and resources (#59) so
@@ -3574,6 +4337,35 @@ async function load(opts = {}) {
3574
4337
  }
3575
4338
  }
3576
4339
 
4340
+ /**
4341
+ * #404: re-read the overlay AND ask for the attribute-drift plan.
4342
+ *
4343
+ * A second row rather than a flag on `refresh()` above, for two reasons.
4344
+ *
4345
+ * The costs are different in kind. The ownership read a refresh already makes
4346
+ * (`live-ls` + `live-plan`) is answered out of the account's tagging index —
4347
+ * a handful of calls whatever the estate's size. `plan` refreshes every
4348
+ * resource, which is one provider read per card: on the workbench's
4349
+ * terralith-4 that is 301 reads for one keypress. Putting them on one button
4350
+ * would make the cheap, repeatable "did anything move?" silently cost the
4351
+ * expensive question every time.
4352
+ *
4353
+ * And they are not even the same request. `refresh()` is `POST /api/refresh`,
4354
+ * which re-observes the PRIMARY project and captures a lanes frame; it never
4355
+ * composes the estate. A choudoufu member is served through the estate branch
4356
+ * of `GET /api/overlay`, which is where the drift read lives — so this row
4357
+ * goes through `load()`, the path that actually renders those cards.
4358
+ */
4359
+ async function refreshWithPlan() {
4360
+ planDriftOnce = true;
4361
+ bulkDiffCache = null; // the pane's rows come from /api/diff — re-fetch them against the new plan
4362
+ nowline("↻ planning — a full read of the account…");
4363
+ await load();
4364
+ const dm = driftMeta();
4365
+ if (dm && dm.read) nowline(dm.drifted ? `↻ plan read — ${dm.drifted} drifted` : "↻ plan read — nothing drifted");
4366
+ else nowline("↻ the plan could not be read — the overlay is ownership only");
4367
+ }
4368
+
3577
4369
  // Refresh (#24): re-check live drift now and capture a lanes frame, in one
3578
4370
  // round-trip. Renders the returned graph directly (no second pull); the server's
3579
4371
  // `frames` event updates the lanes view.
@@ -3638,6 +4430,15 @@ async function initPickers() {
3638
4430
  // way it gates `tiers`/`stacks`), so an unbuilt project or a chant < 0.50
3639
4431
  // never grows a stop that would refuse.
3640
4432
  opsAvailable = info.ops || 0;
4433
+ // #393: whether the runtime stop exists here, and where this estate can
4434
+ // honestly open. Both are read before the first load() below, so a non-chant
4435
+ // estate never paints the components apology on its way to a picture.
4436
+ runtimeCapable = !!info.runtimeCapable;
4437
+ const boot = bootZoom(info.memberKinds);
4438
+ if (boot !== "components") {
4439
+ applyZoom(boot);
4440
+ autoZoomFallback = false;
4441
+ }
3641
4442
  // #254: carve mode declares itself here. The Carve tab is mounted at runtime
3642
4443
  // (panel.js's addPanelTab), so nothing else grows a dead tab.
3643
4444
  carveInfo = info.carve || null;
@@ -4409,6 +5210,17 @@ function paletteCommands() {
4409
5210
 
4410
5211
  // Reads — always available, even in a static export or the preview lock.
4411
5212
  if (!staticMode) c.push(["Re-check live (refresh drift)", () => refresh()]);
5213
+ // #404: the expensive sibling, listed beside it and named for what it costs.
5214
+ // Offered only with an env picked — there is no overlay to plan against
5215
+ // without one — and disabled-with-its-reason otherwise, the convention the
5216
+ // colour-mode and carve rows below already follow.
5217
+ if (!staticMode) {
5218
+ c.push(
5219
+ view.env
5220
+ ? ["Re-check live with plan (attribute drift) — a full read of the account", () => refreshWithPlan()]
5221
+ : ["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)],
5222
+ );
5223
+ }
4412
5224
  c.push(["Fit graph to view", () => fitGraph()]);
4413
5225
  c.push(["Export: current graph as SVG", () => exportSvg()]);
4414
5226
  const inspectCollapsed = document.getElementById("app").classList.contains("inspect-collapsed");
@@ -4439,12 +5251,39 @@ function paletteCommands() {
4439
5251
  for (const [label, v] of availableZooms()) {
4440
5252
  c.push([label + (v === zoomValue() ? " ✓" : ""), () => { applyZoom(v); load(); }]);
4441
5253
  }
5254
+ // #399: the colour-by modes, with the same ✓ every other lens row carries.
5255
+ // A disabled mode is LISTED with its reason rather than hidden — same rule
5256
+ // #254's blocked carve steps follow, and the same reason: "why can't I do
5257
+ // that" is a question the palette should be able to answer.
5258
+ for (const mode of COLOUR_MODES) {
5259
+ const gate = mode === "drift" ? { available: true, reason: "" } : modeAvailability(lastMeta, mode);
5260
+ const why = gate.available ? "" : ` — ${gate.reason}`;
5261
+ c.push([
5262
+ `colour by: ${mode}${mode === activeColourMode() ? " ✓" : ""}${why}`,
5263
+ () => {
5264
+ if (!gate.available) return showToast(gate.reason, false);
5265
+ setColourMode(mode);
5266
+ },
5267
+ ]);
5268
+ }
4442
5269
  // Radial toggle — entity zooms only, same gate the removed checkbox had
4443
5270
  // (components/logical/ops all lay themselves out: waves / nested arch boxes /
4444
5271
  // phase boxes).
4445
5272
  if (!view.components && !view.logical && !view.ops) {
4446
5273
  c.push([(view.radial ? "Disable" : "Enable") + " radial layout", () => { view.radial = !view.radial; load(); }]);
4447
5274
  }
5275
+ // #393: 301 cards in one box read as a strip at any zoom. This shuts every
5276
+ // box over the server's limit down to a card carrying its own counts, so an
5277
+ // estate can be read at the level of its members first.
5278
+ //
5279
+ // Not in a static export: `captureKeys` (src/export.ts) captures the estate
5280
+ // once, expanded, and a bundle that offers a stop it has no snapshot behind
5281
+ // answers the click with "not in this static export". `collapse` is in
5282
+ // `canonicalKey`'s LENS_PARAMS all the same, so the day a bundle does carry
5283
+ // both, the two keys are already distinct.
5284
+ if (!staticMode && !view.components && !view.ops) {
5285
+ c.push([view.collapse ? "Expand all" : "Collapse large boxes", () => { view.collapse = !view.collapse; load(); }]);
5286
+ }
4448
5287
 
4449
5288
  // Env/stack/tier/target selection — replaces the old header pickers.
4450
5289
  c.push(["env: (source)" + (!view.env ? " ✓" : ""), () => { view.env = null; resetDialCaches(); load(); }]);
@@ -4511,9 +5350,73 @@ function paletteCommands() {
4511
5350
  return c.map(([label, run]) => ({ label, run }));
4512
5351
  }
4513
5352
 
5353
+ // --- ⌘K takes an address (#393 item 4) ---------------------------------------
5354
+ // The audit's finding was that the palette had zoom, env, panel and deploy
5355
+ // commands and nothing that took a node. The SPA already holds the IR it
5356
+ // painted (`lastGraphIr`), so the whole feature is a filter over it.
5357
+ //
5358
+ // Built per keystroke rather than baked into `paletteCommands()` on open: a
5359
+ // 301-card estate would otherwise put 301 rows in front of somebody who typed
5360
+ // nothing, and push every command below them. Nothing at all until two
5361
+ // characters are typed, and at most {@link PAL_NODE_ROWS} rows after that.
5362
+ //
5363
+ // Outside `paletteCommands()` for a second reason: that function returns early
5364
+ // in a static export (no writes there at all), and finding a card is a READ.
5365
+ // Nothing below fetches anything — the IR is the one the page painted and the
5366
+ // pan is a viewBox — so a bundle answers ⌘K exactly as a served project does.
5367
+
5368
+ /** How many node rows the palette will show. A cap, not a ranking: past a
5369
+ * dozen the reader is scrolling a list rather than recognising a name, and the
5370
+ * answer is a longer query. */
5371
+ const PAL_NODE_ROWS = 12;
5372
+
5373
+ /** The address inside a composed id — `terralith-4/aws_iam_role.x` is the id,
5374
+ * `aws_iam_role.x` is what a person types. A member name cannot hold a slash
5375
+ * (composeStacks' `shortStackNames`), so the FIRST one splits it. */
5376
+ function nodeAddress(id) {
5377
+ const slash = id.indexOf("/");
5378
+ return slash >= 0 ? id.slice(slash + 1) : id;
5379
+ }
5380
+ function nodeMember(id) {
5381
+ const slash = id.indexOf("/");
5382
+ return slash >= 0 ? id.slice(0, slash) : "";
5383
+ }
5384
+
5385
+ /** The node rows for a query: matches on the composed id and on the address,
5386
+ * with anything that STARTS with the query first — typing `aws_iam_role.team_00`
5387
+ * should reach `aws_iam_role.team_0007_role` before a card that merely mentions
5388
+ * it. Stable within each half (the IR's own order), so the list does not
5389
+ * reshuffle as a query grows. */
5390
+ function paletteNodes(q) {
5391
+ if (!lastGraphIr || q.length < 2) return [];
5392
+ const prefix = [];
5393
+ const rest = [];
5394
+ for (const n of lastGraphIr.nodes) {
5395
+ const address = nodeAddress(n.id);
5396
+ const id = n.id.toLowerCase();
5397
+ const a = address.toLowerCase();
5398
+ if (a.startsWith(q) || id.startsWith(q)) prefix.push({ n, address });
5399
+ else if (a.includes(q) || id.includes(q)) rest.push({ n, address });
5400
+ if (prefix.length >= PAL_NODE_ROWS) break;
5401
+ }
5402
+ return [...prefix, ...rest].slice(0, PAL_NODE_ROWS).map(({ n, address }) => ({
5403
+ label: `node: ${address}`,
5404
+ // What the address alone does not say: which member it is in, and what it
5405
+ // is. Two members of one estate can declare the same address.
5406
+ sub: [nodeMember(n.id), n.kind, n.lexicon].filter(Boolean).join(" · "),
5407
+ run: () => {
5408
+ selectNode(n.id); // the same path a graph click takes — inspect included
5409
+ if (!revealNode(n.id)) showToast(`${address} is not on this view — it may be elided at this zoom`, false);
5410
+ },
5411
+ }));
5412
+ }
5413
+
4514
5414
  function palRender() {
4515
5415
  const q = palInput.value.toLowerCase().trim();
4516
- palCurrent = q ? palCmds.filter((c) => c.label.toLowerCase().includes(q)) : palCmds;
5416
+ const cmds = q ? palCmds.filter((c) => c.label.toLowerCase().includes(q)) : palCmds;
5417
+ // Nodes first: a query that matches a card is nearly always somebody looking
5418
+ // for that card, and a query that matches no card costs nothing.
5419
+ palCurrent = [...paletteNodes(q), ...cmds];
4517
5420
  palSel = Math.max(0, Math.min(palSel, palCurrent.length - 1));
4518
5421
  palList.replaceChildren();
4519
5422
  if (!palCurrent.length) {
@@ -4527,6 +5430,12 @@ function palRender() {
4527
5430
  const d = document.createElement("div");
4528
5431
  d.className = "row" + (i === palSel ? " sel" : "");
4529
5432
  d.textContent = c.label;
5433
+ if (c.sub) {
5434
+ const sub = document.createElement("div");
5435
+ sub.className = "sub";
5436
+ sub.textContent = c.sub;
5437
+ d.appendChild(sub);
5438
+ }
4530
5439
  d.onmousedown = (ev) => {
4531
5440
  ev.preventDefault();
4532
5441
  closePalette();