@intentius/behold 0.20.0 → 0.22.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/behold",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "description": "behold — a live control plane on chant. See your whole estate (every substrate in one graph), coloured by drift; act through delegated, gated Ops.",
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
@@ -86,7 +89,13 @@ import { hasOperator, renderOperator, APPROVED_SEMANTICS } from "./operator.js";
86
89
  import { releaseRows } from "./release.js";
87
90
  import { comparisonRow, comparisonSummary } from "./cross-env.js";
88
91
  initTheme();
89
- 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 });
90
99
  mountThemePicker(document.getElementById("panel-theme"));
91
100
 
92
101
  // Colour node fills by category/kind using the theme's FULL palette (spicypath-style, so the
@@ -116,6 +125,8 @@ try {
116
125
  } catch {
117
126
  /* private mode */
118
127
  }
128
+ // #475: what a load with no `lens=` would paint, so the URL carries any other.
129
+ let savedColourMode = colourMode;
119
130
 
120
131
  const COLOUR_MODE_TITLE = {
121
132
  drift: "Colour each card by what chant observed live — the overlay behold has always drawn",
@@ -140,6 +151,7 @@ function setColourMode(mode) {
140
151
  colourMode = mode;
141
152
  try {
142
153
  localStorage.setItem(COLOUR_STORE_KEY, mode);
154
+ savedColourMode = mode;
143
155
  } catch {
144
156
  /* private mode */
145
157
  }
@@ -1227,12 +1239,29 @@ function wire(ir) {
1227
1239
  host.querySelectorAll(".sel").forEach((n) => n.classList.remove("sel"));
1228
1240
  g.classList.add("sel");
1229
1241
  inspect(node);
1242
+ noteSelection(node.id);
1230
1243
  // #254: in carve mode a click is also the walkthrough's Pick step — the
1231
1244
  // inspect pane already shows the score arithmetic the lens spelled out,
1232
1245
  // and the stepper picks up the same node.
1233
1246
  carvePick(node);
1234
1247
  });
1235
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
+ }
1236
1265
  }
1237
1266
 
1238
1267
  // View state driven by the ⌘K palette's lens commands (#17, moved off the
@@ -1252,7 +1281,10 @@ function wire(ir) {
1252
1281
  // null on a project that declares no `stacks[]` at all — the picker (and the
1253
1282
  // status strip's stack tag) then never renders. Every fetch reads this, so
1254
1283
  // the `changed` SSE re-pull and a palette lens change go through the same path.
1255
- 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, gates: null, detail: 2, components: true, logical: false, runtime: false, ops: false, tier: null, target: null, stack: null, radial: false, collapse: false, compareTo: null };
1256
1288
 
1257
1289
  // #182: `components` is the boot default, but a project that declares no
1258
1290
  // components renders it as ZERO nodes — the first screen was a blank graph
@@ -2099,9 +2131,325 @@ function panelDotRow(color, main, tag, onClick) {
2099
2131
  return row;
2100
2132
  }
