@intentius/behold 0.20.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/behold",
3
- "version": "0.20.0",
3
+ "version": "0.21.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, 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,318 @@ 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 (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
+
2102
2443
  // Select a node from a panel row the same way a graph click would: highlight
2103
2444
  // its card (when it's in the current SVG) and open its inspect panel.
2104
- function selectNode(id) {
2445
+ function selectNode(id, { quiet = false } = {}) {
2105
2446
  const node = lastGraphIr && lastGraphIr.nodes.find((n) => n.id === id);
2106
2447
  if (!node) return;
2107
2448
  const host = document.getElementById("graph");
@@ -2109,6 +2450,7 @@ function selectNode(id) {
2109
2450
  const g = host.querySelector(`[data-node-id="${CSS.escape(id)}"]`);
2110
2451
  if (g) g.classList.add("sel");
2111
2452
  inspect(node);
2453
+ noteSelection(node.id, { quiet });
2112
2454
  carvePick(node);
2113
2455
  }
2114
2456
 
@@ -2704,6 +3046,7 @@ function renderStatusbar() {
2704
3046
  // Pure state — the strip echoes the axes whose controls live on the floating
2705
3047
  // panel and in ⌘K, so the current view stays legible with the panel collapsed.
2706
3048
  const parts = [`zoom: ${zoomValue()}`, view.env ? `env: ${view.env}` : "env: (source)"];
3049
+ if (view.member) parts.unshift(`member: ${view.member}`);
2707
3050
  // #399: which colour the graph is in is state, not a control, so it belongs
2708
3051
  // on the strip the same way the zoom and the env do. Only when it is not
2709
3052
  // drift — drift is what the strip has always implied.
@@ -2717,6 +3060,10 @@ function renderStatusbar() {
2717
3060
  const stillReading = pendingLine(pendingMembers, pendingTotal);
2718
3061
  if (stillReading) parts.push(stillReading);
2719
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();
2720
3067
  // #131: why this level rendered empty, or as the one below it. The server
2721
3068
  // decides (src/zoom-notes.ts) — the SPA never infers it, so the note always
2722
3069
  // describes the graph that actually came back. Appended rather than mixed
@@ -3060,7 +3407,9 @@ function mountOperator(host) {
3060
3407
  box,
3061
3408
  operatorState,
3062
3409
  {
3063
- 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",
3064
3413
  // A static export has no server to ask, so it gets no history affordance
3065
3414
  // at all rather than a button that can only fail.
3066
3415
  ...(staticMode ? {} : { history: () => toggleOperatorHistory() }),
@@ -3078,7 +3427,7 @@ function mountPlayhead(host) {
3078
3427
  if (!hasPlayhead(runState)) return;
3079
3428
  const box = document.createElement("div");
3080
3429
  box.className = "run-playhead";
3081
- 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" });
3082
3431
  host.appendChild(box);
3083
3432
  }
3084
3433
 
@@ -4674,6 +5023,7 @@ async function loadOnce(opts = {}, isCurrent = () => true) {
4674
5023
  pendingMembers = body.meta && body.meta.mode === "progressive" ? [...(body.meta.pending || [])] : [];
4675
5024
  pendingTotal = pendingMembers.length;
4676
5025
  render(body.ir, body.svg, body.meta);
5026
+ afterPaint(body.ir);
4677
5027
  } catch (err) {
4678
5028
  // A background settle poll must not blow away a good graph on a transient error.
4679
5029
  if (!opts.quiet && isCurrent()) {
@@ -4807,6 +5157,7 @@ async function initPickers() {
4807
5157
  environments = info.environments || [];
4808
5158
  tiers = info.tiers || [];
4809
5159
  targets = info.targets || [];
5160
+ openFromQuery();
4810
5161
  renderStatusbar();
4811
5162
  load();
4812
5163
  }
@@ -4849,6 +5200,7 @@ events.addEventListener("member", (e) => {
4849
5200
  events.addEventListener("changed", () => {
4850
5201
  bulkDiffCache = null; // an op ran → per-node live state may have changed
4851
5202
  load();
5203
+ loadWorkspaceGates({ force: true });
4852
5204
  loadSubstrates(); // a bring-up (or any op) finished → re-detect readiness
4853
5205
  });
4854
5206
 
@@ -5500,6 +5852,8 @@ async function initActions() {
5500
5852
  }
5501
5853
  }
5502
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";
5503
5857
  initInspectPane();
5504
5858
 
5505
5859
  // Inspect pane chrome (#15): collapse (chevron / edge tab) + drag-to-resize, with
@@ -5508,7 +5862,11 @@ initInspectPane();
5508
5862
  // so the ⌘K palette (#73) can drive the same collapse/reopen the chevron does.
5509
5863
  function setInspectCollapsed(on) {
5510
5864
  document.getElementById("app").classList.toggle("inspect-collapsed", on);
5511
- localStorage.setItem("behold.inspectCollapsed", on ? "1" : "0");
5865
+ try {
5866
+ localStorage.setItem(INSPECT_COLLAPSED_KEY, on ? "1" : "0");
5867
+ } catch {
5868
+ /* private mode */
5869
+ }
5512
5870
  }
5513
5871
  function toggleInspect() {
5514
5872
  setInspectCollapsed(!document.getElementById("app").classList.contains("inspect-collapsed"));
@@ -5521,7 +5879,13 @@ function initInspectPane() {
5521
5879
  // Restore persisted width + collapsed state.
5522
5880
  const savedW = Number(localStorage.getItem("behold.inspectW"));
5523
5881
  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");
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");
5525
5889
 
5526
5890
  document.getElementById("inspect-collapse").addEventListener("click", () => setInspectCollapsed(true));
5527
5891
  document.getElementById("inspect-reopen").addEventListener("click", () => setInspectCollapsed(false));
@@ -5691,6 +6055,10 @@ function paletteCommands() {
5691
6055
  }
5692
6056
 
5693
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
+ }
5694
6062
  c.push(["env: (source)" + (!view.env ? " ✓" : ""), () => { view.env = null; resetDialCaches(); load(); }]);
5695
6063
  for (const e of environments) {
5696
6064
  c.push([`env: ${e}` + (view.env === e ? " ✓" : ""), () => { view.env = e; resetDialCaches(); load(); }]);
@@ -5722,7 +6090,7 @@ function paletteCommands() {
5722
6090
  if (opsApply) {
5723
6091
  // #165: not for a designated env — its ApplyOp is refused server-side too.
5724
6092
  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)]);
6093
+ if (opsApply.gate && !embedOpts.embed) c.push([`Approve ${opsApply.gate}`, () => signal(opsApply.name, opsApply.gate)]);
5726
6094
  }
5727
6095
  // #165: a designated env's applies happen on the forge, so a rollback PR
5728
6096
  // opened from here would have nothing local to land — hidden, as designed.
package/web/embed.js ADDED
@@ -0,0 +1,76 @@
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") {
65
+ if (v === null || v === "") want.env = null;
66
+ else if (typeof v === "string") want.env = v;
67
+ continue;
68
+ }
69
+ if (k === "member" && v === null) {
70
+ want.member = null; // the whole estate
71
+ continue;
72
+ }
73
+ if (typeof v === "string" && v) want[k] = v;
74
+ }
75
+ return want;
76
+ }
@@ -0,0 +1,45 @@
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("ignores another origin, another type, and any message when there is no host", () => {
41
+ expect(viewFromMessage({ origin: "http://evil", data: { type: "behold:view", member: "x" } }, host)).toBe(null);
42
+ expect(viewFromMessage({ origin: host, data: { type: "other" } }, host)).toBe(null);
43
+ expect(viewFromMessage({ origin: host, data: { type: "behold:view" } }, null)).toBe(null);
44
+ });
45
+ });