@intentius/behold 0.18.0 → 0.20.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 +377 -76
- package/README.md +238 -190
- package/demos.json +17 -8
- package/dist/cli.js +13118 -11622
- 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 +139 -62
- package/example-carve/README.md +14 -14
- package/example-carve/app/package-lock.json +75 -12
- 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 +139 -62
- 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 +135 -217
- package/example-k8s/package.json +2 -3
- package/example-terraform-estate/README.md +109 -0
- package/example-terraform-estate/backends/backend.s3.tf +10 -0
- package/example-terraform-estate/chant.workspace.json +22 -0
- package/example-terraform-estate/envs/dev/README.md +5 -0
- package/example-terraform-estate/envs/prod/data.tf +18 -0
- package/example-terraform-estate/envs/prod/network.tf +80 -0
- package/example-terraform-estate/envs/prod/outputs.tf +13 -0
- package/example-terraform-estate/envs/prod/provider.tf +10 -0
- package/example-terraform-estate/envs/prod/services.tf +21 -0
- package/example-terraform-estate/envs/prod/storage.tf +31 -0
- package/example-terraform-estate/envs/prod/variables.tf +17 -0
- package/example-terraform-estate/envs/prod/versions.tf +10 -0
- package/example-terraform-estate/modules/service/main.tf +58 -0
- package/example-terraform-estate/modules/service/outputs.tf +7 -0
- package/example-terraform-estate/modules/service/variables.tf +16 -0
- package/example-terraform-estate/modules/service/versions.tf +10 -0
- package/example-terraform-estate/platform/iam.tf +40 -0
- package/example-terraform-estate/platform/kms.tf +16 -0
- package/example-terraform-estate/platform/outputs.tf +7 -0
- package/example-terraform-estate/platform/variables.tf +10 -0
- package/example-terraform-estate/platform/versions.tf +10 -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 +65 -178
- package/example-writes/package.json +2 -3
- package/package.json +7 -6
- package/web/app.js +439 -34
- package/web/behaviour-scale.js +32 -0
- package/web/behaviour-scale.test.js +39 -0
- package/web/index.html +23 -1
- package/web/pending.js +67 -0
- package/web/pending.test.js +62 -0
- package/web/read-cost.js +49 -0
- package/web/read-cost.test.js +76 -0
- package/web/refresh-queue.js +26 -0
- package/web/refresh-queue.test.js +54 -0
- package/example-choudoufu-estate/.behold.json +0 -8
package/web/app.js
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
// floating control panel's chrome (panel.js — drag/snap/collapse/tabs, persisted
|
|
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
|
+
import { createRefreshQueue } from "./refresh-queue.js";
|
|
12
|
+
import { readCostLine } from "./read-cost.js";
|
|
13
|
+
import { applyMemberFrame, pendingLine, stillPending } from "./pending.js";
|
|
11
14
|
import { initTheme, mountThemePicker, readableOn, colorForCategory, onThemeChange, getTokens, getTheme, pinTokensFor } from "./theme.js";
|
|
12
15
|
// #399 M2 / #401 M4 of #397: the colour-by modes' arithmetic and the
|
|
13
16
|
// provenance badge's wording — every decision the behaviour overlay makes,
|
|
@@ -32,6 +35,7 @@ import {
|
|
|
32
35
|
scaleEnds,
|
|
33
36
|
totalsFor,
|
|
34
37
|
totalsText,
|
|
38
|
+
deltaRows,
|
|
35
39
|
} from "./behaviour-scale.js";
|
|
36
40
|
import { addPanelTab, initPanel, setPanelTab, togglePanelCollapsed, isPanelCollapsed } from "./panel.js";
|
|
37
41
|
// #254: the carve walkthrough's stepper — everything it DECIDES is a pure
|
|
@@ -199,6 +203,12 @@ function recolorNodesByCategory(ir) {
|
|
|
199
203
|
// unpriced count has to agree with what is on the canvas.
|
|
200
204
|
if (plan.mode !== "drift" && paint.unpriced) g.setAttribute("data-unpriced", "1");
|
|
201
205
|
else g.removeAttribute("data-unpriced");
|
|
206
|
+
// #429: and the card an overlay never observed because its member has no
|
|
207
|
+
// live half. Only on an overlay: on the source graph NO card has a status
|
|
208
|
+
// and the mark would be on every one of them, saying nothing.
|
|
209
|
+
const onOverlay = !!(lastMeta && lastMeta.env);
|
|
210
|
+
const node = graphIr.nodes.find((n) => n.id === id);
|
|
211
|
+
g.toggleAttribute("data-no-live", onOverlay && !!node && !(node.attrs && node.attrs._status));
|
|
202
212
|
// Classify each element ONCE by the pinhole token it rode on (data-cat role), then apply
|
|
203
213
|
// the role's colour on every pass. This is what makes it recolour on theme switch: after
|
|
204
214
|
// the first pass the fill is a hex (not a --pin-* var), so we must key off the marker, not
|
|
@@ -282,11 +292,11 @@ if (staticMode) {
|
|
|
282
292
|
|
|
283
293
|
/** Canonical key for a read URL — path + the lens params (whitelisted, sorted)
|
|
284
294
|
* that select a distinct snapshot. MUST match src/export.ts `canonicalKey`. */
|
|
285
|
-
const LENS_PARAMS = ["collapse", "components", "detail", "env", "logical", "ops", "radial", "runtime", "tier"];
|
|
295
|
+
const LENS_PARAMS = ["collapse", "components", "detail", "env", "logical", "ops", "radial", "runtime", "stacks", "tier"];
|
|
286
296
|
function canonicalKey(path, params) {
|
|
287
297
|
// Components + logical + ops views ignore detail/radial — drop them so they
|
|
288
298
|
// match the single captured snapshot (MUST match src/export.ts).
|
|
289
|
-
const flat = params.get("components") === "1" || params.get("logical") === "1" || params.get("ops") === "1";
|
|
299
|
+
const flat = params.get("components") === "1" || params.get("logical") === "1" || params.get("ops") === "1" || params.get("stacks") === "1";
|
|
290
300
|
const q = LENS_PARAMS.filter((k) => params.has(k) && !(flat && (k === "detail" || k === "radial")))
|
|
291
301
|
.map((k) => `${k}=${params.get(k)}`)
|
|
292
302
|
.join("&");
|
|
@@ -329,6 +339,29 @@ function vocabularyNote() {
|
|
|
329
339
|
const v = lastMeta && lastMeta.vocabulary;
|
|
330
340
|
return (v && v.note) || "";
|
|
331
341
|
}
|
|
342
|
+
// #396 item 5: the inspect pane's LIVE section prints `ownership` raw, and
|
|
343
|
+
// `ownership` is a word two different readers write into the same field.
|
|
344
|
+
// chant's live reader answers it from the resource's own marker — `owned` /
|
|
345
|
+
// `foreign`, which is chant's contract for that field (chant graph-ir.d.ts).
|
|
346
|
+
// src/choudoufu-live.ts has no marker to read: it writes the same two words for
|
|
347
|
+
// choudoufu's `bound[]` and `unowned[]`, so an unowned card read `status:
|
|
348
|
+
// unowned` and, two rows below it, `ownership: foreign` — chant's word for
|
|
349
|
+
// choudoufu's state, the last of the four #393 item 8 did not reach.
|
|
350
|
+
//
|
|
351
|
+
// So this row joins the legend, the counts and the status row in being rendered
|
|
352
|
+
// through the estate's own vocabulary rather than printed. Translated ONLY when
|
|
353
|
+
// the estate's members are all of a kind that has its own words for these
|
|
354
|
+
// colours (`vocabulary.of`): on a chant estate, and on a mixed one — which
|
|
355
|
+
// keeps chant's words on the legend by design and says so in its tooltip — the
|
|
356
|
+
// value is printed exactly as it is today, which is the point. The mapping is
|
|
357
|
+
// value → the colour that value means → that colour's word, so it needs no
|
|
358
|
+
// table of its own beyond the two words chant's field can hold.
|
|
359
|
+
const OWNERSHIP_STATUS = { owned: "good", foreign: "warn" };
|
|
360
|
+
function ownershipLabel(value) {
|
|
361
|
+
const v = lastMeta && lastMeta.vocabulary;
|
|
362
|
+
if (!v || !v.labels || !v.of || v.of === "chant" || v.of === "mixed") return value;
|
|
363
|
+
return v.labels[OWNERSHIP_STATUS[value]] || value;
|
|
364
|
+
}
|
|
332
365
|
// #404: whether a plan was read for this overlay, and how many cards it would
|
|
333
366
|
// change. `{read: false}` and `{read: true, drifted: 0}` are DIFFERENT answers
|
|
334
367
|
// — "nobody looked" versus "looked, nothing drifted" — and the legend must not
|
|
@@ -695,6 +728,15 @@ function inspect(node) {
|
|
|
695
728
|
// statement about this entity is legible — and where a figure without its
|
|
696
729
|
// provenance would be visible as the contract violation it is.
|
|
697
730
|
renderBehaviourSection(section, blockOf(node));
|
|
731
|
+
// #402: what the FILE says this entity costs, beside what the account does.
|
|
732
|
+
// On a card the account no longer holds this is the only behaviour row, and
|
|
733
|
+
// it says so rather than reading as the card's cost.
|
|
734
|
+
const declared = node.attrs && node.attrs._behaviourDeclared;
|
|
735
|
+
if (declared && declared.cost) {
|
|
736
|
+
const add = section("behaviour · declared");
|
|
737
|
+
add("cost", figureWithBadge(fmtCost(declared.cost.perHour, declared.cost.currency), [declared], behaviourMeta()));
|
|
738
|
+
if (!blockOf(node)) add("live", "no live figure — the prediction of the account does not include this entity");
|
|
739
|
+
}
|
|
698
740
|
|
|
699
741
|
// Render diff (#146's deferred half): what `chant helm diff <digest> <env>
|
|
700
742
|
// --live --json` said about this chart's PINNED render. Never rely on the
|
|
@@ -708,7 +750,7 @@ function inspect(node) {
|
|
|
708
750
|
if (node.physicalId || node.ownership) {
|
|
709
751
|
const live = section("live");
|
|
710
752
|
if (node.physicalId) live("physical id", node.physicalId);
|
|
711
|
-
if (node.ownership) live("ownership", node.ownership);
|
|
753
|
+
if (node.ownership) live("ownership", ownershipLabel(node.ownership)); // #396 item 5: the estate's own word for it
|
|
712
754
|
} else if (liveStatus) {
|
|
713
755
|
// The colour alone doesn't carry chant's verdict or its reasoning — spell
|
|
714
756
|
// both out here (never rely on the node's colour alone, #57 accessibility
|
|
@@ -826,15 +868,22 @@ function inspect(node) {
|
|
|
826
868
|
const attrKeys = Object.keys(node.attrs || {}).filter((k) => !k.startsWith("_") && !(k === "release" && release));
|
|
827
869
|
if (attrKeys.length) {
|
|
828
870
|
const decl = section("declared");
|
|
829
|
-
// #393 item 10:
|
|
830
|
-
//
|
|
831
|
-
// `adopt_tofu_address`), or its own one-line command
|
|
832
|
-
//
|
|
833
|
-
//
|
|
834
|
-
//
|
|
835
|
-
//
|
|
871
|
+
// #393 item 10: a choudoufu card that can be adopted leads with the write
|
|
872
|
+
// that does it — the two marker tags choudoufu named (`adopt_tofu_estate` /
|
|
873
|
+
// `adopt_tofu_address`), or its own one-line command when it sends one.
|
|
874
|
+
// behold composes neither (src/choudoufu-live.ts); this puts the line
|
|
875
|
+
// first and makes it copyable, the way the moves panel already does for a
|
|
876
|
+
// `live-mv` line. The paragraph explaining the omission follows.
|
|
877
|
+
//
|
|
878
|
+
// #413: both kinds reach here now. An UNOWNED row carries the marker pair,
|
|
879
|
+
// and an adoptable one carries choudoufu's own command — so the tooltip
|
|
880
|
+
// says which of the two this is rather than claiming tags either way.
|
|
836
881
|
const adoptable = node.lexicon === "choudoufu" && typeof node.attrs.adopt === "string" ? "adopt" : null;
|
|
837
|
-
if (adoptable)
|
|
882
|
+
if (adoptable) {
|
|
883
|
+
const isCommand = /\s/.test(node.attrs.adopt.trim()) && !node.attrs.adopt.startsWith("tofu-estate=");
|
|
884
|
+
copyableRow(decl, adoptable, node.attrs.adopt,
|
|
885
|
+
isCommand ? "Copy the command that adopts this object" : "Copy the tags that adopt this object");
|
|
886
|
+
}
|
|
838
887
|
for (const k of attrKeys) {
|
|
839
888
|
if (k === adoptable) continue;
|
|
840
889
|
decl(k, fmtValue(node.attrs[k]));
|
|
@@ -994,7 +1043,7 @@ function renderObserved(panel, o, health, healthDetail) {
|
|
|
994
1043
|
if (o.type) add("type", o.type);
|
|
995
1044
|
if (o.status) add("status", o.status, HEALTH_COLOR[health] || undefined);
|
|
996
1045
|
if (o.physicalId) add("physical id", o.physicalId);
|
|
997
|
-
if (o.ownership) add("ownership", o.ownership);
|
|
1046
|
+
if (o.ownership) add("ownership", ownershipLabel(o.ownership)); // #396 item 5: same, off /api/diff
|
|
998
1047
|
if (o.lastUpdated) add("last updated", o.lastUpdated);
|
|
999
1048
|
// What the object's own controller says is wrong with it (#86, chant#1401).
|
|
1000
1049
|
// Placed with health and status rather than among the attributes below,
|
|
@@ -1259,6 +1308,13 @@ const ZOOM_OPTS = [
|
|
|
1259
1308
|
// same chant source), so it sits last, and only appears when the served
|
|
1260
1309
|
// estate has actually emitted Ops — `opsAvailable`, from /api/project.
|
|
1261
1310
|
["zoom: ops", "ops"],
|
|
1311
|
+
// The apply order (#426): the cross-stack ordering chant already computes at
|
|
1312
|
+
// synthesis — which lexicon partition must apply before which. A different
|
|
1313
|
+
// axis again, like `ops`: neither coarser nor finer than a resource, and the
|
|
1314
|
+
// only stop whose edges are about TIME rather than reference. Single-project
|
|
1315
|
+
// only and gated on `stacksCapable`, because two members' `aws` stacks are
|
|
1316
|
+
// not one stack.
|
|
1317
|
+
["zoom: apply order", "stacks"],
|
|
1262
1318
|
];
|
|
1263
1319
|
const ZOOM_DETAIL = { composites: 1, resources: 2, attributes: 3, runtime: 3 };
|
|
1264
1320
|
/** How many emitted Ops the served estate has (0 = the stop doesn't exist).
|
|
@@ -1271,18 +1327,28 @@ let opsAvailable = 0;
|
|
|
1271
1327
|
* Kubernetes tier that could only ever answer "nothing below the declaration
|
|
1272
1328
|
* boundary". */
|
|
1273
1329
|
let runtimeCapable = false;
|
|
1330
|
+
/** #422: the members whose live read has not landed yet, off the progressive
|
|
1331
|
+
* overlay's `meta.pending`. Empty on every non-progressive load, which is every
|
|
1332
|
+
* single-project serve and every static snapshot. */
|
|
1333
|
+
let pendingMembers = [];
|
|
1334
|
+
let pendingTotal = 0;
|
|
1335
|
+
/** #426: whether the apply-order stop is worth offering — /api/project's
|
|
1336
|
+
* `stacksCapable`, which is false for an estate and for a single-lexicon
|
|
1337
|
+
* project, where the answer could only ever be one box. */
|
|
1338
|
+
let stacksCapable = false;
|
|
1274
1339
|
/** The zoom stops this project can actually offer — `runtime` needs an env AND
|
|
1275
1340
|
* a member whose substrate has an owner chain, `ops` needs emitted op.json
|
|
1276
1341
|
* files. Both the panel and ⌘K read this, so a stop never appears in one
|
|
1277
1342
|
* surface and not the other. */
|
|
1278
1343
|
function availableZooms() {
|
|
1279
|
-
return ZOOM_OPTS.filter(([, z]) => (z === "runtime" ? Boolean(view.env) && runtimeCapable : z === "ops" ? opsAvailable > 0 : true));
|
|
1344
|
+
return ZOOM_OPTS.filter(([, z]) => (z === "runtime" ? Boolean(view.env) && runtimeCapable : z === "ops" ? opsAvailable > 0 : z === "stacks" ? stacksCapable : true));
|
|
1280
1345
|
}
|
|
1281
1346
|
/** Current zoom value from (components, logical, ops, detail). */
|
|
1282
1347
|
function zoomValue() {
|
|
1283
1348
|
if (view.components) return "components";
|
|
1284
1349
|
if (view.logical) return "logical";
|
|
1285
1350
|
if (view.ops) return "ops";
|
|
1351
|
+
if (view.stacks) return "stacks";
|
|
1286
1352
|
if (view.runtime) return "runtime";
|
|
1287
1353
|
return { 1: "composites", 2: "resources", 3: "attributes" }[view.detail] ?? "resources";
|
|
1288
1354
|
}
|
|
@@ -1291,8 +1357,9 @@ function applyZoom(z) {
|
|
|
1291
1357
|
view.components = z === "components";
|
|
1292
1358
|
view.logical = z === "logical";
|
|
1293
1359
|
view.ops = z === "ops";
|
|
1360
|
+
view.stacks = z === "stacks";
|
|
1294
1361
|
view.runtime = z === "runtime";
|
|
1295
|
-
if (z !== "components" && z !== "logical" && z !== "ops") view.detail = ZOOM_DETAIL[z] ?? 2;
|
|
1362
|
+
if (z !== "components" && z !== "logical" && z !== "ops" && z !== "stacks") view.detail = ZOOM_DETAIL[z] ?? 2;
|
|
1296
1363
|
}
|
|
1297
1364
|
|
|
1298
1365
|
// --- Floating control panel content ---------------------------------------
|
|
@@ -1539,12 +1606,21 @@ function renderCarveState(host, state) {
|
|
|
1539
1606
|
*/
|
|
1540
1607
|
function copyableRow(add, key, text, title) {
|
|
1541
1608
|
const wrap = document.createElement("span");
|
|
1542
|
-
|
|
1609
|
+
// `center`, not `baseline`: an `overflow: hidden` flex item takes its bottom
|
|
1610
|
+
// margin edge as its baseline, so the clipped line below would have dragged
|
|
1611
|
+
// the copy button down with it.
|
|
1612
|
+
wrap.style.cssText = "display:flex;gap:6px;align-items:center;min-width:0";
|
|
1543
1613
|
const line = document.createElement("code");
|
|
1544
1614
|
line.className = "grow";
|
|
1545
|
-
line.
|
|
1615
|
+
// #396 item 7c: ONE line, clipped. `tofu-estate=… tofu-address=…` wrapped
|
|
1616
|
+
// onto three lines in the 260px pane and pushed everything the card had to
|
|
1617
|
+
// say off the bottom — for a string nobody reads off the screen anyway,
|
|
1618
|
+
// because the copy button beside it is how it gets used. The whole of it is
|
|
1619
|
+
// on this row's tooltip, on the button's, and in the clipboard.
|
|
1620
|
+
line.style.cssText = "flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis";
|
|
1546
1621
|
line.textContent = text;
|
|
1547
|
-
|
|
1622
|
+
line.title = text;
|
|
1623
|
+
const copy = actButton("copy", () => copyToClipboard(text, copy), title ? `${title}:\n${text}` : text);
|
|
1548
1624
|
wrap.append(line, copy);
|
|
1549
1625
|
add(key, wrap);
|
|
1550
1626
|
}
|
|
@@ -1676,6 +1752,35 @@ function renderBehaviourTotals(host) {
|
|
|
1676
1752
|
// precedence in one place rather than at each call site.
|
|
1677
1753
|
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");
|
|
1678
1754
|
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."));
|
|
1755
|
+
if (mode === "cost") renderBehaviourDelta(host, b);
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
/**
|
|
1759
|
+
* Live versus declared (#402): what the account costs as it stands, what the
|
|
1760
|
+
* file says it should, and the difference. Present only when the overlay was
|
|
1761
|
+
* asked for at a traffic level, because both figures are predictions *at* one.
|
|
1762
|
+
* The wording of every row is `deltaRows`' (web/behaviour-scale.js).
|
|
1763
|
+
*/
|
|
1764
|
+
function renderBehaviourDelta(host, b) {
|
|
1765
|
+
const rows = deltaRows(b);
|
|
1766
|
+
if (!rows.length) return;
|
|
1767
|
+
host.appendChild(panelHeading(`live versus declared${b.at ? ` · at ${b.at.traffic}` : ""}`));
|
|
1768
|
+
for (const t of rows) {
|
|
1769
|
+
const r = document.createElement("div");
|
|
1770
|
+
r.className = "behaviour-total";
|
|
1771
|
+
const name = document.createElement("div");
|
|
1772
|
+
name.className = "grow";
|
|
1773
|
+
name.style.fontWeight = "600";
|
|
1774
|
+
name.textContent = t.label;
|
|
1775
|
+
r.appendChild(name);
|
|
1776
|
+
for (const [word, text] of [["live", t.live], ["declared", t.declared], ["delta", t.delta]]) {
|
|
1777
|
+
const line = document.createElement("div");
|
|
1778
|
+
line.textContent = `${word} ${text}`;
|
|
1779
|
+
r.appendChild(line);
|
|
1780
|
+
}
|
|
1781
|
+
host.appendChild(r);
|
|
1782
|
+
}
|
|
1783
|
+
host.appendChild(panelMuted("delta is live minus declared. A negative one means the account holds less than the file declares."));
|
|
1679
1784
|
}
|
|
1680
1785
|
|
|
1681
1786
|
function renderPanelScope() {
|
|
@@ -2073,6 +2178,23 @@ function renderPanelModel() {
|
|
|
2073
2178
|
// bound and is already counted on the row above. The row appears only when
|
|
2074
2179
|
// a plan was actually read; where it was not, the legend says so in words
|
|
2075
2180
|
// and prints no number, because "0 drifted" would be a claim nobody made.
|
|
2181
|
+
// #429: a card with NO `_status` at all. pinhole's painter falls back to
|
|
2182
|
+
// `neutral` for an unrecognised value, `undefined` included, so a member
|
|
2183
|
+
// with no live half — a Terraform root, whose `live`/`overlay`/`env` are
|
|
2184
|
+
// stripped in src/terraform-member.ts — paints exactly like a card behold
|
|
2185
|
+
// looked at and could not see. It is in no legend bucket either, so the
|
|
2186
|
+
// colour claimed 37 unobserved cards on an estate whose legend counted 2.
|
|
2187
|
+
//
|
|
2188
|
+
// Named rather than folded into `unobserved`, because they are different
|
|
2189
|
+
// claims: "I looked and could not see" against "there is nothing here to
|
|
2190
|
+
// look at". Same argument #399 makes for an unpriced card, and the same
|
|
2191
|
+
// answer — mark it, do not merely colour it.
|
|
2192
|
+
const noLive = ir.nodes.filter((n) => !(n.attrs && n.attrs._status)).length;
|
|
2193
|
+
if (noLive) {
|
|
2194
|
+
const row = panelDotRow("var(--muted)", "no live half", String(noLive));
|
|
2195
|
+
row.title = "These members declare no live half, so nothing observed them — which is not the same as unobserved, and not the same as absent.";
|
|
2196
|
+
host.appendChild(row);
|
|
2197
|
+
}
|
|
2076
2198
|
const dm = driftMeta();
|
|
2077
2199
|
if (dm && dm.read) {
|
|
2078
2200
|
host.appendChild(panelDotRow("var(--degraded)", "drifted (the plan would change it)", String(dm.drifted)));
|
|
@@ -2462,6 +2584,36 @@ function markOperatorCards(ir) {
|
|
|
2462
2584
|
* estates this can appear on the corner is free; the guard below still skips a
|
|
2463
2585
|
* card that already carries one of those, rather than drawing over it.
|
|
2464
2586
|
*/
|
|
2587
|
+
/**
|
|
2588
|
+
* The cards whose member is still being read (#422).
|
|
2589
|
+
*
|
|
2590
|
+
* The same post-render stamp `markCarvedCards`/`markDriftedCards` use, and for
|
|
2591
|
+
* the same reason: pinhole's SVG arrives as a string and is replaced on every
|
|
2592
|
+
* render, so a mark is re-applied here rather than asked for at layout time.
|
|
2593
|
+
*
|
|
2594
|
+
* A dashed outline and a muted corner rather than a fill, because the fill is
|
|
2595
|
+
* the status channel and this card HAS no status yet — painting one would be
|
|
2596
|
+
* the claim the whole milestone exists to avoid. `_pendingRead` rides its own
|
|
2597
|
+
* attr for the same reason: a fifth `_status` value falls back to neutral in
|
|
2598
|
+
* pinhole's painter, which reads as "unobserved", which is a different and
|
|
2599
|
+
* wrong thing.
|
|
2600
|
+
*/
|
|
2601
|
+
function markPendingCards(ir) {
|
|
2602
|
+
const svg = document.querySelector("#graph svg");
|
|
2603
|
+
if (!svg) return;
|
|
2604
|
+
for (const n of (ir && ir.nodes) || []) {
|
|
2605
|
+
const g = svg.querySelector('[data-node-id="' + CSS.escape(n.id) + '"]');
|
|
2606
|
+
if (!g) continue;
|
|
2607
|
+
const pending = !!(n.attrs && n.attrs._pendingRead);
|
|
2608
|
+
g.toggleAttribute("data-pending-read", pending);
|
|
2609
|
+
if (pending && !g.querySelector("title")) {
|
|
2610
|
+
const t = document.createElementNS("http://www.w3.org/2000/svg", "title");
|
|
2611
|
+
t.textContent = "still reading this member — not yet observed";
|
|
2612
|
+
g.appendChild(t);
|
|
2613
|
+
}
|
|
2614
|
+
}
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2465
2617
|
function markDriftedCards(ir) {
|
|
2466
2618
|
const svg = document.querySelector("#graph svg");
|
|
2467
2619
|
if (!svg) return;
|
|
@@ -2559,6 +2711,11 @@ function renderStatusbar() {
|
|
|
2559
2711
|
if (view.stack) parts.push(`stack: ${view.stack}`);
|
|
2560
2712
|
if (axes.tier) parts.push(`tier: ${axes.tier}`);
|
|
2561
2713
|
if (view.radial && !view.components && !view.logical && !view.ops) parts.push("radial");
|
|
2714
|
+
// #422: what the picture is still waiting on, while it is waiting. Ahead of
|
|
2715
|
+
// the zoom note, because a half-read estate is the more urgent fact about
|
|
2716
|
+
// what is on screen.
|
|
2717
|
+
const stillReading = pendingLine(pendingMembers, pendingTotal);
|
|
2718
|
+
if (stillReading) parts.push(stillReading);
|
|
2562
2719
|
el.textContent = parts.join(" · ");
|
|
2563
2720
|
// #131: why this level rendered empty, or as the one below it. The server
|
|
2564
2721
|
// decides (src/zoom-notes.ts) — the SPA never infers it, so the note always
|
|
@@ -3388,6 +3545,7 @@ function render(ir, svg, m) {
|
|
|
3388
3545
|
markPlayhead(ir); // #284 item 2: same, for the step the run is sitting on
|
|
3389
3546
|
markOperatorCards(ir); // #234 free rider: same, for the operating loop's home
|
|
3390
3547
|
markDriftedCards(ir); // #404: same, for a bound card the plan would change — after the carve/operator stamps, so it can see and yield to them
|
|
3548
|
+
markPendingCards(ir); // #422: and the cards whose member has not answered yet
|
|
3391
3549
|
applyLayout(); // #228: last, so the hand-placed deltas ride on top of every other pass
|
|
3392
3550
|
renderDial();
|
|
3393
3551
|
}
|
|
@@ -3483,7 +3641,13 @@ function setupGraphViewBox(svg) {
|
|
|
3483
3641
|
const a = (svg.getAttribute("viewBox") || "").split(/\s+/).map(Number);
|
|
3484
3642
|
if (a.length === 4 && a.every((n) => !Number.isNaN(n))) {
|
|
3485
3643
|
vbInit = a.slice();
|
|
3486
|
-
|
|
3644
|
+
// #396 item 2: land on the fit that keeps the panel's footprint clear, not
|
|
3645
|
+
// on the raw box — a re-render (a zoom change, an env change, the SSE
|
|
3646
|
+
// re-pull) goes through here and never through fitGraph, so the first
|
|
3647
|
+
// column would slide back under the panel at every one of them.
|
|
3648
|
+
vb = fittedViewBox() || a.slice();
|
|
3649
|
+
vbAtFit = true;
|
|
3650
|
+
applyVB();
|
|
3487
3651
|
} else {
|
|
3488
3652
|
vbInit = vb = null;
|
|
3489
3653
|
}
|
|
@@ -3494,11 +3658,178 @@ function currentSvg() {
|
|
|
3494
3658
|
function applyVB() {
|
|
3495
3659
|
const s = currentSvg();
|
|
3496
3660
|
if (s && vb) s.setAttribute("viewBox", vb.join(" "));
|
|
3661
|
+
scaleBoxLabels(); // #396 item 7b: box titles/badges keep a legible size at every zoom
|
|
3662
|
+
}
|
|
3663
|
+
|
|
3664
|
+
// #396 item 2: the panel is `position: fixed` and floats OVER the graph pane
|
|
3665
|
+
// (index.html), so "fit the pane" put the leftmost column of cards underneath
|
|
3666
|
+
// it — a whole column of the terralith, half waterpark's `github` box — at
|
|
3667
|
+
// boot and after every re-render. The panel is furniture the reader cannot see
|
|
3668
|
+
// through, so the fit's target is the pane MINUS whatever the panel is
|
|
3669
|
+
// currently covering.
|
|
3670
|
+
//
|
|
3671
|
+
// Read at fit time, never remembered: the panel drags, docks to either side and
|
|
3672
|
+
// collapses to its tab bar, and a fit computed against where it used to be is
|
|
3673
|
+
// the same bug with a different offset. Collapsed or hidden → no footprint, and
|
|
3674
|
+
// the fit is the whole pane exactly as before.
|
|
3675
|
+
//
|
|
3676
|
+
// Horizontal only. The panel is a tall palette that docks left or right; a
|
|
3677
|
+
// top/bottom dock still leaves the full width, and insetting vertically for it
|
|
3678
|
+
// would shrink the graph for a strip the cards mostly clear anyway.
|
|
3679
|
+
const FIT_PANEL_MAX = 0.45; // the panel can never claim more than this of the pane
|
|
3680
|
+
|
|
3681
|
+
function panelInsets(pane) {
|
|
3682
|
+
const none = { left: 0, right: 0 };
|
|
3683
|
+
const p = document.getElementById("panel");
|
|
3684
|
+
if (!p || p.classList.contains("collapsed")) return none;
|
|
3685
|
+
const cs = getComputedStyle(p);
|
|
3686
|
+
if (cs.display === "none" || cs.visibility === "hidden" || Number(cs.opacity) === 0) return none;
|
|
3687
|
+
const r = p.getBoundingClientRect();
|
|
3688
|
+
if (!r.width || !r.height) return none;
|
|
3689
|
+
// Nothing to inset for a panel that has been dragged clear of the graph pane.
|
|
3690
|
+
if (r.bottom <= pane.top || r.top >= pane.bottom || r.right <= pane.left || r.left >= pane.right) return none;
|
|
3691
|
+
const cap = pane.width * FIT_PANEL_MAX;
|
|
3692
|
+
// Whichever side it is nearer to is the side it eats.
|
|
3693
|
+
return (r.left + r.right) / 2 < (pane.left + pane.right) / 2
|
|
3694
|
+
? { left: Math.min(cap, Math.max(0, r.right - pane.left)), right: 0 }
|
|
3695
|
+
: { left: 0, right: Math.min(cap, Math.max(0, pane.right - r.left)) };
|
|
3696
|
+
}
|
|
3697
|
+
|
|
3698
|
+
/**
|
|
3699
|
+
* The viewBox that fits the whole graph into the pane's UNCOVERED part.
|
|
3700
|
+
*
|
|
3701
|
+
* The returned box is given the pane's own aspect ratio, so `xMidYMid meet`
|
|
3702
|
+
* letterboxes nothing and the pane's pixels map to viewBox units at one known
|
|
3703
|
+
* scale: the graph is then placed by arithmetic — centred inside the band
|
|
3704
|
+
* between the insets, centred vertically — instead of wherever `meet` would
|
|
3705
|
+
* have dropped it. With no insets the result is pixel-identical to the old
|
|
3706
|
+
* `vb = vbInit`, which is why nothing without a panel over it moves.
|
|
3707
|
+
*/
|
|
3708
|
+
function fittedViewBox() {
|
|
3709
|
+
if (!vbInit) return null;
|
|
3710
|
+
const host = document.getElementById("graph");
|
|
3711
|
+
if (!host) return null;
|
|
3712
|
+
const pane = host.getBoundingClientRect();
|
|
3713
|
+
const [x0, y0, w0, h0] = vbInit;
|
|
3714
|
+
if (!(pane.width > 0 && pane.height > 0 && w0 > 0 && h0 > 0)) return null;
|
|
3715
|
+
const { left, right } = panelInsets(pane);
|
|
3716
|
+
const band = Math.max(1, pane.width - left - right);
|
|
3717
|
+
const s = Math.min(band / w0, pane.height / h0);
|
|
3718
|
+
const vw = pane.width / s;
|
|
3719
|
+
const vh = pane.height / s;
|
|
3720
|
+
return [x0 - left / s - (band / s - w0) / 2, y0 - (vh - h0) / 2, vw, vh];
|
|
3497
3721
|
}
|
|
3722
|
+
|
|
3498
3723
|
function fitGraph() {
|
|
3499
|
-
if (vbInit)
|
|
3500
|
-
|
|
3501
|
-
|
|
3724
|
+
if (!vbInit) return;
|
|
3725
|
+
vb = fittedViewBox() || vbInit.slice();
|
|
3726
|
+
vbAtFit = true;
|
|
3727
|
+
applyVB();
|
|
3728
|
+
}
|
|
3729
|
+
|
|
3730
|
+
// #396 item 2: the fit now depends on the pane — its size, and what the panel
|
|
3731
|
+
// covers of it — where `vbInit` alone depended on neither, so a graph sitting
|
|
3732
|
+
// at fit has to be re-fitted when the pane moves under it. It moves more often
|
|
3733
|
+
// than a window resize: the statusbar's note wraps onto a second line, the
|
|
3734
|
+
// inspect pane is dragged wider, a panel tab's content grows. Only while the
|
|
3735
|
+
// view IS the fit: a pan or a zoom takes ownership of the viewBox, and nothing
|
|
3736
|
+
// here may pull it back out from under the reader's hands.
|
|
3737
|
+
let vbAtFit = false;
|
|
3738
|
+
function watchPaneForFit(host) {
|
|
3739
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
3740
|
+
new ResizeObserver(() => {
|
|
3741
|
+
if (vbAtFit) fitGraph();
|
|
3742
|
+
}).observe(host);
|
|
3743
|
+
}
|
|
3744
|
+
|
|
3745
|
+
// --- #396 item 7b: box titles and badges that survive the fit ---------------
|
|
3746
|
+
//
|
|
3747
|
+
// pinhole draws a group box's title at a flat `font-size="12"` and its badge at
|
|
3748
|
+
// `11`, in viewBox UNITS. That reads as 12px on a graph whose fit is 1:1 and as
|
|
3749
|
+
// 7px on terralith-4, whose box is 8000 units wide — the one label that says
|
|
3750
|
+
// which member 301 cards belong to, and the one that counts them, are the two
|
|
3751
|
+
// things on the canvas a person cannot read at the zoom they arrive at.
|
|
3752
|
+
//
|
|
3753
|
+
// So the label is given a floor in SCREEN pixels: its size in units is raised
|
|
3754
|
+
// by the inverse of the current scale until it renders at its natural size, and
|
|
3755
|
+
// never below it, so anything already legible is untouched (zoomed IN, the
|
|
3756
|
+
// attribute is left at 12 and grows with the graph, exactly as before). The
|
|
3757
|
+
// anchor never moves — x, y and `text-anchor` are pinhole's — so a title stays
|
|
3758
|
+
// welded to its box's top-left corner and a badge to its top-right through
|
|
3759
|
+
// every pan and zoom; only the type gets bigger.
|
|
3760
|
+
//
|
|
3761
|
+
// The floor yields to the box: a label may not grow past a fraction of the
|
|
3762
|
+
// box's height, nor past its width. A sub-box too small to hold readable type
|
|
3763
|
+
// at this zoom says so by staying small, rather than by writing across its
|
|
3764
|
+
// neighbours.
|
|
3765
|
+
const BOX_LABEL_GUTTER = 34; // units below a box's top edge that the title row occupies
|
|
3766
|
+
const BOX_LABEL_MAX_H = 8; // a label may be at most box height / this
|
|
3767
|
+
const BOX_LABEL_EM = 0.62; // rough advance width per char, as a fraction of the size
|
|
3768
|
+
|
|
3769
|
+
let boxLabelCache = { svg: null, labels: [] };
|
|
3770
|
+
let boxLabelScale = null;
|
|
3771
|
+
|
|
3772
|
+
/** The `<text>` elements pinhole put in each group box's title gutter, with the
|
|
3773
|
+
* box they belong to and the size it drew them at. Matched by geometry rather
|
|
3774
|
+
* than by sibling order: a box's optional identity mark sits between the title
|
|
3775
|
+
* and the badge, and #228's layout pass lifts the rect and its title into a
|
|
3776
|
+
* wrapper `<g>` after the render, so neither order nor a common parent holds.
|
|
3777
|
+
* What geometry alone would let through — a card's own text, an edge chip's —
|
|
3778
|
+
* is excluded by the ancestor it sits under. */
|
|
3779
|
+
function boxLabelsOf(svg) {
|
|
3780
|
+
if (boxLabelCache.svg === svg) return boxLabelCache.labels;
|
|
3781
|
+
const labels = [];
|
|
3782
|
+
const boxes = [];
|
|
3783
|
+
for (const rect of svg.querySelectorAll("rect[data-group-id]")) {
|
|
3784
|
+
const b = {
|
|
3785
|
+
x: parseFloat(rect.getAttribute("x")),
|
|
3786
|
+
y: parseFloat(rect.getAttribute("y")),
|
|
3787
|
+
w: parseFloat(rect.getAttribute("width")),
|
|
3788
|
+
h: parseFloat(rect.getAttribute("height")),
|
|
3789
|
+
};
|
|
3790
|
+
if ([b.x, b.y, b.w, b.h].every((n) => Number.isFinite(n))) boxes.push(b);
|
|
3791
|
+
}
|
|
3792
|
+
if (boxes.length) {
|
|
3793
|
+
for (const t of svg.querySelectorAll("text")) {
|
|
3794
|
+
const x = parseFloat(t.getAttribute("x"));
|
|
3795
|
+
const y = parseFloat(t.getAttribute("y"));
|
|
3796
|
+
// pinhole's own size, stashed the first time so a re-index can never
|
|
3797
|
+
// read a size this pass already raised as if it were the natural one.
|
|
3798
|
+
const base = parseFloat(t.dataset.pinFont ?? t.getAttribute("font-size"));
|
|
3799
|
+
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(base)) continue;
|
|
3800
|
+
// Not a card's own text (inside its `g[data-node-id]`), not an edge
|
|
3801
|
+
// chip's (inside the edge's `g`), not a radial wedge label behold adds
|
|
3802
|
+
// itself. What is left at a box's title row IS the box's title row.
|
|
3803
|
+
if (t.closest("[data-node-id], [data-edge-from], [data-edge-to], #radial-labels")) continue;
|
|
3804
|
+
const box = boxes.find((b) => y > b.y && y <= b.y + BOX_LABEL_GUTTER && x >= b.x - 2 && x <= b.x + b.w + 2);
|
|
3805
|
+
if (!box) continue;
|
|
3806
|
+
t.dataset.pinFont = String(base);
|
|
3807
|
+
labels.push({ el: t, box, base });
|
|
3808
|
+
}
|
|
3809
|
+
}
|
|
3810
|
+
boxLabelCache = { svg, labels };
|
|
3811
|
+
boxLabelScale = null;
|
|
3812
|
+
return labels;
|
|
3813
|
+
}
|
|
3814
|
+
|
|
3815
|
+
/** Re-size every box label for the current zoom. Cheap and idempotent: the
|
|
3816
|
+
* scale is remembered, so the pan handler's stream of `applyVB` calls (which
|
|
3817
|
+
* move the viewBox without changing its width) touches no attribute at all. */
|
|
3818
|
+
function scaleBoxLabels() {
|
|
3819
|
+
const svg = currentSvg();
|
|
3820
|
+
if (!svg || !vb) return;
|
|
3821
|
+
const labels = boxLabelsOf(svg);
|
|
3822
|
+
if (!labels.length) return;
|
|
3823
|
+
const pane = svg.getBoundingClientRect();
|
|
3824
|
+
if (!(pane.width > 0 && pane.height > 0)) return;
|
|
3825
|
+
const scale = Math.min(pane.width / vb[2], pane.height / vb[3]);
|
|
3826
|
+
if (!(scale > 0) || scale === boxLabelScale) return;
|
|
3827
|
+
boxLabelScale = scale;
|
|
3828
|
+
for (const l of labels) {
|
|
3829
|
+
const chars = Math.max(4, (l.el.textContent || "").length);
|
|
3830
|
+
const cap = Math.max(l.base, Math.min(l.box.h / BOX_LABEL_MAX_H, l.box.w / (chars * BOX_LABEL_EM)));
|
|
3831
|
+
const size = Math.max(l.base, Math.min(l.base / scale, cap));
|
|
3832
|
+
l.el.setAttribute("font-size", String(Math.round(size * 10) / 10));
|
|
3502
3833
|
}
|
|
3503
3834
|
}
|
|
3504
3835
|
|
|
@@ -3537,6 +3868,7 @@ function revealNode(id) {
|
|
|
3537
3868
|
const w = Math.min(vbInit[2], Math.max(box.width * REVEAL_CARDS_ACROSS, vbInit[2] / 60));
|
|
3538
3869
|
const h = w * (vbInit[3] / vbInit[2]);
|
|
3539
3870
|
vb = [box.x + box.width / 2 - w / 2, box.y + box.height / 2 - h / 2, w, h];
|
|
3871
|
+
vbAtFit = false;
|
|
3540
3872
|
applyVB();
|
|
3541
3873
|
return true;
|
|
3542
3874
|
}
|
|
@@ -3555,6 +3887,7 @@ function ensureZoomControls(host) {
|
|
|
3555
3887
|
}
|
|
3556
3888
|
if (zoomWired) return;
|
|
3557
3889
|
zoomWired = true;
|
|
3890
|
+
watchPaneForFit(host);
|
|
3558
3891
|
host.addEventListener(
|
|
3559
3892
|
"wheel",
|
|
3560
3893
|
(e) => {
|
|
@@ -3576,6 +3909,7 @@ function ensureZoomControls(host) {
|
|
|
3576
3909
|
vb[1] = cy - ((cy - vb[1]) * nh) / vb[3];
|
|
3577
3910
|
vb[2] = nw;
|
|
3578
3911
|
vb[3] = nh;
|
|
3912
|
+
vbAtFit = false;
|
|
3579
3913
|
applyVB();
|
|
3580
3914
|
},
|
|
3581
3915
|
{ passive: false },
|
|
@@ -3608,6 +3942,7 @@ function ensureZoomControls(host) {
|
|
|
3608
3942
|
}
|
|
3609
3943
|
vb[0] -= (dx / r.width) * vb[2];
|
|
3610
3944
|
vb[1] -= (dy / r.height) * vb[3];
|
|
3945
|
+
vbAtFit = false;
|
|
3611
3946
|
px = e.clientX;
|
|
3612
3947
|
py = e.clientY;
|
|
3613
3948
|
applyVB();
|
|
@@ -4211,7 +4546,8 @@ function renderPreconditionError(body) {
|
|
|
4211
4546
|
}
|
|
4212
4547
|
|
|
4213
4548
|
// Fetch the current view (source graph, or the picked env's live overlay).
|
|
4214
|
-
|
|
4549
|
+
const load = createRefreshQueue(loadOnce);
|
|
4550
|
+
async function loadOnce(opts = {}, isCurrent = () => true) {
|
|
4215
4551
|
const meta = document.getElementById("meta");
|
|
4216
4552
|
// A background settle re-pull (post-apply) shouldn't flash the meta/overlay.
|
|
4217
4553
|
if (!opts.quiet) {
|
|
@@ -4230,6 +4566,14 @@ async function load(opts = {}) {
|
|
|
4230
4566
|
// cross-substrate entity overlay, which components never use.
|
|
4231
4567
|
q.set("components", "1");
|
|
4232
4568
|
if (view.env) q.set("env", view.env);
|
|
4569
|
+
} else if (view.stacks) {
|
|
4570
|
+
// The apply-order lens (#426): chant's cross-stack ordering, built fresh
|
|
4571
|
+
// for the same reason the ops lens is — it reads none of the entity
|
|
4572
|
+
// knobs. No env (it is computed at synthesis, with no substrate), no
|
|
4573
|
+
// detail, no radial. Tier rides along because a project's source can
|
|
4574
|
+
// branch on one, which can change which lexicons it partitions into.
|
|
4575
|
+
q = new URLSearchParams({ stacks: "1" });
|
|
4576
|
+
if (view.tier) q.set("tier", view.tier);
|
|
4233
4577
|
} else if (view.ops) {
|
|
4234
4578
|
// The ops lens (#284): the declared Ops, read from each Op's emitted
|
|
4235
4579
|
// op.json. Built fresh rather than layered onto the entity lenses,
|
|
@@ -4263,6 +4607,11 @@ async function load(opts = {}) {
|
|
|
4263
4607
|
} else if (view.env) {
|
|
4264
4608
|
endpoint = "/api/overlay";
|
|
4265
4609
|
q.set("env", view.env);
|
|
4610
|
+
// #422: draw the estate from source at once and recolour each member as
|
|
4611
|
+
// its own live read lands, instead of a blank canvas until the slowest
|
|
4612
|
+
// one finishes. The server only honours it for a composed estate, and
|
|
4613
|
+
// never in a static snapshot, where there is no stream to follow.
|
|
4614
|
+
if (!staticMode) q.set("progressive", "1");
|
|
4266
4615
|
// The runtime tier (#86) descends below the declaration boundary. It is
|
|
4267
4616
|
// live-only by nature — owner-referenced children exist in the cluster,
|
|
4268
4617
|
// never in your source — so it rides the overlay and means nothing
|
|
@@ -4294,6 +4643,7 @@ async function load(opts = {}) {
|
|
|
4294
4643
|
}
|
|
4295
4644
|
const res = await apiFetch(`${endpoint}?${q}`);
|
|
4296
4645
|
const body = await res.json();
|
|
4646
|
+
if (!isCurrent()) return; // a newer view/event is pending; do not paint the old result
|
|
4297
4647
|
if (!res.ok) {
|
|
4298
4648
|
// #72: a classified precondition failure (lint gate, not installed, a
|
|
4299
4649
|
// tier that needs credentials) gets the calmer entry/error card instead
|
|
@@ -4314,13 +4664,19 @@ async function load(opts = {}) {
|
|
|
4314
4664
|
autoZoomFallback = false;
|
|
4315
4665
|
applyZoom("resources");
|
|
4316
4666
|
renderStatusbar();
|
|
4317
|
-
return
|
|
4667
|
+
return loadOnce(opts, isCurrent);
|
|
4318
4668
|
}
|
|
4319
4669
|
autoZoomFallback = false;
|
|
4670
|
+
// #422: a progressive answer names the members it is still reading, and
|
|
4671
|
+
// the `member` stream above clears them one at a time. Any other answer
|
|
4672
|
+
// resets it, so a switch away from a progressive load never leaves a stale
|
|
4673
|
+
// "still reading" claim on screen.
|
|
4674
|
+
pendingMembers = body.meta && body.meta.mode === "progressive" ? [...(body.meta.pending || [])] : [];
|
|
4675
|
+
pendingTotal = pendingMembers.length;
|
|
4320
4676
|
render(body.ir, body.svg, body.meta);
|
|
4321
4677
|
} catch (err) {
|
|
4322
4678
|
// A background settle poll must not blow away a good graph on a transient error.
|
|
4323
|
-
if (!opts.quiet) {
|
|
4679
|
+
if (!opts.quiet && isCurrent()) {
|
|
4324
4680
|
// Text, never innerHTML — `err.message` embeds chant's own stderr, which
|
|
4325
4681
|
// is not ours to interpolate as markup (the sibling precondition card has
|
|
4326
4682
|
// said so since #72; this branch had been left behind).
|
|
@@ -4434,6 +4790,7 @@ async function initPickers() {
|
|
|
4434
4790
|
// honestly open. Both are read before the first load() below, so a non-chant
|
|
4435
4791
|
// estate never paints the components apology on its way to a picture.
|
|
4436
4792
|
runtimeCapable = !!info.runtimeCapable;
|
|
4793
|
+
stacksCapable = !!info.stacksCapable;
|
|
4437
4794
|
const boot = bootZoom(info.memberKinds);
|
|
4438
4795
|
if (boot !== "components") {
|
|
4439
4796
|
applyZoom(boot);
|
|
@@ -4460,20 +4817,39 @@ initPickers();
|
|
|
4460
4817
|
// No backend in a static export → no live event stream; a no-op keeps the
|
|
4461
4818
|
// `events.addEventListener(...)` wiring below harmless.
|
|
4462
4819
|
const events = staticMode ? { addEventListener() {} } : new EventSource("/api/events");
|
|
4463
|
-
//
|
|
4464
|
-
//
|
|
4465
|
-
//
|
|
4466
|
-
//
|
|
4467
|
-
|
|
4468
|
-
|
|
4469
|
-
|
|
4470
|
-
|
|
4471
|
-
|
|
4820
|
+
// One notification requests one refresh. Further notifications during a slow
|
|
4821
|
+
// read collapse to one follow-up; no wall-clock timers multiply estate scans.
|
|
4822
|
+
// #422: one frame per member as its live read settles. Payload-carrying, like
|
|
4823
|
+
// `apply` and `run` — the node set is already on screen, so this is id ->
|
|
4824
|
+
// status and the client repaints in place. Nothing is re-fetched: a refetch
|
|
4825
|
+
// here would re-run the whole estate read this stream exists to narrate.
|
|
4826
|
+
events.addEventListener("member", (e) => {
|
|
4827
|
+
let frame;
|
|
4828
|
+
try {
|
|
4829
|
+
frame = JSON.parse(e.data);
|
|
4830
|
+
} catch {
|
|
4831
|
+
return;
|
|
4832
|
+
}
|
|
4833
|
+
// The live pass itself failed. Say so on the now-line and stop claiming the
|
|
4834
|
+
// remaining members are still arriving, because nothing more is coming.
|
|
4835
|
+
if (frame.error) {
|
|
4836
|
+
pendingMembers = [];
|
|
4837
|
+
nowline(`the estate read failed — ${frame.error}`);
|
|
4838
|
+
renderStatusbar();
|
|
4839
|
+
return;
|
|
4840
|
+
}
|
|
4841
|
+
if (!lastGraphIr) return;
|
|
4842
|
+
const moved = applyMemberFrame(lastGraphIr, frame);
|
|
4843
|
+
pendingMembers = stillPending(pendingMembers, frame);
|
|
4844
|
+
if (moved.length) recolorNodesByCategory(lastGraphIr);
|
|
4845
|
+
markPendingCards(lastGraphIr);
|
|
4846
|
+
renderStatusbar();
|
|
4847
|
+
});
|
|
4848
|
+
|
|
4472
4849
|
events.addEventListener("changed", () => {
|
|
4473
4850
|
bulkDiffCache = null; // an op ran → per-node live state may have changed
|
|
4474
4851
|
load();
|
|
4475
4852
|
loadSubstrates(); // a bring-up (or any op) finished → re-detect readiness
|
|
4476
|
-
scheduleSettle();
|
|
4477
4853
|
});
|
|
4478
4854
|
|
|
4479
4855
|
// Substrate readiness (M5, #54): is each substrate the project needs actually
|
|
@@ -4610,11 +4986,19 @@ setInterval(() => {
|
|
|
4610
4986
|
// can't fire a second pull while one's in flight. Ref-counted — nested loads
|
|
4611
4987
|
// (graph + CI + resources) only lift the scrim when the last finishes.
|
|
4612
4988
|
let loadingDepth = 0;
|
|
4989
|
+
// #421: what the LAST read cost, shown while this one runs. The person watching
|
|
4990
|
+
// the scrim wants to know whether to wait or go away, and behold has never told
|
|
4991
|
+
// them. Fetched after a load settles rather than during one — asking the server
|
|
4992
|
+
// how slow it is while it is being slow helps nobody — so the line always
|
|
4993
|
+
// describes the previous read and never guesses at this one.
|
|
4994
|
+
let lastReadCost = null;
|
|
4613
4995
|
function showLoading(msg) {
|
|
4614
4996
|
loadingDepth++;
|
|
4615
4997
|
const o = document.getElementById("loading-overlay");
|
|
4616
4998
|
if (!o) return;
|
|
4617
4999
|
document.getElementById("loading-msg").textContent = msg || "loading…";
|
|
5000
|
+
const cost = document.getElementById("loading-cost");
|
|
5001
|
+
if (cost) cost.textContent = lastReadCost || "";
|
|
4618
5002
|
o.hidden = false;
|
|
4619
5003
|
}
|
|
4620
5004
|
function hideLoading() {
|
|
@@ -4622,6 +5006,27 @@ function hideLoading() {
|
|
|
4622
5006
|
if (loadingDepth > 0) return;
|
|
4623
5007
|
const o = document.getElementById("loading-overlay");
|
|
4624
5008
|
if (o) o.hidden = true;
|
|
5009
|
+
void refreshReadCost();
|
|
5010
|
+
}
|
|
5011
|
+
|
|
5012
|
+
// One cheap call on counters, never on the read path. `?reads=1` is what makes
|
|
5013
|
+
// that true: the bare route also runs the full doctor diagnosis, whose substrate
|
|
5014
|
+
// probes spawn docker/gh/helm/k3d with no timeout, and this runs after every
|
|
5015
|
+
// load settles. A cost line is never worth breaking a load over, so every
|
|
5016
|
+
// failure here leaves the previous line standing.
|
|
5017
|
+
//
|
|
5018
|
+
// A static export has no backend to ask and no reads to report, so it does not
|
|
5019
|
+
// ask. Without this guard the bundle logs a 404 on every page load and relies on
|
|
5020
|
+
// the fetch failing to stay silent, which is an outcome rather than a rule.
|
|
5021
|
+
async function refreshReadCost() {
|
|
5022
|
+
if (staticMode) return;
|
|
5023
|
+
try {
|
|
5024
|
+
const res = await fetch("/api/doctor?reads=1");
|
|
5025
|
+
if (!res.ok) return;
|
|
5026
|
+
lastReadCost = readCostLine((await res.json()).reads);
|
|
5027
|
+
} catch {
|
|
5028
|
+
/* no line rather than a wrong one */
|
|
5029
|
+
}
|
|
4625
5030
|
}
|
|
4626
5031
|
|
|
4627
5032
|
// #259: the op stream is chant's own stdout, and an Op that reports as JSON
|