2101
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 ("gates" in apply) view.gates = apply.gates;
2177
+ if (want.member === null) {
2178
+ view.member = null;
2179
+ selectedNodeId = null;
2180
+ }
2181
+ if (apply.member && !apply.node && selectedNodeId && nodeMember(selectedNodeId) !== apply.member) {
2182
+ // Another member, and no card named in it: the old pick goes.
2183
+ selectedNodeId = null;
2184
+ document.querySelectorAll("#graph .sel").forEach((n) => n.classList.remove("sel"));
2185
+ }
2186
+ if (apply.member || apply.node) pendingPlace = { member: apply.member || null, node: apply.node || null };
2187
+ return fetchKey() !== before;
2188
+ }
2189
+ function fetchKey() {
2190
+ return [view.env, zoomValue(), view.tier, view.radial].join("|");
2191
+ }
2192
+
2193
+ // #476: the host moves the view with a `behold:view` message carrying the same
2194
+ // fields as the URL. Only from the host's origin (viewFromMessage).
2195
+ window.addEventListener("message", (e) => {
2196
+ const want = viewFromMessage(e, embedOpts.host);
2197
+ if (!want) return;
2198
+ const lensBefore = colourMode;
2199
+ const gatesBefore = view.gates;
2200
+ const moved = applyWantedView(want);
2201
+ // #477: the host's env for the gate strip moved. Read it now, not on the
2202
+ // next `changed`; renderStatusbar below writes it into the URL.
2203
+ if (view.gates !== gatesBefore) loadWorkspaceGates({ force: true });
2204
+ if (moved) {
2205
+ resetDialCaches();
2206
+ renderStatusbar();
2207
+ load();
2208
+ return;
2209
+ }
2210
+ if (colourMode !== lensBefore) recolorNodesByCategory();
2211
+ if (pendingPlace && lastGraphIr) afterPaint(lastGraphIr);
2212
+ else if (want.member === null) fitGraph();
2213
+ const line = refusalLine(urlRefusals);
2214
+ urlRefusals = [];
2215
+ if (line) nowline(line);
2216
+ renderStatusbar();
2217
+ });
2218
+
2219
+ /** Tell the host what was picked: a card (member + node) or a member's box. */
2220
+ function embedSelected(member, node) {
2221
+ if (embedOpts.embed && member) setInspectCollapsed(!node);
2222
+ if (!embedOpts.host || window.parent === window) return;
2223
+ try {
2224
+ window.parent.postMessage(selectMessage(member, node), embedOpts.host);
2225
+ } catch {
2226
+ /* the parent went away */
2227
+ }
2228
+ }
2229
+
2230
+ // --- #477: a declared workspace's waiting gates --------------------------------
2231
+ // Read when the env changes and when the server says something moved (the
2232
+ // `changed` event, which a host's POST /api/refresh?notify=1 also sends).
2233
+ // Framed, the cards carry no button: the host approves (workspace-gates.js).
2234
+ let wsGates = { env: undefined, answer: null, seq: 0 };
2235
+ // ?gates=<env> (#477): the env the gate strip reads, apart from the graph's.
2236
+ // A host that opens behold on the source graph still wants the gates of the
2237
+ // env its block watches. It is a view key (view-url.js), so the link sets it,
2238
+ // a host's `behold:view` moves it, and the URL keeps it.
2239
+ function gatesEnv() {
2240
+ return view.gates || view.env || "";
2241
+ }
2242
+ function loadWorkspaceGates({ force = false } = {}) {
2243
+ if (staticMode) return;
2244
+ const env = gatesEnv();
2245
+ if (!force && wsGates.env === env) return paintWorkspaceGates();
2246
+ // Every read is numbered; only the newest one paints, so an answer for an
2247
+ // env the view has left never lands under the one it moved to.
2248
+ const seq = ++wsGates.seq;
2249
+ wsGates.env = env;
2250
+ fetch(`/api/workspace/gates${env ? `?env=${encodeURIComponent(env)}` : ""}`)
2251
+ .then((r) => r.json())
2252
+ .then((j) => {
2253
+ if (seq === wsGates.seq) wsGates.answer = j;
2254
+ })
2255
+ .catch(() => {
2256
+ if (seq === wsGates.seq) wsGates.answer = null;
2257
+ })
2258
+ .finally(() => {
2259
+ if (seq === wsGates.seq) paintWorkspaceGates();
2260
+ });
2261
+ }
2262
+ function paintWorkspaceGates() {
2263
+ const host = document.getElementById("ws-gates");
2264
+ if (!host) return;
2265
+ const answer = wsGates.answer;
2266
+ host.replaceChildren();
2267
+ if (!answer || !answer.workspace) {
2268
+ host.hidden = true;
2269
+ return;
2270
+ }
2271
+ const { cards, elsewhere, refusal } = gateCards(answer, { embed: embedOpts.embed, member: embedOpts.embed ? view.member : null });
2272
+ for (const c of cards) {
2273
+ const box = document.createElement("div");
2274
+ box.className = "ws-gate";
2275
+ box.dataset.gate = c.key;
2276
+ const title = document.createElement("div");
2277
+ title.className = "ws-gate-title";
2278
+ title.textContent = c.title;
2279
+ const facts = document.createElement("div");
2280
+ facts.className = "ws-gate-facts";
2281
+ facts.textContent = c.facts;
2282
+ facts.title = c.command;
2283
+ box.append(title, facts);
2284
+ if (c.note) {
2285
+ const note = document.createElement("div");
2286
+ note.className = "ws-gate-note";
2287
+ note.textContent = c.note;
2288
+ box.appendChild(note);
2289
+ }
2290
+ if (c.button && !previewMode && !staticMode) {
2291
+ const b = button(c.button.label, "approve", () => approveWorkspaceGate(c.key, b));
2292
+ b.title = c.button.title;
2293
+ box.appendChild(b);
2294
+ }
2295
+ host.appendChild(box);
2296
+ }
2297
+ if (elsewhere) {
2298
+ const more = document.createElement("div");
2299
+ more.className = "ws-gate ws-gate-note";
2300
+ more.textContent = `${elsewhere} more gate${elsewhere === 1 ? "" : "s"} waiting in other members`;
2301
+ host.appendChild(more);
2302
+ }
2303
+ if (refusal) {
2304
+ const err = document.createElement("div");
2305
+ err.className = "ws-gate ws-gate-note";
2306
+ err.textContent = `gates unread: ${refusal.error}`;
2307
+ err.title = refusal.remedy || "";
2308
+ host.appendChild(err);
2309
+ }
2310
+ host.hidden = !host.children.length;
2311
+ }
2312
+ function approveWorkspaceGate(key, btn) {
2313
+ if (btn) btn.disabled = true;
2314
+ fetch("/api/workspace/gates/approve", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ key, env: wsGates.answer && wsGates.answer.env }) })
2315
+ .then((r) => r.json())
2316
+ .then((j) => {
2317
+ if (j.error) {
2318
+ showToast(`✗ approve ${key}: ${j.error}`, false);
2319
+ nowline("✗ " + j.error);
2320
+ } else {
2321
+ showToast(`✓ ${key} approved as ${j.approver}`, true);
2322
+ }
2323
+ loadWorkspaceGates({ force: true });
2324
+ })
2325
+ .catch((e) => showToast(`✗ approve ${key}: ${e}`, false))
2326
+ .finally(() => {
2327
+ if (btn) btn.disabled = false;
2328
+ });
2329
+ }
2330
+
2331
+ /** The member names a graph shows: its member boxes, and the first segment of
2332
+ * each composed node id. */
2333
+ function membersOf(ir) {
2334
+ const out = new Set();
2335
+ for (const n of (ir && ir.nodes) || []) {
2336
+ const m = nodeMember(n.id);
2337
+ if (m) out.add(m);
2338
+ }
2339
+ const svg = currentSvg();
2340
+ if (svg && out.size) for (const r of svg.querySelectorAll("rect[data-group-id]")) out.add(r.getAttribute("data-group-id"));
2341
+ return out;
2342
+ }
2343
+
2344
+ /** After every paint: place the view where the link (or the last pick) put it. */
2345
+ function afterPaint(ir) {
2346
+ const ids = new Set(((ir && ir.nodes) || []).map((n) => n.id));
2347
+ if (pendingPlace) {
2348
+ const place = settlePlace(pendingPlace, membersOf(ir), ids);
2349
+ pendingPlace = null;
2350
+ urlRefusals.push(...place.refused);
2351
+ if (place.apply.member) view.member = place.apply.member;
2352
+ if (place.apply.node) {
2353
+ selectNode(place.apply.node, { quiet: true });
2354
+ revealNode(place.apply.node);
2355
+ }
2356
+ renderStatusbar();
2357
+ } else if (selectedNodeId && !ids.has(selectedNodeId)) {
2358
+ // The pick isn't on this view any more; the URL stops naming it.
2359
+ selectedNodeId = null;
2360
+ syncViewUrl();
2361
+ }
2362
+ if (view.member && !selectedNodeId) revealMember(view.member);
2363
+ const line = refusalLine(urlRefusals);
2364
+ urlRefusals = [];
2365
+ if (line) nowline(line);
2366
+ }
2367
+
2368
+ /** A card was picked (graph click, panel row, palette, the link). */
2369
+ function noteSelection(id, { quiet = false } = {}) {
2370
+ selectedNodeId = id;
2371
+ const m = nodeMember(id);
2372
+ if (m) view.member = m;
2373
+ syncViewUrl();
2374
+ // A pick the link or the host made isn't a click: the host isn't told.
2375
+ if (!quiet) embedSelected(view.member, id);
2376
+ }
2377
+
2378
+ /** Frame one member's box, or the cards in it when the zoom draws no boxes. */
2379
+ function revealMember(member) {
2380
+ const svg = currentSvg();
2381
+ if (!svg || !vbInit) return false;
2382
+ let box = null;
2383
+ const rect = svg.querySelector(`rect[data-group-id="${CSS.escape(member)}"]`);
2384
+ if (rect) {
2385
+ box = boxInSvg(svg, rect);
2386
+ } else {
2387
+ for (const g of svg.querySelectorAll("[data-node-id]")) {
2388
+ if (nodeMember(g.getAttribute("data-node-id")) !== member) continue;
2389
+ const b = boxInSvg(svg, g);
2390
+ if (b) box = box ? union(box, b) : b;
2391
+ }
2392
+ }
2393
+ if (!box || !(box.width > 0) || !(box.height > 0)) return false;
2394
+ const pad = 0.06;
2395
+ const aspect = vbInit[3] / vbInit[2];
2396
+ let w = box.width * (1 + 2 * pad);
2397
+ let h = box.height * (1 + 2 * pad);
2398
+ if (h / w > aspect) w = h / aspect;
2399
+ else h = w * aspect;
2400
+ vb = [box.x + box.width / 2 - w / 2, box.y + box.height / 2 - h / 2, w, h];
2401
+ vbAtFit = false;
2402
+ applyVB();
2403
+ return true;
2404
+ }
2405
+ /** An element's box in the svg's user units, after every transform on the way
2406
+ * (a dragged box is a `translate` on its wrapper, #228). */
2407
+ function boxInSvg(svg, el) {
2408
+ try {
2409
+ const b = el.getBBox();
2410
+ const m = svg.getScreenCTM() && el.getScreenCTM() ? svg.getScreenCTM().inverse().multiply(el.getScreenCTM()) : null;
2411
+ if (!m) return { x: b.x, y: b.y, width: b.width, height: b.height };
2412
+ 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));
2413
+ const xs = pts.map((p) => p.x);
2414
+ const ys = pts.map((p) => p.y);
2415
+ return { x: Math.min(...xs), y: Math.min(...ys), width: Math.max(...xs) - Math.min(...xs), height: Math.max(...ys) - Math.min(...ys) };
2416
+ } catch {
2417
+ return null;
2418
+ }
2419
+ }
2420
+ function union(a, b) {
2421
+ const x = Math.min(a.x, b.x);
2422
+ const y = Math.min(a.y, b.y);
2423
+ 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 };
2424
+ }
2425
+
2426
+ /** Write the view into the address bar, keeping every other parameter. */
2427
+ function syncViewUrl() {
2428
+ if (!viewUrlReady) return;
2429
+ const place = pendingPlace || {};
2430
+ const state = {
2431
+ member: place.member || view.member,
2432
+ zoom: zoomValue(),
2433
+ env: view.env,
2434
+ tier: view.tier,
2435
+ lens: colourMode,
2436
+ node: place.node || selectedNodeId,
2437
+ radial: view.radial && !view.components && !view.logical && !view.ops,
2438
+ gates: view.gates,
2439
+ };
2440
+ const defaults = { env: (projectInfo && projectInfo.currentEnv) || null, lens: savedColourMode };
2441
+ const next = location.pathname + writeViewQuery(location.search, state, defaults) + location.hash;
2442
+ if (next === location.pathname + location.search + location.hash) return;
2443
+ try {
2444
+ history.replaceState(history.state, "", next);
2445
+ } catch {
2446
+ /* a sandboxed frame may refuse; the view still works */
2447
+ }
2448
+ }
2449
+
2102
2450
  // Select a node from a panel row the same way a graph click would: highlight
