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