@intentius/behold 0.19.0 → 0.21.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 (52) hide show
  1. package/AGENTS.md +90 -5
  2. package/README.md +54 -1
  3. package/demos.json +3 -2
  4. package/dist/cli.js +1327 -303
  5. package/example-argo-estate/README.md +8 -8
  6. package/example-argo-estate/app-a/package.json +2 -2
  7. package/example-argo-estate/app-b/package.json +2 -2
  8. package/example-argo-estate/chant.workspace.json +27 -0
  9. package/example-argo-estate/control-plane/package.json +2 -2
  10. package/example-argo-estate/package-lock.json +133 -56
  11. package/example-carve/README.md +14 -14
  12. package/example-carve/app/package-lock.json +72 -9
  13. package/example-carve/app/package.json +2 -2
  14. package/example-carve/legacy-tf/versions.tf +1 -1
  15. package/example-choudoufu-estate/README.md +4 -4
  16. package/example-choudoufu-estate/chant.workspace.json +32 -0
  17. package/example-choudoufu-estate/scripts/choudoufu-up.sh +21 -0
  18. package/example-flux-estate/README.md +5 -5
  19. package/example-flux-estate/app-a/package.json +2 -2
  20. package/example-flux-estate/app-b/package.json +2 -2
  21. package/example-flux-estate/chant.workspace.json +27 -0
  22. package/example-flux-estate/control-plane/package.json +2 -2
  23. package/example-flux-estate/package-lock.json +133 -56
  24. package/example-k8s/README.md +20 -20
  25. package/example-k8s/chant.config.ts +1 -1
  26. package/example-k8s/ops/k3d-apply.op.ts +1 -1
  27. package/example-k8s/package-lock.json +129 -211
  28. package/example-k8s/package.json +2 -3
  29. package/example-terraform-estate/README.md +5 -5
  30. package/example-terraform-estate/chant.workspace.json +22 -0
  31. package/example-writes/README.md +5 -5
  32. package/example-writes/chant.config.ts +1 -1
  33. package/example-writes/ops/apply.op.ts +1 -1
  34. package/example-writes/ops/floci.op.ts +2 -2
  35. package/example-writes/ops/reconcile.op.ts +1 -1
  36. package/example-writes/package-lock.json +62 -175
  37. package/example-writes/package.json +2 -3
  38. package/package.json +6 -6
  39. package/web/app.js +431 -17
  40. package/web/behaviour-scale.js +32 -0
  41. package/web/behaviour-scale.test.js +39 -0
  42. package/web/embed.js +76 -0
  43. package/web/embed.test.js +45 -0
  44. package/web/index.html +20 -0
  45. package/web/operator.js +14 -1
  46. package/web/panel.js +11 -3
  47. package/web/run-playhead.js +13 -1
  48. package/web/view-url.js +138 -0
  49. package/web/view-url.test.js +84 -0
  50. package/web/workspace-gates.js +44 -0
  51. package/web/workspace-gates.test.js +34 -0
  52. package/example-choudoufu-estate/.behold.json +0 -8
package/web/app.js CHANGED
@@ -9,9 +9,12 @@
9
9
  // position), and the theme picker into the panel's View-tab slot (a stable element
10
10
  // renderPanelView never rewrites, so the select mounts once and survives re-renders).
11
11
  import { createRefreshQueue } from "./refresh-queue.js";
12
+ import { readViewQuery, writeViewQuery, settleView, settlePlace, refusalLine } from "./view-url.js";
13
+ import { readEmbed, selectMessage, viewFromMessage } from "./embed.js";
14
+ import { gateCards } from "./workspace-gates.js";
12
15
  import { readCostLine } from "./read-cost.js";
13
16
  import { applyMemberFrame, pendingLine, stillPending } from "./pending.js";
14
- import { initTheme, mountThemePicker, readableOn, colorForCategory, onThemeChange, getTokens, getTheme, pinTokensFor } from "./theme.js";
17
+ import { initTheme, setTheme, mountThemePicker, readableOn, colorForCategory, onThemeChange, getTokens, getTheme, pinTokensFor } from "./theme.js";
15
18
  // #399 M2 / #401 M4 of #397: the colour-by modes' arithmetic and the
16
19
  // provenance badge's wording — every decision the behaviour overlay makes,
17
20
  // pure and unit-tested beside the file. This module owns the fetches, the SVG
@@ -35,6 +38,7 @@ import {
35
38
  scaleEnds,
36
39
  totalsFor,
37
40
  totalsText,
41
+ deltaRows,
38
42
  } from "./behaviour-scale.js";
39
43
  import { addPanelTab, initPanel, setPanelTab, togglePanelCollapsed, isPanelCollapsed } from "./panel.js";
40
44
  // #254: the carve walkthrough's stepper — everything it DECIDES is a pure
@@ -85,7 +89,13 @@ import { hasOperator, renderOperator, APPROVED_SEMANTICS } from "./operator.js";
85
89
  import { releaseRows } from "./release.js";
86
90
  import { comparisonRow, comparisonSummary } from "./cross-env.js";
87
91
  initTheme();