2103
2451
  // its card (when it's in the current SVG) and open its inspect panel.
2104
- function selectNode(id) {
2452
+ function selectNode(id, { quiet = false } = {}) {
2105
2453
  const node = lastGraphIr && lastGraphIr.nodes.find((n) => n.id === id);
2106
2454
  if (!node) return;
2107
2455
  const host = document.getElementById("graph");
@@ -2109,6 +2457,7 @@ function selectNode(id) {
2109
2457
  const g = host.querySelector(`[data-node-id="${CSS.escape(id)}"]`);
2110
2458
  if (g) g.classList.add("sel");
2111
2459
  inspect(node);
2460
+ noteSelection(node.id, { quiet });
2112
2461
  carvePick(node);
2113
2462
  }
2114
2463
 
@@ -2704,6 +3053,7 @@ function renderStatusbar() {
2704
3053
  // Pure state — the strip echoes the axes whose controls live on the floating
2705
3054
  // panel and in ⌘K, so the current view stays legible with the panel collapsed.
2706
3055
  const parts = [`zoom: ${zoomValue()}`, view.env ? `env: ${view.env}` : "env: (source)"];
3056
+ if (view.member) parts.unshift(`member: ${view.member}`);
2707
3057
  // #399: which colour the graph is in is state, not a control, so it belongs
2708
3058
  // on the strip the same way the zoom and the env do. Only when it is not
2709
3059
  // drift — drift is what the strip has always implied.
@@ -2717,6 +3067,10 @@ function renderStatusbar() {
2717
3067
  const stillReading = pendingLine(pendingMembers, pendingTotal);
2718
3068
  if (stillReading) parts.push(stillReading);
2719
3069
  el.textContent = parts.join(" · ");
3070
+ syncViewUrl();
3071
+ // #477: gates don't wait on the graph (a member that fails to draw can still
3072
+ // have a gate waiting). Re-read only when the env moved; `changed` forces it.
3073
+ if (projectInfo) loadWorkspaceGates();
2720
3074
  // #131: why this level rendered empty, or as the one below it. The server
2721
3075
  // decides (src/zoom-notes.ts) — the SPA never infers it, so the note always
2722
3076
  // describes the graph that actually came back. Appended rather than mixed
@@ -3060,7 +3414,9 @@ function mountOperator(host) {
3060
3414
  box,
3061
3415
  operatorState,
3062
3416
  {
3063
- approve: (op, gate) => approveConvergeGate(op, gate),
3417
+ approve: embedOpts.embed || previewMode || staticMode ? null : (op, gate) => approveConvergeGate(op, gate),
3418
+ approver: projectInfo && projectInfo.approver,
3419
+ why: embedOpts.embed ? "host" : previewMode ? "preview" : "static",
3064
3420
  // A static export has no server to ask, so it gets no history affordance
3065
3421
  // at all rather than a button that can only fail.
3066
3422
  ...(staticMode ? {} : { history: () => toggleOperatorHistory() }),
@@ -3078,7 +3434,7 @@ function mountPlayhead(host) {
3078
3434
  if (!hasPlayhead(runState)) return;
3079
3435
  const box = document.createElement("div");
3080
3436
  box.className = "run-playhead";
3081
- renderPlayhead(box, runState, gateCard, { approve: (op, gate) => signal(op, gate) });
3437
+ 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" });
3082
3438
  host.appendChild(box);
3083
3439
  }
3084
3440
 
@@ -4674,6 +5030,7 @@ async function loadOnce(opts = {}, isCurrent = () => true) {
4674
5030
  pendingMembers = body.meta && body.meta.mode === "progressive" ? [...(body.meta.pending || [])] : [];
4675
5031
  pendingTotal = pendingMembers.length;
4676
5032
  render(body.ir, body.svg, body.meta);
5033
+ afterPaint(body.ir);
4677
5034
  } catch (err) {
4678
5035
  // A background settle poll must not blow away a good graph on a transient error.
4679
5036
  if (!opts.quiet && isCurrent()) {
@@ -4807,6 +5164,7 @@ async function initPickers() {
4807
5164
  environments = info.environments || [];
4808
5165
  tiers = info.tiers || [];
4809
5166
  targets = info.targets || [];
5167
+ openFromQuery();
4810
5168
  renderStatusbar();
4811
5169
  load();
4812
5170
  }
@@ -4849,6 +5207,7 @@ events.addEventListener("member", (e) => {
4849
5207
  events.addEventListener("changed", () => {
4850
5208
  bulkDiffCache = null; // an op ran → per-node live state may have changed
4851
5209
  load();
5210
+ loadWorkspaceGates({ force: true });
4852
5211
  loadSubstrates(); // a bring-up (or any op) finished → re-detect readiness
4853
5212
  });
4854
5213
 
@@ -5500,6 +5859,8 @@ async function initActions() {
5500
5859
  }
5501
5860
  }
5502
5861
  initActions();
5862
+ // #476: a frame keeps its own inspect state, folded until something is picked.
5863
+ const INSPECT_COLLAPSED_KEY = embedOpts.embed ? "behold.inspectCollapsed.embed" : "behold.inspectCollapsed";
5503
5864
  initInspectPane();
5504
5865
 
5505
5866
  // Inspect pane chrome (#15): collapse (chevron / edge tab) + drag-to-resize, with
@@ -5508,7 +5869,11 @@ initInspectPane();
5508
5869
  // so the ⌘K palette (#73) can drive the same collapse/reopen the chevron does.
5509
5870
  function setInspectCollapsed(on) {
5510
5871
  document.getElementById("app").classList.toggle("inspect-collapsed", on);
5511
- localStorage.setItem("behold.inspectCollapsed", on ? "1" : "0");
5872
+ try {
5873
+ localStorage.setItem(INSPECT_COLLAPSED_KEY, on ? "1" : "0");
5874
+ } catch {
5875
+ /* private mode */
5876
+ }
5512
5877
  }
5513
5878
  function toggleInspect() {
5514
5879
  setInspectCollapsed(!document.getElementById("app").classList.contains("inspect-collapsed"));
@@ -5521,7 +5886,13 @@ function initInspectPane() {
5521
5886
  // Restore persisted width + collapsed state.
5522
5887
  const savedW = Number(localStorage.getItem("behold.inspectW"));
5523
5888
  if (savedW >= MIN && savedW <= MAX) document.documentElement.style.setProperty("--inspect-w", savedW + "px");
5524
- if (localStorage.getItem("behold.inspectCollapsed") === "1") app.classList.add("inspect-collapsed");
5889
+ let folded = null;
5890
+ try {
5891
+ folded = localStorage.getItem(INSPECT_COLLAPSED_KEY);
5892
+ } catch {
5893
+ /* private mode */
5894
+ }
5895
+ if (folded === "1" || (folded === null && embedOpts.embed)) app.classList.add("inspect-collapsed");
5525
5896
 
5526
5897
  document.getElementById("inspect-collapse").addEventListener("click", () => setInspectCollapsed(true));
5527
5898
  document.getElementById("inspect-reopen").addEventListener("click", () => setInspectCollapsed(false));
@@ -5691,6 +6062,10 @@ function paletteCommands() {
5691
6062
  }
5692
6063
 
5693
6064
  // Env/stack/tier/target selection — replaces the old header pickers.
6065
+ if (view.member) c.push([`member: whole estate (leave ${view.member})`, () => { view.member = null; selectedNodeId = null; renderStatusbar(); fitGraph(); }]);
6066
+ for (const m of lastGraphIr ? membersOf(lastGraphIr) : []) {
6067
+ if (m !== view.member) c.push([`member: ${m}`, () => { view.member = m; selectedNodeId = null; renderStatusbar(); revealMember(m); }]);
6068
+ }
5694
6069
  c.push(["env: (source)" + (!view.env ? " ✓" : ""), () => { view.env = null; resetDialCaches(); load(); }]);
5695
6070
  for (const e of environments) {
5696
6071
  c.push([`env: ${e}` + (view.env === e ? " ✓" : ""), () => { view.env = e; resetDialCaches(); load(); }]);
@@ -5722,7 +6097,7 @@ function paletteCommands() {
5722
6097
  if (opsApply) {
5723
6098
  // #165: not for a designated env — its ApplyOp is refused server-side too.
5724
6099
  if (!designatedFor(view.env || opsInitialEnv)) c.push([`Deploy: Sync (${opsApply.name})`, () => runOp(opsApply.name)]);
5725
- if (opsApply.gate) c.push([`Approve ${opsApply.gate}`, () => signal(opsApply.name, opsApply.gate)]);
6100
+ if (opsApply.gate && !embedOpts.embed) c.push([`Approve ${opsApply.gate}`, () => signal(opsApply.name, opsApply.gate)]);
5726
6101
  }
5727
6102
  // #165: a designated env's applies happen on the forge, so a rollback PR
5728
6103
  // opened from here would have nothing local to land — hidden, as designed.
package/web/embed.js ADDED
@@ -0,0 +1,78 @@
1
+ // #476: behold framed in a host pane (arugula's workspace block). Pure logic,
2
+ // no DOM: app.js reads the embed parameters once, posts `behold:select` to the
3
+ // parent when a member or a card is picked, and takes `behold:view` from the
4
+ // parent to move the view.
5
+ //
6
+ // ?embed=1 compact chrome: no Deploy tab, no theme picker, the
7
+ // panel and the inspect pane start folded
8
+ // ?theme=<name> a theme for this frame only (light, dark, or any theme
9
+ // name the picker lists); never saved
10
+ // ?host=<origin> the parent's origin. Messages go only there, and only
11
+ // messages from there are taken. No host, no messages.
12
+ //
13
+ // Gates in a frame are the host's to approve (#477): behold draws them and
14
+ // says where to approve.
15
+
16
+ import { VIEW_KEYS } from "./view-url.js";
17
+
18
+ /** The light/dark words a host can pass, mapped to a theme the picker has. */
19
+ export const THEME_ALIASES = { light: "GitHub Light Default", dark: "GitHub Dark Default" };
20
+
21
+ /** The embed parameters of a query. `host` is an origin, or null when it is
22
+ * missing or not one (a path, a bare word): a target origin has to be exact. */
23
+ export function readEmbed(search) {
24
+ const q = new URLSearchParams(search || "");
25
+ const embed = /^(1|true|on|yes)$/i.test(q.get("embed") || "");
26
+ let host = null;
27
+ const raw = (q.get("host") || "").trim();
28
+ if (raw) {
29
+ try {
30
+ const u = new URL(raw);
31
+ if (u.protocol === "http:" || u.protocol === "https:") host = u.origin;
32
+ } catch {
33
+ host = null;
34
+ }
35
+ }
36
+ const t = (q.get("theme") || "").trim();
37
+ const theme = t ? THEME_ALIASES[t.toLowerCase()] || t : null;
38
+ return { embed, host, theme };
39
+ }
40
+
41
+ /** The message a pick sends the parent. `node` is null when a member's box was
42
+ * picked rather than a card. */
43
+ export function selectMessage(member, node) {
44
+ return { type: "behold:select", member: member || null, node: node || null };
45
+ }
46
+
47
+ /**
48
+ * A `behold:view` message from the parent, reduced to the view keys it
49
+ * carries, or null when it isn't one or doesn't come from the host. The values
50
+ * go through the same checks as the URL's (view-url.js settleView).
51
+ */
52
+ export function viewFromMessage(event, host) {
53
+ if (!host || !event || event.origin !== host) return null;
54
+ const d = event.data;
55
+ if (!d || typeof d !== "object" || d.type !== "behold:view") return null;
56
+ const want = {};
57
+ for (const k of VIEW_KEYS) {
58
+ if (!(k in d)) continue;
59
+ const v = d[k];
60
+ if (k === "radial") {
61
+ if (typeof v === "boolean") want.radial = v;
62
+ continue;
63
+ }
64
+ if (k === "env" || k === "gates") {
65
+ // env: null or "" is the source graph. gates: null or "" hands the
66
+ // gate strip back to the graph's env.
67
+ if (v === null || v === "") want[k] = null;
68
+ else if (typeof v === "string") want[k] = v;
69
+ continue;
70
+ }
71
+ if (k === "member" && v === null) {
72
+ want.member = null; // the whole estate
73
+ continue;
74
+ }
75
+ if (typeof v === "string" && v) want[k] = v;
76
+ }
77
+ return want;
78
+ }
@@ -0,0 +1,51 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { readEmbed, selectMessage, viewFromMessage, THEME_ALIASES } from "./embed.js";
3
+
4
+ describe("readEmbed (#476)", () => {
5
+ it("reads embed, an exact host origin and a theme", () => {
6
+ expect(readEmbed("?embed=1&host=http%3A%2F%2Flocalhost%3A7777%2Fsome%2Fpath&theme=dark")).toEqual({
7
+ embed: true,
8
+ host: "http://localhost:7777",
9
+ theme: THEME_ALIASES.dark,
10
+ });
11
+ });
12
+ it("takes no host that isn't an http(s) origin", () => {
13
+ expect(readEmbed("?host=localhost").host).toBe(null);
14
+ expect(readEmbed("?host=javascript%3Aalert(1)").host).toBe(null);
15
+ });
16
+ it("passes a theme name through", () => {
17
+ expect(readEmbed("?theme=Atom%20One%20Light").theme).toBe("Atom One Light");
18
+ });
19
+ it("is off by default", () => {
20
+ expect(readEmbed("")).toEqual({ embed: false, host: null, theme: null });
21
+ });
22
+ });
23
+
24
+ describe("selectMessage (#476)", () => {
25
+ it("names the member and the card", () => {
26
+ expect(selectMessage("delivery", "delivery/appService")).toEqual({ type: "behold:select", member: "delivery", node: "delivery/appService" });
27
+ expect(selectMessage("delivery", null)).toEqual({ type: "behold:select", member: "delivery", node: null });
28
+ });
29
+ });
30
+
31
+ describe("viewFromMessage (#476)", () => {
32
+ const host = "http://localhost:7777";
33
+ it("takes the view keys from the host", () => {
34
+ const e = { origin: host, data: { type: "behold:view", member: "delivery", env: "prod", radial: true, embed: "ignored", zoom: 3 } };
35
+ expect(viewFromMessage(e, host)).toEqual({ member: "delivery", env: "prod", radial: true });
36
+ });
37
+ it("reads member null as the whole estate and env empty as the source", () => {
38
+ expect(viewFromMessage({ origin: host, data: { type: "behold:view", member: null, env: "" } }, host)).toEqual({ member: null, env: null });
39
+ });
40
+ it("takes gates, the gate strip's env, and null or empty as following the graph's env (#477)", () => {
41
+ expect(viewFromMessage({ origin: host, data: { type: "behold:view", gates: "prod" } }, host)).toEqual({ gates: "prod" });
42
+ expect(viewFromMessage({ origin: host, data: { type: "behold:view", gates: "" } }, host)).toEqual({ gates: null });
43
+ expect(viewFromMessage({ origin: host, data: { type: "behold:view", gates: null } }, host)).toEqual({ gates: null });
44
+ expect(viewFromMessage({ origin: host, data: { type: "behold:view", gates: 3 } }, host)).toEqual({});
45
+ });
46
+ it("ignores another origin, another type, and any message when there is no host", () => {
47
+ expect(viewFromMessage({ origin: "http://evil", data: { type: "behold:view", member: "x" } }, host)).toBe(null);
48
+ expect(viewFromMessage({ origin: host, data: { type: "other" } }, host)).toBe(null);
49
+ expect(viewFromMessage({ origin: host, data: { type: "behold:view" } }, null)).toBe(null);
50
+ });
51
+ });