@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/README.md +53 -0
- package/dist/cli.js +301 -65
- package/package.json +1 -1
- package/web/app.js +384 -9
- package/web/embed.js +78 -0
- package/web/embed.test.js +51 -0
- package/web/index.html +20 -0
- package/web/operator.js +14 -1
- package/web/panel.js +11 -3
- package/web/run-playhead.js +13 -1
- package/web/view-url.js +151 -0
- package/web/view-url.test.js +109 -0
- package/web/workspace-gates.js +44 -0
- package/web/workspace-gates.test.js +34 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/behold",
|
|
3
|
-
"version": "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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|