88
- initPanel();
92
+ // #476: framed in a host pane. The flag is a class on <html>, so the CSS that
93
+ // folds the chrome away needs no JS, and the theme is the host's for this
94
+ // frame only.
95
+ const embedOpts = readEmbed(location.search);
96
+ if (embedOpts.theme && !setTheme(embedOpts.theme, { persist: false })) console.warn(`behold: no theme named ${embedOpts.theme}`);
97
+ if (embedOpts.embed) document.documentElement.classList.add("embed");
98
+ initPanel({ embed: embedOpts.embed });
89
99
  mountThemePicker(document.getElementById("panel-theme"));
90
100
 
91
101
  // Colour node fills by category/kind using the theme's FULL palette (spicypath-style, so the
@@ -115,6 +125,8 @@ try {
115
125
  } catch {
116
126
  /* private mode */
117
127
  }
128
+ // #475: what a load with no `lens=` would paint, so the URL carries any other.
129
+ let savedColourMode = colourMode;
118
130
 
119
131
  const COLOUR_MODE_TITLE = {
120
132
  drift: "Colour each card by what chant observed live — the overlay behold has always drawn",
@@ -139,6 +151,7 @@ function setColourMode(mode) {
139
151
  colourMode = mode;
140
152
  try {
141
153
  localStorage.setItem(COLOUR_STORE_KEY, mode);
154
+ savedColourMode = mode;
142
155
  } catch {
143
156
  /* private mode */
144
157
  }
@@ -727,6 +740,15 @@ function inspect(node) {
727
740
  // statement about this entity is legible — and where a figure without its
728
741
  // provenance would be visible as the contract violation it is.
729
742
  renderBehaviourSection(section, blockOf(node));
743
+ // #402: what the FILE says this entity costs, beside what the account does.
744
+ // On a card the account no longer holds this is the only behaviour row, and
745
+ // it says so rather than reading as the card's cost.
746
+ const declared = node.attrs && node.attrs._behaviourDeclared;
747
+ if (declared && declared.cost) {
748
+ const add = section("behaviour · declared");
749
+ add("cost", figureWithBadge(fmtCost(declared.cost.perHour, declared.cost.currency), [declared], behaviourMeta()));
750
+ if (!blockOf(node)) add("live", "no live figure — the prediction of the account does not include this entity");
751
+ }
730
752
 
731
753
  // Render diff (#146's deferred half): what `chant helm diff <digest> <env>
732
754
  // --live --json` said about this chart's PINNED render. Never rely on the
@@ -858,15 +880,22 @@ function inspect(node) {
858
880
  const attrKeys = Object.keys(node.attrs || {}).filter((k) => !k.startsWith("_") && !(k === "release" && release));
859
881
  if (attrKeys.length) {
860
882
  const decl = section("declared");
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.
883
+ // #393 item 10: a choudoufu card that can be adopted leads with the write
884
+ // that does it — the two marker tags choudoufu named (`adopt_tofu_estate` /
885
+ // `adopt_tofu_address`), or its own one-line command when it sends one.
886
+ // behold composes neither (src/choudoufu-live.ts); this puts the line
887
+ // first and makes it copyable, the way the moves panel already does for a
888
+ // `live-mv` line. The paragraph explaining the omission follows.
889
+ //
890
+ // #413: both kinds reach here now. An UNOWNED row carries the marker pair,
891
+ // and an adoptable one carries choudoufu's own command — so the tooltip
892
+ // says which of the two this is rather than claiming tags either way.
868
893
  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");
894
+ if (adoptable) {
895
+ const isCommand = /\s/.test(node.attrs.adopt.trim()) && !node.attrs.adopt.startsWith("tofu-estate=");
896
+ copyableRow(decl, adoptable, node.attrs.adopt,
897
+ isCommand ? "Copy the command that adopts this object" : "Copy the tags that adopt this object");
898
+ }
870
899
  for (const k of attrKeys) {
871
900
  if (k === adoptable) continue;
872
901
  decl(k, fmtValue(node.attrs[k]));
@@ -1210,12 +1239,29 @@ function wire(ir) {
1210
1239
  host.querySelectorAll(".sel").forEach((n) => n.classList.remove("sel"));
1211
1240
  g.classList.add("sel");
1212
1241
  inspect(node);
1242
+ noteSelection(node.id);
1213
1243
  // #254: in carve mode a click is also the walkthrough's Pick step — the
1214
1244
  // inspect pane already shows the score arithmetic the lens spelled out,
1215
1245
  // and the stepper picks up the same node.
1216
1246
  carvePick(node);
1217
1247
  });
1218
1248
  }
1249
+ // #475/#476: a member's box picks the member. Only boxes that are members
1250
+ // (a wave or a VPC box is not one), and only a click on the box itself, not
1251
+ // on a card inside it or after a drag-pan.
1252
+ const members = new Set(ir.nodes.map((n) => nodeMember(n.id)).filter(Boolean));
1253
+ for (const rect of host.querySelectorAll("rect[data-group-id]")) {
1254
+ const id = rect.getAttribute("data-group-id");
1255
+ if (!members.has(id)) continue;
1256
+ rect.addEventListener("click", (e) => {
1257
+ if (panMoved || e.target !== rect) return;
1258
+ host.querySelectorAll(".sel").forEach((n) => n.classList.remove("sel"));
1259
+ selectedNodeId = null;
1260
+ view.member = id;
1261
+ renderStatusbar();
1262
+ embedSelected(id, null);
1263
+ });
1264
+ }
1219
1265
  }
1220
1266
 
1221
1267
  // View state driven by the ⌘K palette's lens commands (#17, moved off the
@@ -1235,7 +1281,10 @@ function wire(ir) {
1235
1281
  // null on a project that declares no `stacks[]` at all — the picker (and the
1236
1282
  // status strip's stack tag) then never renders. Every fetch reads this, so
1237
1283
  // the `changed` SSE re-pull and a palette lens change go through the same path.
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 };
1284
+ // member (#475) is the estate member the view is on: a host opens behold with
1285
+ // ?member=, and picking a card or a member box moves it. It changes no fetch;
1286
+ // the graph is the whole estate, framed on that member's box.
1287
+ const view = { member: null, env: null, detail: 2, components: true, logical: false, runtime: false, ops: false, tier: null, target: null, stack: null, radial: false, collapse: false, compareTo: null };
1239
1288
 
1240
1289
  // #182: `components` is the boot default, but a project that declares no
1241
1290
  // components renders it as ZERO nodes — the first screen was a blank graph
@@ -1735,6 +1784,35 @@ function renderBehaviourTotals(host) {
1735
1784
  // precedence in one place rather than at each call site.
1736
1785
  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
1786
  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."));
1787
+ if (mode === "cost") renderBehaviourDelta(host, b);
1788
+ }
1789
+
1790
+ /**
1791
+ * Live versus declared (#402): what the account costs as it stands, what the
1792
+ * file says it should, and the difference. Present only when the overlay was
1793
+ * asked for at a traffic level, because both figures are predictions *at* one.
1794
+ * The wording of every row is `deltaRows`' (web/behaviour-scale.js).
1795
+ */
1796
+ function renderBehaviourDelta(host, b) {
1797
+ const rows = deltaRows(b);
1798
+ if (!rows.length) return;
1799
+ host.appendChild(panelHeading(`live versus declared${b.at ? ` · at ${b.at.traffic}` : ""}`));
1800
+ for (const t of rows) {
1801
+ const r = document.createElement("div");
1802
+ r.className = "behaviour-total";
1803
+ const name = document.createElement("div");
1804
+ name.className = "grow";
1805
+ name.style.fontWeight = "600";
1806
+ name.textContent = t.label;
1807
+ r.appendChild(name);
1808
+ for (const [word, text] of [["live", t.live], ["declared", t.declared], ["delta", t.delta]]) {
1809
+ const line = document.createElement("div");
1810
+ line.textContent = `${word} ${text}`;
1811
+ r.appendChild(line);
1812
+ }
1813
+ host.appendChild(r);
1814
+ }
1815
+ host.appendChild(panelMuted("delta is live minus declared. A negative one means the account holds less than the file declares."));
1738
1816
  }
1739
1817
 
1740
1818
  function renderPanelScope() {
@@ -2053,9 +2131,318 @@ function panelDotRow(color, main, tag, onClick) {
2053
2131
  return row;
2054
2132
  }
2055
2133
 
2134
+ // --- #475: the view in the URL -----------------------------------------------
2135
+ // The query is read once, at boot (openFromQuery), and written back with
2136
+ // history.replaceState from renderStatusbar(), which every view change already
2137
+ // runs through. member and node can only be checked once a graph is on screen,
2138
+ // so they wait in `pendingPlace` for the first paint (afterPaint). What the
2139
+ // link asked for and behold could not open is said on the now line.
2140
+ let pendingPlace = null;
2141
+ let urlRefusals = [];
2142
+ let selectedNodeId = null;
2143
+
2144
+ let viewUrlReady = false; // nothing is written back until the link has been read
2145
+ function openFromQuery() {
2146
+ viewUrlReady = true;
2147
+ applyWantedView(readViewQuery(location.search));
2148
+ }
2149
+
2150
+ /**
2151
+ * Move the view to what a link or the host asked for. Values the estate can't
2152
+ * honour are kept in `urlRefusals` for the now line; member and node wait in
2153
+ * `pendingPlace` for a graph to check them against. Returns whether the
2154
+ * fetch-shaping part of the view moved (a re-read is needed).
2155
+ */
2156
+ function applyWantedView(want) {
2157
+ if (!Object.keys(want).length) return false;
2158
+ const before = fetchKey();
2159
+ // env first: whether the runtime stop exists depends on it.
2160
+ const first = settleView("env" in want ? { env: want.env } : {}, { envs: environments });
2161
+ if ("env" in first.apply) view.env = first.apply.env;
2162
+ const { env: _env, ...rest } = want;
2163
+ const { apply, refused } = settleView(rest, {
2164
+ zooms: availableZooms().map(([, z]) => z),
2165
+ tiers,
2166
+ lenses: COLOUR_MODES,
2167
+ });
2168
+ urlRefusals.push(...first.refused, ...refused);
2169
+ if (apply.tier) view.tier = apply.tier;
2170
+ if (apply.zoom) {
2171
+ applyZoom(apply.zoom);
2172
+ autoZoomFallback = false; // the link chose the zoom; an empty one is said, not swapped
2173
+ }
2174
+ if (apply.lens) colourMode = apply.lens; // not written to localStorage: the link's pick, not yours
2175
+ if ("radial" in apply) view.radial = apply.radial;
2176
+ if (want.member === null) {
2177
+ view.member = null;
2178
+ selectedNodeId = null;
2179
+ }
2180
+ if (apply.member && !apply.node && selectedNodeId && nodeMember(selectedNodeId) !== apply.member) {
2181
+ // Another member, and no card named in it: the old pick goes.
2182
+ selectedNodeId = null;
2183
+ document.querySelectorAll("#graph .sel").forEach((n) => n.classList.remove("sel"));
2184
+ }
2185
+ if (apply.member || apply.node) pendingPlace = { member: apply.member || null, node: apply.node || null };
2186
+ return fetchKey() !== before;
2187
+ }
2188
+ function fetchKey() {
2189
+ return [view.env, zoomValue(), view.tier, view.radial].join("|");
2190
+ }
2191
+
2192
+ // #476: the host moves the view with a `behold:view` message carrying the same
2193
+ // fields as the URL. Only from the host's origin (viewFromMessage).
2194
+ window.addEventListener("message", (e) => {
2195
+ const want = viewFromMessage(e, embedOpts.host);
2196
+ if (!want) return;
2197
+ const lensBefore = colourMode;
2198
+ if (applyWantedView(want)) {
2199
+ resetDialCaches();
2200
+ renderStatusbar();
2201
+ load();
2202
+ return;
2203
+ }
2204
+ if (colourMode !== lensBefore) recolorNodesByCategory();
2205
+ if (pendingPlace && lastGraphIr) afterPaint(lastGraphIr);
2206
+ else if (want.member === null) fitGraph();
2207
+ const line = refusalLine(urlRefusals);
2208
+ urlRefusals = [];
2209
+ if (line) nowline(line);
2210
+ renderStatusbar();
2211
+ });
2212
+
2213
+ /** Tell the host what was picked: a card (member + node) or a member's box. */
2214
+ function embedSelected(member, node) {
2215
+ if (embedOpts.embed && member) setInspectCollapsed(!node);
2216
+ if (!embedOpts.host || window.parent === window) return;
2217
+ try {
2218
+ window.parent.postMessage(selectMessage(member, node), embedOpts.host);
2219
+ } catch {
2220
+ /* the parent went away */
2221
+ }
2222
+ }
2223
+
2224
+ // --- #477: a declared workspace's waiting gates --------------------------------
2225
+ // Read when the env changes and when the server says something moved (the
2226
+ // `changed` event, which a host's POST /api/refresh?notify=1 also sends).
2227
+ // Framed, the cards carry no button: the host approves (workspace-gates.js).
2228
+ let wsGates = { env: undefined, answer: null, seq: 0 };
2229
+ // ?gates=<env> (#477): the env the gate strip reads, apart from the graph's.
2230
+ // A host that opens behold on the source graph still wants the gates of the
2231
+ // env its block watches.
2232
+ const gatesEnvParam = new URLSearchParams(location.search).get("gates") || "";
2233
+ function gatesEnv() {
2234
+ return gatesEnvParam || view.env || "";
2235
+ }
2236
+ function loadWorkspaceGates({ force = false } = {}) {
2237
+ if (staticMode) return;
2238
+ const env = gatesEnv();
2239
+ if (!force && wsGates.env === env) return paintWorkspaceGates();
2240
+ // Every read is numbered; only the newest one paints, so an answer for an
2241
+ // env the view has left never lands under the one it moved to.
2242
+ const seq = ++wsGates.seq;
2243
+ wsGates.env = env;
2244
+ fetch(`/api/workspace/gates${env ? `?env=${encodeURIComponent(env)}` : ""}`)
2245
+ .then((r) => r.json())
2246
+ .then((j) => {
2247
+ if (seq === wsGates.seq) wsGates.answer = j;
2248
+ })
2249
+ .catch(() => {
2250
+ if (seq === wsGates.seq) wsGates.answer = null;
2251
+ })
2252
+ .finally(() => {
2253
+ if (seq === wsGates.seq) paintWorkspaceGates();
2254
+ });
2255
+ }
2256
+ function paintWorkspaceGates() {
2257
+ const host = document.getElementById("ws-gates");
2258
+ if (!host) return;
2259
+ const answer = wsGates.answer;
2260
+ host.replaceChildren();
2261
+ if (!answer || !answer.workspace) {
2262
+ host.hidden = true;
2263
+ return;
2264
+ }
2265
+ const { cards, elsewhere, refusal } = gateCards(answer, { embed: embedOpts.embed, member: embedOpts.embed ? view.member : null });
2266
+ for (const c of cards) {
2267
+ const box = document.createElement("div");
2268
+ box.className = "ws-gate";
2269
+ box.dataset.gate = c.key;
2270
+ const title = document.createElement("div");
2271
+ title.className = "ws-gate-title";
2272
+ title.textContent = c.title;
2273
+ const facts = document.createElement("div");
2274
+ facts.className = "ws-gate-facts";
2275
+ facts.textContent = c.facts;
2276
+ facts.title = c.command;
2277
+ box.append(title, facts);
2278
+ if (c.note) {
2279
+ const note = document.createElement("div");
2280
+ note.className = "ws-gate-note";
2281
+ note.textContent = c.note;
2282
+ box.appendChild(note);
2283
+ }
2284
+ if (c.button && !previewMode && !staticMode) {
2285
+ const b = button(c.button.label, "approve", () => approveWorkspaceGate(c.key, b));
2286
+ b.title = c.button.title;
2287
+ box.appendChild(b);
2288
+ }
2289
+ host.appendChild(box);
2290
+ }
2291
+ if (elsewhere) {
2292
+ const more = document.createElement("div");
2293
+ more.className = "ws-gate ws-gate-note";
2294
+ more.textContent = `${elsewhere} more gate${elsewhere === 1 ? "" : "s"} waiting in other members`;
2295
+ host.appendChild(more);
2296
+ }
2297
+ if (refusal) {
2298
+ const err = document.createElement("div");
2299
+ err.className = "ws-gate ws-gate-note";
2300
+ err.textContent = `gates unread: ${refusal.error}`;
2301
+ err.title = refusal.remedy || "";
2302
+ host.appendChild(err);
2303
+ }
2304
+ host.hidden = !host.children.length;
2305
+ }
2306
+ function approveWorkspaceGate(key, btn) {
2307
+ if (btn) btn.disabled = true;
2308
+ fetch("/api/workspace/gates/approve", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ key, env: wsGates.answer && wsGates.answer.env }) })
2309
+ .then((r) => r.json())
2310
+ .then((j) => {
2311
+ if (j.error) {
2312
+ showToast(`✗ approve ${key}: ${j.error}`, false);
2313
+ nowline("✗ " + j.error);
2314
+ } else {
2315
+ showToast(`✓ ${key} approved as ${j.approver}`, true);
2316
+ }
2317
+ loadWorkspaceGates({ force: true });
2318
+ })
2319
+ .catch((e) => showToast(`✗ approve ${key}: ${e}`, false))
2320
+ .finally(() => {
2321
+ if (btn) btn.disabled = false;
2322
+ });
2323
+ }
2324
+
2325
+ /** The member names a graph shows: its member boxes, and the first segment of
2326
+ * each composed node id. */
2327
+ function membersOf(ir) {
2328
+ const out = new Set();
2329
+ for (const n of (ir && ir.nodes) || []) {
2330
+ const m = nodeMember(n.id);
2331
+ if (m) out.add(m);
2332
+ }
2333
+ const svg = currentSvg();
2334
+ if (svg && out.size) for (const r of svg.querySelectorAll("rect[data-group-id]")) out.add(r.getAttribute("data-group-id"));
2335
+ return out;
2336
+ }
2337
+
2338
+ /** After every paint: place the view where the link (or the last pick) put it. */
2339
+ function afterPaint(ir) {
2340
+ const ids = new Set(((ir && ir.nodes) || []).map((n) => n.id));
2341
+ if (pendingPlace) {
2342
+ const place = settlePlace(pendingPlace, membersOf(ir), ids);
2343
+ pendingPlace = null;
2344
+ urlRefusals.push(...place.refused);
2345
+ if (place.apply.member) view.member = place.apply.member;
2346
+ if (place.apply.node) {
2347
+ selectNode(place.apply.node, { quiet: true });
2348
+ revealNode(place.apply.node);
2349
+ }
2350
+ renderStatusbar();
2351
+ } else if (selectedNodeId && !ids.has(selectedNodeId)) {
2352
+ // The pick isn't on this view any more; the URL stops naming it.
2353
+ selectedNodeId = null;
2354
+ syncViewUrl();
2355
+ }
2356
+ if (view.member && !selectedNodeId) revealMember(view.member);
2357
+ const line = refusalLine(urlRefusals);
2358
+ urlRefusals = [];
2359
+ if (line) nowline(line);
2360
+ }
2361
+
2362
+ /** A card was picked (graph click, panel row, palette, the link). */
2363
+ function noteSelection(id, { quiet = false } = {}) {
2364
+ selectedNodeId = id;
2365
+ const m = nodeMember(id);
2366
+ if (m) view.member = m;
2367
+ syncViewUrl();
2368
+ // A pick the link or the host made isn't a click: the host isn't told.
2369
+ if (!quiet) embedSelected(view.member, id);
2370
+ }
2371
+
2372
+ /** Frame one member's box, or the cards in it when the zoom draws no boxes. */
2373
+ function revealMember(member) {
2374
+ const svg = currentSvg();
2375
+ if (!svg || !vbInit) return false;
2376
+ let box = null;
2377
+ const rect = svg.querySelector(`rect[data-group-id="${CSS.escape(member)}"]`);
2378
+ if (rect) {
2379
+ box = boxInSvg(svg, rect);
2380
+ } else {
2381
+ for (const g of svg.querySelectorAll("[data-node-id]")) {
2382
+ if (nodeMember(g.getAttribute("data-node-id")) !== member) continue;
2383
+ const b = boxInSvg(svg, g);
2384
+ if (b) box = box ? union(box, b) : b;
2385
+ }
2386
+ }
2387
+ if (!box || !(box.width > 0) || !(box.height > 0)) return false;
2388
+ const pad = 0.06;
2389
+ const aspect = vbInit[3] / vbInit[2];
2390
+ let w = box.width * (1 + 2 * pad);
2391
+ let h = box.height * (1 + 2 * pad);
2392
+ if (h / w > aspect) w = h / aspect;
2393
+ else h = w * aspect;
2394
+ vb = [box.x + box.width / 2 - w / 2, box.y + box.height / 2 - h / 2, w, h];
2395
+ vbAtFit = false;
2396
+ applyVB();
2397
+ return true;
2398
+ }
2399
+ /** An element's box in the svg's user units, after every transform on the way
2400
+ * (a dragged box is a `translate` on its wrapper, #228). */
2401
+ function boxInSvg(svg, el) {
2402
+ try {
2403
+ const b = el.getBBox();
2404
+ const m = svg.getScreenCTM() && el.getScreenCTM() ? svg.getScreenCTM().inverse().multiply(el.getScreenCTM()) : null;
2405
+ if (!m) return { x: b.x, y: b.y, width: b.width, height: b.height };
2406
+ const pts = [[b.x, b.y], [b.x + b.width, b.y], [b.x, b.y + b.height], [b.x + b.width, b.y + b.height]].map(([x, y]) => new DOMPoint(x, y).matrixTransform(m));
2407
+ const xs = pts.map((p) => p.x);
2408
+ const ys = pts.map((p) => p.y);
2409
+ return { x: Math.min(...xs), y: Math.min(...ys), width: Math.max(...xs) - Math.min(...xs), height: Math.max(...ys) - Math.min(...ys) };
2410
+ } catch {
2411
+ return null;
2412
+ }
2413
+ }
2414
+ function union(a, b) {
2415
+ const x = Math.min(a.x, b.x);
2416
+ const y = Math.min(a.y, b.y);
2417
+ return { x, y, width: Math.max(a.x + a.width, b.x + b.width) - x, height: Math.max(a.y + a.height, b.y + b.height) - y };
2418
+ }
2419
+
2420
+ /** Write the view into the address bar, keeping every other parameter. */
2421
+ function syncViewUrl() {
2422
+ if (!viewUrlReady) return;
2423
+ const place = pendingPlace || {};
2424
+ const state = {
2425
+ member: place.member || view.member,
2426
+ zoom: zoomValue(),
2427
+ env: view.env,
2428
+ tier: view.tier,
2429
+ lens: colourMode,
2430
+ node: place.node || selectedNodeId,
2431
+ radial: view.radial && !view.components && !view.logical && !view.ops,
2432
+ };
2433
+ const defaults = { env: (projectInfo && projectInfo.currentEnv) || null, lens: savedColourMode };
2434
+ const next = location.pathname + writeViewQuery(location.search, state, defaults) + location.hash;
2435
+ if (next === location.pathname + location.search + location.hash) return;
2436
+ try {
2437
+ history.replaceState(history.state, "", next);
2438
+ } catch {
2439
+ /* a sandboxed frame may refuse; the view still works */
2440
+ }
2441
+ }
2442
+
2056
2443
  // Select a node from a panel row the same way a graph click would: highlight
2057
2444
  // its card (when it's in the current SVG) and open its inspect panel.
2058
- function selectNode(id) {
2445
+ function selectNode(id, { quiet = false } = {}) {
2059
2446
  const node = lastGraphIr && lastGraphIr.nodes.find((n) => n.id === id);
2060
2447
  if (!node) return;
2061
2448
  const host = document.getElementById("graph");
@@ -2063,6 +2450,7 @@ function selectNode(id) {
2063
2450
  const g = host.querySelector(`[data-node-id="${CSS.escape(id)}"]`);
2064
2451
  if (g) g.classList.add("sel");
2065
2452
  inspect(node);
2453
+ noteSelection(node.id, { quiet });
2066
2454
  carvePick(node);
2067
2455
  }
2068
2456
 
@@ -2658,6 +3046,7 @@ function renderStatusbar() {
2658
3046
  // Pure state — the strip echoes the axes whose controls live on the floating
2659
3047
  // panel and in ⌘K, so the current view stays legible with the panel collapsed.
2660
3048
  const parts = [`zoom: ${zoomValue()}`, view.env ? `env: ${view.env}` : "env: (source)"];
3049
+ if (view.member) parts.unshift(`member: ${view.member}`);
2661
3050
  // #399: which colour the graph is in is state, not a control, so it belongs
2662
3051
  // on the strip the same way the zoom and the env do. Only when it is not
2663
3052
  // drift — drift is what the strip has always implied.
@@ -2671,6 +3060,10 @@ function renderStatusbar() {
2671
3060
  const stillReading = pendingLine(pendingMembers, pendingTotal);
2672
3061
  if (stillReading) parts.push(stillReading);
2673
3062
  el.textContent = parts.join(" · ");
3063
+ syncViewUrl();
3064
+ // #477: gates don't wait on the graph (a member that fails to draw can still
3065
+ // have a gate waiting). Re-read only when the env moved; `changed` forces it.
3066
+ if (projectInfo) loadWorkspaceGates();
2674
3067
  // #131: why this level rendered empty, or as the one below it. The server
2675
3068
  // decides (src/zoom-notes.ts) — the SPA never infers it, so the note always
2676
3069
  // describes the graph that actually came back. Appended rather than mixed
@@ -3014,7 +3407,9 @@ function mountOperator(host) {
3014
3407
  box,
3015
3408
  operatorState,
3016
3409
  {
3017
- approve: (op, gate) => approveConvergeGate(op, gate),
3410
+ approve: embedOpts.embed || previewMode || staticMode ? null : (op, gate) => approveConvergeGate(op, gate),
3411
+ approver: projectInfo && projectInfo.approver,
3412
+ why: embedOpts.embed ? "host" : previewMode ? "preview" : "static",
3018
3413
  // A static export has no server to ask, so it gets no history affordance
3019
3414
  // at all rather than a button that can only fail.
3020
3415
  ...(staticMode ? {} : { history: () => toggleOperatorHistory() }),
@@ -3032,7 +3427,7 @@ function mountPlayhead(host) {
3032
3427
  if (!hasPlayhead(runState)) return;
3033
3428
  const box = document.createElement("div");
3034
3429
  box.className = "run-playhead";
3035
- renderPlayhead(box, runState, gateCard, { approve: (op, gate) => signal(op, gate) });
3430
+ renderPlayhead(box, runState, gateCard, { approve: embedOpts.embed || previewMode || staticMode ? null : (op, gate) => signal(op, gate), approver: projectInfo && projectInfo.approver, why: embedOpts.embed ? "host" : previewMode ? "preview" : "static" });
3036
3431
  host.appendChild(box);
3037
3432
  }
3038
3433
 
@@ -4628,6 +5023,7 @@ async function loadOnce(opts = {}, isCurrent = () => true) {
4628
5023
  pendingMembers = body.meta && body.meta.mode === "progressive" ? [...(body.meta.pending || [])] : [];
4629
5024
  pendingTotal = pendingMembers.length;
4630
5025
  render(body.ir, body.svg, body.meta);
5026
+ afterPaint(body.ir);
4631
5027
  } catch (err) {
4632
5028
  // A background settle poll must not blow away a good graph on a transient error.
4633
5029
  if (!opts.quiet && isCurrent()) {
@@ -4761,6 +5157,7 @@ async function initPickers() {
4761
5157
  environments = info.environments || [];
4762
5158
  tiers = info.tiers || [];
4763
5159
  targets = info.targets || [];
5160
+ openFromQuery();
4764
5161
  renderStatusbar();
4765
5162
  load();
4766
5163
  }
@@ -4803,6 +5200,7 @@ events.addEventListener("member", (e) => {
4803
5200
  events.addEventListener("changed", () => {
4804
5201
  bulkDiffCache = null; // an op ran → per-node live state may have changed
4805
5202
  load();
5203
+ loadWorkspaceGates({ force: true });
4806
5204
  loadSubstrates(); // a bring-up (or any op) finished → re-detect readiness
4807
5205
  });
4808
5206
 
@@ -5454,6 +5852,8 @@ async function initActions() {
5454
5852
  }
5455
5853
  }
5456
5854
  initActions();
5855
+ // #476: a frame keeps its own inspect state, folded until something is picked.
5856
+ const INSPECT_COLLAPSED_KEY = embedOpts.embed ? "behold.inspectCollapsed.embed" : "behold.inspectCollapsed";
5457
5857
  initInspectPane();
5458
5858
 
5459
5859
  // Inspect pane chrome (#15): collapse (chevron / edge tab) + drag-to-resize, with
@@ -5462,7 +5862,11 @@ initInspectPane();
5462
5862
  // so the ⌘K palette (#73) can drive the same collapse/reopen the chevron does.
5463
5863
  function setInspectCollapsed(on) {
5464
5864
  document.getElementById("app").classList.toggle("inspect-collapsed", on);
5465
- localStorage.setItem("behold.inspectCollapsed", on ? "1" : "0");
5865
+ try {
5866
+ localStorage.setItem(INSPECT_COLLAPSED_KEY, on ? "1" : "0");
5867
+ } catch {
5868
+ /* private mode */
5869
+ }
5466
5870
  }
5467
5871
  function toggleInspect() {
5468
5872
  setInspectCollapsed(!document.getElementById("app").classList.contains("inspect-collapsed"));
@@ -5475,7 +5879,13 @@ function initInspectPane() {
5475
5879
  // Restore persisted width + collapsed state.
5476
5880
  const savedW = Number(localStorage.getItem("behold.inspectW"));
5477
5881
  if (savedW >= MIN && savedW <= MAX) document.documentElement.style.setProperty("--inspect-w", savedW + "px");
5478
- if (localStorage.getItem("behold.inspectCollapsed") === "1") app.classList.add("inspect-collapsed");
5882
+ let folded = null;
5883
+ try {
5884
+ folded = localStorage.getItem(INSPECT_COLLAPSED_KEY);
5885
+ } catch {
5886
+ /* private mode */
5887
+ }
5888
+ if (folded === "1" || (folded === null && embedOpts.embed)) app.classList.add("inspect-collapsed");
5479
5889
 
5480
5890
  document.getElementById("inspect-collapse").addEventListener("click", () => setInspectCollapsed(true));
5481
5891
  document.getElementById("inspect-reopen").addEventListener("click", () => setInspectCollapsed(false));
@@ -5645,6 +6055,10 @@ function paletteCommands() {
5645
6055
  }
5646
6056
 
5647
6057
  // Env/stack/tier/target selection — replaces the old header pickers.
6058
+ if (view.member) c.push([`member: whole estate (leave ${view.member})`, () => { view.member = null; selectedNodeId = null; renderStatusbar(); fitGraph(); }]);
6059
+ for (const m of lastGraphIr ? membersOf(lastGraphIr) : []) {
6060
+ if (m !== view.member) c.push([`member: ${m}`, () => { view.member = m; selectedNodeId = null; renderStatusbar(); revealMember(m); }]);
6061
+ }
5648
6062
  c.push(["env: (source)" + (!view.env ? " ✓" : ""), () => { view.env = null; resetDialCaches(); load(); }]);
5649
6063
  for (const e of environments) {
5650
6064
  c.push([`env: ${e}` + (view.env === e ? " ✓" : ""), () => { view.env = e; resetDialCaches(); load(); }]);
@@ -5676,7 +6090,7 @@ function paletteCommands() {
5676
6090
  if (opsApply) {
5677
6091
  // #165: not for a designated env — its ApplyOp is refused server-side too.
5678
6092
  if (!designatedFor(view.env || opsInitialEnv)) c.push([`Deploy: Sync (${opsApply.name})`, () => runOp(opsApply.name)]);
5679
- if (opsApply.gate) c.push([`Approve ${opsApply.gate}`, () => signal(opsApply.name, opsApply.gate)]);
6093
+ if (opsApply.gate && !embedOpts.embed) c.push([`Approve ${opsApply.gate}`, () => signal(opsApply.name, opsApply.gate)]);
5680
6094
  }
5681
6095
  // #165: a designated env's applies happen on the forge, so a rollback PR
5682
6096
  // opened from here would have nothing local to land — hidden, as designed.
@@ -199,6 +199,38 @@ export function fmtCost(perHour, currency) {
199
199
  }
200
200
 
201
201
  /** A headroom fraction as the percentage of capacity still free. */
202
+ /** A signed delta, in the engine's own currency: the sign is the statement. */
203
+ export function fmtDelta(perHour, currency) {
204
+ const figure = fmtCost(Math.abs(perHour), currency);
205
+ if (perHour === 0) return `${figure}, no difference`;
206
+ return `${perHour > 0 ? "+" : "-"}${figure}`;
207
+ }
208
+
209
+ /**
210
+ * Live versus declared (#402), as the rows the Scope panel prints: one triple
211
+ * for the estate, one per member that has a side. Empty when the payload
212
+ * carries neither figure, which is a request that named no traffic level.
213
+ *
214
+ * Each figure says whose it is. `total` is the engine's own statement and
215
+ * `sum` is behold adding the engine's per-entity figures; the delta is always
216
+ * behold's subtraction of the two beside it, and a missing side is said, not
217
+ * left blank, so a reader never takes an absent live figure for a zero.
218
+ */
219
+ export function deltaRows(meta) {
220
+ if (!meta || (!meta.live && !meta.declared)) return [];
221
+ const whose = (f) => (f.basis === "total" ? "engine total" : "behold's sum");
222
+ const side = (f, missing) => (f ? `${fmtCost(f.perHour, f.currency)} · ${whose(f)}` : missing);
223
+ const triple = (label, t) => ({
224
+ label,
225
+ live: side(t.live, "no live figure"),
226
+ declared: side(t.declared, "no declared figure"),
227
+ delta: t.delta ? fmtDelta(t.delta.perHour, t.delta.currency) : "no delta, see diagnostics",
228
+ });
229
+ const rows = [triple("estate", meta)];
230
+ for (const [name, t] of Object.entries(meta.members || {}).sort(([a], [b]) => a.localeCompare(b))) rows.push(triple(name, t));
231
+ return rows;
232
+ }
233
+
202
234
  export function fmtHeadroom(v) {
203
235
  return typeof v === "number" && Number.isFinite(v) ? `${Math.round(v * 100)}% free` : "—";
204
236
  }