@intentius/behold 0.2.2

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/web/app.js ADDED
@@ -0,0 +1,2352 @@
1
+ // behold SPA. Fetches the read-only graph from the server — chant's IR + pinhole's
2
+ // rendered SVG — inlines the SVG and wires click-inspect. The visual is pinhole's
3
+ // mature painter (themes, icons, `_status` drift colouring); behold owns the data,
4
+ // the inspect panel, and (later) the lanes + delegated actions.
5
+
6
+ // Ghostty colour themes (#62): apply the persisted/default theme's tokens as CSS vars
7
+ // before first paint (so the whole graph + chrome recolour from one source), then mount
8
+ // the theme picker into the header's #pickers slot.
9
+ import { initTheme, mountThemePicker, readableOn, colorForCategory, onThemeChange, getTokens } from "./theme.js";
10
+ initTheme();
11
+ mountThemePicker(document.getElementById("pickers"));
12
+
13
+ // Colour node fills by category/kind using the theme's FULL palette (spicypath-style, so the
14
+ // graph shows the theme's many colours), while pinhole's drift stays on the bar/stroke. Node
15
+ // labels/icons get readable ink (black/white) on the category fill. Re-runs on theme switch,
16
+ // since the categorical hues come from the active theme's palette.
17
+ let lastGraphIr = null;
18
+ function recolorNodesByCategory(ir) {
19
+ if (ir) lastGraphIr = ir;
20
+ const graphIr = ir || lastGraphIr;
21
+ const svg = document.querySelector("#graph svg");
22
+ if (!svg || !graphIr) return;
23
+ const kindOf = new Map(graphIr.nodes.map((n) => [n.id, n.kind || n.lexicon || "node"]));
24
+ for (const g of svg.querySelectorAll("[data-node-id]")) {
25
+ const kind = kindOf.get(g.getAttribute("data-node-id"));
26
+ if (!kind) continue;
27
+ const cat = colorForCategory(kind), ink = readableOn(cat);
28
+ // Classify each element ONCE by the pinhole token it rode on (data-cat role), then apply
29
+ // the role's colour on every pass. This is what makes it recolour on theme switch: after
30
+ // the first pass the fill is a hex (not a --pin-* var), so we must key off the marker, not
31
+ // the token. Roles: fill=card background (→ category hue), bg=foreignObject background,
32
+ // inkf/inks=label/icon (→ readable ink). The drift bar (--pin-*Bar) is never classified.
33
+ for (const el of g.querySelectorAll("*")) {
34
+ let role = el.getAttribute("data-cat");
35
+ if (!role) {
36
+ const f = el.getAttribute("fill") || "", s = el.getAttribute("stroke") || "", st = el.getAttribute("style") || "";
37
+ if (/--pin-\w+Fill\b/.test(f)) role = "fill";
38
+ else if (/--pin-\w+Fill\b/.test(st)) role = "bg";
39
+ else if (/--pin-text/.test(f)) role = "inkf";
40
+ else if (/--pin-text/.test(s)) role = "inks";
41
+ if (role) el.setAttribute("data-cat", role);
42
+ }
43
+ if (role === "fill") el.setAttribute("fill", cat);
44
+ else if (role === "bg") el.setAttribute("style", (el.getAttribute("style") || "").replace(/background:[^;]*/, "background:" + cat));
45
+ else if (role === "inkf") el.setAttribute("fill", ink);
46
+ else if (role === "inks") el.setAttribute("stroke", ink);
47
+ }
48
+ }
49
+ // Runtime children get their bar coloured here (#144): pinhole's status
50
+ // palette has no `runtime` token, so chant's `_status: runtime` falls through
51
+ // to the neutral bar and a Pod reads exactly like an unobserved node. The bar
52
+ // is the 4px rect pinhole draws second inside each node group.
53
+ const statusOf = new Map(graphIr.nodes.map((n) => [n.id, n.attrs && n.attrs._status]));
54
+ for (const g of svg.querySelectorAll("[data-node-id]")) {
55
+ if (statusOf.get(g.getAttribute("data-node-id")) !== "runtime") continue;
56
+ const bar = g.querySelector('rect[width="4"]');
57
+ if (bar) bar.setAttribute("fill", "var(--runtime)");
58
+ }
59
+ }
60
+ onThemeChange(() => recolorNodesByCategory());
61
+
62
+ // Static-export mode (`behold export`): the SPA runs off a pre-captured bundle
63
+ // with no backend. Detect the flag the export injects, load its manifest, and
64
+ // replay every read from `snapshots/` — the graph, zoom dial, radial, inspect,
65
+ // and env/tier pickers all work; live observe + all writes are off.
66
+ const staticMode = !!window.__BEHOLD_STATIC__;
67
+ let manifest = null;
68
+ if (staticMode) {
69
+ try {
70
+ manifest = await fetch("./manifest.json").then((r) => r.json());
71
+ } catch {
72
+ /* no manifest → apiFetch falls back to a not-captured error */
73
+ }
74
+ }
75
+
76
+ /** Canonical key for a read URL — path + the lens params (whitelisted, sorted)
77
+ * that select a distinct snapshot. MUST match src/export.ts `canonicalKey`. */
78
+ const LENS_PARAMS = ["components", "detail", "env", "logical", "radial", "runtime", "tier"];
79
+ function canonicalKey(path, params) {
80
+ // Components + logical views ignore detail/radial — drop them so they match
81
+ // the single captured snapshot (MUST match src/export.ts).
82
+ const flat = params.get("components") === "1" || params.get("logical") === "1";
83
+ const q = LENS_PARAMS.filter((k) => params.has(k) && !(flat && (k === "detail" || k === "radial")))
84
+ .map((k) => `${k}=${params.get(k)}`)
85
+ .join("&");
86
+ return q ? `${path}?${q}` : path;
87
+ }
88
+
89
+ /** Fetch a read endpoint — live `fetch` normally; in static mode, resolve the
90
+ * canonical key against the manifest and load the captured snapshot instead. */
91
+ function apiFetch(url) {
92
+ if (!staticMode) return fetch(url);
93
+ const u = new URL(url, location.origin);
94
+ const key = canonicalKey(u.pathname, u.searchParams);
95
+ const file = manifest && manifest.keyToFile[key];
96
+ if (!file) return Promise.resolve(new Response(JSON.stringify({ error: `not in this static export: ${key}` }), { status: 404, headers: { "content-type": "application/json" } }));
97
+ return fetch("./" + file);
98
+ }
99
+
100
+ // `neutral` (chant#1168, #1089): a declared node chant could not read live
101
+ // state for — distinct from `accent`/"pending" (provider confirmed it
102
+ // absent). Additive: a chant predating #1168 never emits `neutral` here.
103
+ // `runtime` (chant#1180, #1077): a live, undeclared node whose owner chain
104
+ // reaches a declared entity — expected runtime (a Pod its Deployment
105
+ // created), never foreign and never drift. Additive the same way.
106
+ const STATUS_LABEL = { good: "managed", warn: "foreign", accent: "pending", neutral: "unobserved", runtime: "runtime child" };
107
+ // M1.1 (#57), palette hardened M2 (#54): the component-DAG live-status join
108
+ // paints the same `_status` vocabulary (good/warn/accent/neutral) but with
109
+ // different meaning — a stack-health reading, not "managed" — so the inspect
110
+ // panel picks this label set for a node that carries `_liveStatus` (see
111
+ // joinComponentStatus, src/component-status.ts). `accent` (pinhole's blue
112
+ // paint — there's no separate amber token) reads as "in progress" here,
113
+ // distinct from the entity overlay's "pending" meaning for the same colour.
114
+ const COMPONENT_STATUS_LABEL = { good: "healthy", accent: "in progress", warn: "rollback / failed", neutral: "not deployed" };
115
+ // behold#146: the artifact-presence join (src/helm-artifacts.ts) paints
116
+ // Helm::Chart nodes with the same palette but the 2-way ARTIFACT vocabulary —
117
+ // a release is "installed", never "managed": chant's docs are explicit that
118
+ // artifacts have no declared axis to classify against. `_artifact`'s presence
119
+ // (or an artifact-flavoured `_unobserved`) picks this label set.
120
+ const ARTIFACT_STATUS_LABEL = { good: "installed", warn: "installed, not healthy", accent: "not installed", neutral: "unobserved" };
121
+
122
+ // A declared attribute value may be a cross-resource reference ({$ref:"x.y"}) —
123
+ // the "static infra refs" — rather than a concrete value. Render those readably;
124
+ // concrete values (present once a resource is provisioned) show as-is.
125
+ function fmtValue(v) {
126
+ if (v && typeof v === "object") {
127
+ if (typeof v.$ref === "string") return "→ " + v.$ref;
128
+ return JSON.stringify(v);
129
+ }
130
+ return String(v);
131
+ }
132
+
133
+ function inspect(node) {
134
+ const panel = document.getElementById("inspect-body");
135
+ panel.innerHTML = "<h2>inspect</h2>";
136
+ panel.dataset.node = node.id; // so async sections can verify this node is still shown
137
+ const section = (title) => {
138
+ const h = document.createElement("h3");
139
+ h.textContent = title;
140
+ h.style.cssText = "font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);margin:14px 0 6px";
141
+ panel.appendChild(h);
142
+ const dl = document.createElement("dl");
143
+ panel.appendChild(dl);
144
+ return (k, v) => {
145
+ const dt = document.createElement("dt");
146
+ dt.textContent = k;
147
+ const dd = document.createElement("dd");
148
+ dd.textContent = v;
149
+ dl.append(dt, dd);
150
+ };
151
+ };
152
+
153
+ const id = section("identity");
154
+ id("id", node.id);
155
+ id("kind", node.kind);
156
+ id("lexicon", node.lexicon);
157
+ const st = node.attrs && node.attrs._status;
158
+ // Component-DAG live status (#57): `_liveStatus` is only ever set by
159
+ // joinComponentStatus, so its presence picks the component-status label set
160
+ // over the entity overlay's managed/foreign/pending.
161
+ const liveStatus = node.attrs && node.attrs._liveStatus;
162
+ // behold#146: a Helm chart's status is artifact presence, not management —
163
+ // the join sets `_artifact` on a match, and every Helm::Chart in an overlay
164
+ // went through it, so the kind alone is the reliable picker.
165
+ const isArtifact = node.kind === "Helm::Chart";
166
+ if (st) id("status", (isArtifact ? ARTIFACT_STATUS_LABEL[st] : liveStatus ? COMPONENT_STATUS_LABEL[st] : STATUS_LABEL[st]) || st);
167
+ if (node.attrs && node.attrs._artifact) {
168
+ const a = node.attrs._artifact;
169
+ if (a.release) id("release", a.release);
170
+ if (a.status) id("release status", a.status);
171
+ if (a.revision) id("revision", a.revision);
172
+ if (a.chart) id("chart", a.chart);
173
+ }
174
+ // chant#1168 (#1089): `_unobserved` carries WHY chant couldn't read this
175
+ // entity's live state — only ever set alongside the entity overlay's
176
+ // `_status: "neutral"` (never the component-status join's own, unrelated
177
+ // `neutral` = "not deployed"), so it's safe to show unconditionally here.
178
+ if (node.attrs && node.attrs._unobserved) id("unobserved reason", node.attrs._unobserved);
179
+ // chant#1180 (#1077): `runtimeOwner` is a first-class IR field (like
180
+ // `ownership`/`physicalId`), not an `attrs` tag — the declared entity this
181
+ // live, undeclared node's owner chain resolves to. Shown immediately from
182
+ // the graph node itself, no diff fetch needed.
183
+ if (node.runtimeOwner) id("runtime owner", node.runtimeOwner);
184
+ if (node.sourceLoc && node.sourceLoc.file) id("source", node.sourceLoc.file);
185
+
186
+ // Containment hierarchy: a resource shows its parent chain UP (composite →
187
+ // component); a collapsed composite (detail 1: `attrs.members` is a count)
188
+ // shows its member list DOWN. The parent chain is on the node itself
189
+ // (compositeInstance/compositeParent + the src/<component>/ path) — no fetch.
190
+ const sp = ((node.sourceLoc && node.sourceLoc.file) || "").split("/");
191
+ const component = sp[0] === "src" && sp[1] === "examples" ? "examples/" + (sp[2] || "") : sp[0] === "src" && sp.length >= 3 ? sp[1] : null;
192
+ const isComposite = node.attrs && typeof node.attrs.members === "number";
193
+ if (!isComposite && (node.compositeInstance || (component && node.kind !== "Component"))) {
194
+ const bt = section("belongs to");
195
+ if (component) bt("component", component);
196
+ if (node.compositeInstance) bt("composite", node.compositeParent ? `${node.compositeInstance} · ${node.compositeParent}` : node.compositeInstance);
197
+ }
198
+ if (isComposite) {
199
+ const h = document.createElement("h3");
200
+ h.textContent = `members · ${node.attrs.members}`;
201
+ h.style.cssText = "font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);margin:14px 0 6px";
202
+ panel.appendChild(h);
203
+ const loading = document.createElement("p");
204
+ loading.style.color = "var(--muted)";
205
+ loading.textContent = "loading…";
206
+ panel.appendChild(loading);
207
+ const dl = document.createElement("dl");
208
+ panel.appendChild(dl);
209
+ const forId = node.id;
210
+ getCompositeMembers(node.id).then((members) => {
211
+ if (panel.dataset.node !== forId) return; // selection changed while loading
212
+ loading.remove();
213
+ if (!members.length) {
214
+ loading.textContent = "(no members found)";
215
+ panel.insertBefore(loading, dl);
216
+ return;
217
+ }
218
+ for (const m of members) {
219
+ const dt = document.createElement("dt");
220
+ dt.textContent = m.kind;
221
+ const dd = document.createElement("dd");
222
+ dd.textContent = m.id;
223
+ dl.append(dt, dd);
224
+ }
225
+ });
226
+ }
227
+
228
+ // Live state: what chant observed in the cloud. Only managed (provisioned)
229
+ // nodes carry it — pending nodes have none because they aren't deployed yet.
230
+ if (node.physicalId || node.ownership) {
231
+ const live = section("live");
232
+ if (node.physicalId) live("physical id", node.physicalId);
233
+ if (node.ownership) live("ownership", node.ownership);
234
+ } else if (liveStatus) {
235
+ // The colour alone doesn't carry chant's verdict or its reasoning — spell
236
+ // both out here (never rely on the node's colour alone, #57 accessibility
237
+ // note): reconciliation is the raw `chant components status` verdict
238
+ // (reconciled/unrecorded/stale/drifted/unknown), detail is chant's own
239
+ // human-readable explanation. M2 (#54, chant 0.18.29): when present, the
240
+ // raw stack — the actual signal the palette painted from — backs it up
241
+ // with the provider-native fact (e.g. loom-db's UPDATE_ROLLBACK_COMPLETE).
242
+ const live = section("live status");
243
+ live("reconciliation", liveStatus.reconciliation);
244
+ if (liveStatus.detail) live("detail", liveStatus.detail);
245
+ if (liveStatus.stack) {
246
+ live("stack", liveStatus.stack.name);
247
+ if (liveStatus.stack.status) live("stack status", liveStatus.stack.status);
248
+ if (liveStatus.stack.healthy !== undefined) live("healthy", String(liveStatus.stack.healthy));
249
+ }
250
+ } else if (st === "accent") {
251
+ const live = section("live");
252
+ live("", "not provisioned yet (pending) — no live state");
253
+ } else if (st === "runtime") {
254
+ // chant#1180 (#1077): a runtime child rarely carries physicalId/ownership
255
+ // today (it's not chant-owned in the marker sense), so make sure
256
+ // something explains the node rather than showing an empty "live" gap.
257
+ const live = section("live");
258
+ live("", `runtime child — owned by ${node.runtimeOwner || "its declared parent"}, not itself declared`);
259
+ }
260
+
261
+ // Declared attributes — the source-of-truth values / cross-resource refs.
262
+ const attrKeys = Object.keys(node.attrs || {}).filter((k) => !k.startsWith("_"));
263
+ if (attrKeys.length) {
264
+ const decl = section("declared");
265
+ for (const k of attrKeys) decl(k, fmtValue(node.attrs[k]));
266
+ }
267
+
268
+ // CI projection facet (M1.2, #56/#58): loomster's GitLab CI is the SAME
269
+ // component DAG projected — waves = stages, components = jobs, `dependsOn` =
270
+ // `needs:`. Read one way it's the deployment, read the other it's the
271
+ // pipeline. A read-only per-node detail hanging off the component by name —
272
+ // no topology change. Only present for component nodes, and only once the
273
+ // facet loaded (component-DAG mode; see loadCi()).
274
+ const job = node.kind === "Component" ? ciByComponent.get(node.id) : undefined;
275
+ if (job) {
276
+ // The heading names the forge the pipeline was actually generated for
277
+ // (#164) — it used to say GitLab whatever the project targeted.
278
+ const ci = section(`CI (${{ gitlab: "GitLab", github: "GitHub Actions", forgejo: "Forgejo" }[ciForge] || ciForge || "pipeline"})`);
279
+ ci("stage", job.stage);
280
+ ci("needs", job.needs.length ? job.needs.join(", ") : "(none)");
281
+ ci("runs", job.script.length ? job.script.join(" && ") : `chant run --components ${node.id}`);
282
+ // The last pipeline run of this session, when one touched this component
283
+ // (#164) — the server joins it as `_ciJob`/`ci` on the node.
284
+ if (node.attrs && node.attrs._ciJob) ci("last run", `${node.attrs._ciJob}: ${node.attrs.ci}`);
285
+ }
286
+
287
+ // Resources facet (#59 unify) — a best-effort slice of the DoD's "its
288
+ // stack, and its resources": the AWS resources declared under this
289
+ // component's own source directory (see loadResources(); src/server.ts
290
+ // `/api/resources` documents why this is resources-by-source-location, not
291
+ // a literal CFN stack lookup, and a pre-existing chant gap — verified
292
+ // against loomster/Floci — that leaves physicalId/ownership usually empty
293
+ // even in live mode: kind/id are always the real declared shape; treat
294
+ // physicalId as a bonus when chant happens to supply it, not a given).
295
+ const resources = node.kind === "Component" ? resourcesByComponent[node.id] : undefined;
296
+ if (resources) {
297
+ const res = section("resources");
298
+ if (!resources.length) {
299
+ res("", "(none found under this component's source directory)");
300
+ } else {
301
+ for (const r of resources) {
302
+ res(r.kind, r.physicalId ? `${r.id} (${r.physicalId})` : r.id);
303
+ }
304
+ }
305
+ }
306
+
307
+ // A foreign node on a live-import substrate can be pulled into typed source:
308
+ // Adopt triggers the ReconcileOp (cloud → code), which opens a reviewable PR.
309
+ // behold never writes source — a human merges. Managed/pending nodes and
310
+ // substrates with no live-import path show nothing.
311
+ if (adoptable(node) && !previewMode) {
312
+ const b = button("Adopt", "", () => runOp(adopt.reconcile.name));
313
+ b.title = `Reconcile ${node.id} into source via ${adopt.reconcile.name} (opens a PR)`;
314
+ const wrap = document.createElement("p");
315
+ wrap.style.marginTop = "12px";
316
+ wrap.appendChild(b);
317
+ panel.appendChild(wrap);
318
+ }
319
+
320
+ // Live state for this node (#27/#30): a node that's already been observed
321
+ // (managed=good or foreign=warn — that's why it's coloured) auto-loads its
322
+ // observed state + drift, so a click shows it without a second click. Cached
323
+ // per node (loadNodeDiff) so re-clicks are instant. A live diff is a build +
324
+ // cloud query, so we only fire it for observed nodes, and never in static.
325
+ //
326
+ // chant#1168 (#1089): the entity overlay's `neutral` means "chant couldn't
327
+ // read this" — worth a diff fetch too, since `/api/diff`'s `unobserved`
328
+ // entries carry the richer reason/detail this panel can show (see
329
+ // renderDiff). Guarded to `!liveStatus` so this doesn't also fire for the
330
+ // component-status join's unrelated `neutral` ("not deployed" — no entity
331
+ // name for `/api/diff` to match against).
332
+ // chant#1180 (#1077): `runtime` is worth a diff fetch too — `/api/diff`'s
333
+ // `observed` map already carries a runtime child (chant's describeResources
334
+ // reports it the same as any other resource it found), and its own
335
+ // `fieldDrift` may apply too on a substrate with per-field ownership.
336
+ const observed = st === "good" || st === "warn" || st === "runtime" || (st === "neutral" && !liveStatus);
337
+ if (view.env && observed) {
338
+ const forId = node.id;
339
+ const loading = document.createElement("p");
340
+ loading.style.cssText = "color:var(--muted);margin-top:12px";
341
+ loading.textContent = "loading live state…";
342
+ panel.appendChild(loading);
343
+ loadNodeDiff(node.id).then((j) => {
344
+ if (panel.dataset.node !== forId) return; // reselected while loading
345
+ loading.remove();
346
+ if (!j) {
347
+ const p = document.createElement("p");
348
+ p.style.cssText = "color:var(--muted);margin-top:12px";
349
+ p.textContent = "live state unavailable";
350
+ panel.appendChild(p);
351
+ return;
352
+ }
353
+ renderObserved(panel, j.observed, j.health); // #30 observed state + #26 health
354
+ renderDiff(panel, j.diff); // #27 — drift since snapshot
355
+ renderFieldDrift(panel, j.fieldDrift); // #87 — field-level (per-manager) drift
356
+ });
357
+ }
358
+ }
359
+
360
+ // Bulk per-node live state — ONE `chant lifecycle diff --live` sliced for every
361
+ // node (/api/diff), fetched once per env and cached, so inspecting an observed
362
+ // node is instant (no per-node query). Via apiFetch, so a static export replays
363
+ // the captured snapshot. Cache is per env; cleared on lens change / after an op.
364
+ let bulkDiffCache = null; // { env, nodes: { <id>: { observed, diff, health } } }
365
+ async function loadNodeDiff(id) {
366
+ if (!bulkDiffCache || bulkDiffCache.env !== view.env) {
367
+ bulkDiffCache = null;
368
+ try {
369
+ const res = await apiFetch(`/api/diff?env=${encodeURIComponent(view.env)}`);
370
+ if (res.ok) bulkDiffCache = await res.json();
371
+ } catch {
372
+ /* leave null → empty below */
373
+ }
374
+ if (!bulkDiffCache || !bulkDiffCache.nodes) bulkDiffCache = { env: view.env, nodes: {} };
375
+ }
376
+ return bulkDiffCache.nodes[id] || null;
377
+ }
378
+
379
+ const HEALTH_COLOR = {
380
+ healthy: "var(--managed)",
381
+ progressing: "var(--pending)",
382
+ degraded: "var(--degraded)",
383
+ unknown: "var(--muted)",
384
+ };
385
+
386
+ // Render a node's observed live state (#30) + health verdict (#26).
387
+ function renderObserved(panel, o, health) {
388
+ if (!o) return; // pending/foreign nodes have no observed record in the diff
389
+ const h = document.createElement("h3");
390
+ h.textContent = "observed";
391
+ h.style.cssText = "font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);margin:14px 0 6px";
392
+ panel.appendChild(h);
393
+ const dl = document.createElement("dl");
394
+ const add = (k, v, color) => {
395
+ const dt = document.createElement("dt");
396
+ dt.textContent = k;
397
+ const dd = document.createElement("dd");
398
+ dd.textContent = v;
399
+ if (color) dd.style.color = color;
400
+ dl.append(dt, dd);
401
+ };
402
+ // Health first — the "is it well?" verdict, distinct from drift. Absent when
403
+ // the substrate reports no status (not fabricated).
404
+ if (health && health !== "unknown") add("health", health, HEALTH_COLOR[health]);
405
+ if (o.type) add("type", o.type);
406
+ if (o.status) add("status", o.status, HEALTH_COLOR[health] || undefined);
407
+ if (o.physicalId) add("physical id", o.physicalId);
408
+ if (o.ownership) add("ownership", o.ownership);
409
+ if (o.lastUpdated) add("last updated", o.lastUpdated);
410
+ // What the object's own controller says is wrong with it (#86, chant#1401).
411
+ // Placed with health and status rather than among the attributes below,
412
+ // because it is the line that says what to DO: `Unschedulable` is the reason,
413
+ // "0/3 nodes are available: 1 node(s) had untolerated taint" is the answer.
414
+ // chant only sends conditions that are NOT in their happy state, so anything
415
+ // here is worth reading; the field is absent on every substrate that records
416
+ // none.
417
+ const conditions = o.attributes?.conditions;
418
+ if (Array.isArray(conditions)) {
419
+ for (const c of conditions) add("condition", String(c), "var(--degraded)");
420
+ }
421
+ for (const [k, v] of Object.entries(o.attributes || {})) {
422
+ if (k === "conditions") continue; // rendered above, one line each
423
+ add(k, typeof v === "object" ? JSON.stringify(v) : String(v));
424
+ }
425
+ panel.appendChild(dl);
426
+ }
427
+
428
+ const DIFF_LABEL = {
429
+ drifted: "drifted since snapshot",
430
+ missing: "declared, not in cloud",
431
+ orphan: "in cloud, not declared",
432
+ disappeared: "gone since snapshot",
433
+ newlyObserved: "live — no snapshot baseline",
434
+ unchanged: "in sync",
435
+ // chant#1168 (#1089): its own category — chant couldn't read this entity's
436
+ // live state at all, so this is neither drift nor a confirmed absence.
437
+ unobserved: "chant could not read live state",
438
+ // chant#1180 (#1077): its own category too — expected runtime, never drift
439
+ // and never orphan/adopt.
440
+ runtime: "expected runtime child",
441
+ };
442
+
443
+ // Render a node's live-diff into the inspect panel (#27).
444
+ function renderDiff(panel, diff) {
445
+ const h = document.createElement("h3");
446
+ h.textContent = "drift";
447
+ h.style.cssText = "font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);margin:14px 0 6px";
448
+ panel.appendChild(h);
449
+ if (!diff) {
450
+ const p = document.createElement("p");
451
+ p.style.color = "var(--muted)";
452
+ p.textContent = "not present in the live diff";
453
+ panel.appendChild(p);
454
+ return;
455
+ }
456
+ const cat = document.createElement("p");
457
+ cat.textContent = DIFF_LABEL[diff.category] || diff.category;
458
+ panel.appendChild(cat);
459
+ // chant#1168 (#1089): show WHY chant couldn't look, instead of the
460
+ // "no field changes" text below — that phrasing implies a comparison ran
461
+ // and found nothing, which is exactly the wrong read for a hole in the
462
+ // observation.
463
+ if (diff.category === "unobserved") {
464
+ const p = document.createElement("p");
465
+ p.style.color = "var(--muted)";
466
+ p.textContent = diff.unobservedReason
467
+ ? `reason: ${diff.unobservedReason}${diff.unobservedDetail ? " — " + diff.unobservedDetail : ""}`
468
+ : "chant did not report why";
469
+ panel.appendChild(p);
470
+ return;
471
+ }
472
+ // chant#1180 (#1077): never call this "drift" — it's the runtime doing its
473
+ // job (a Pod its Deployment created); deleting it just gets it recreated.
474
+ if (diff.category === "runtime") {
475
+ const p = document.createElement("p");
476
+ p.style.color = "var(--runtime)";
477
+ p.textContent = diff.runtimeOwner
478
+ ? `owned by ${diff.runtimeOwner} — created by its controller, not drift`
479
+ : "created by its controller, not drift";
480
+ panel.appendChild(p);
481
+ return;
482
+ }
483
+ if (!diff.changes.length) {
484
+ const p = document.createElement("p");
485
+ p.style.color = "var(--muted)";
486
+ p.textContent =
487
+ diff.category === "newlyObserved"
488
+ ? "no field diff yet — take a snapshot (chant lifecycle snapshot) to track changes"
489
+ : "no field changes";
490
+ panel.appendChild(p);
491
+ return;
492
+ }
493
+ const dl = document.createElement("dl");
494
+ for (const ch of diff.changes) {
495
+ const dt = document.createElement("dt");
496
+ dt.textContent = ch.path;
497
+ const dd = document.createElement("dd");
498
+ dd.textContent = `${JSON.stringify(ch.oldValue)} → ${JSON.stringify(ch.newValue)}`;
499
+ dl.append(dt, dd);
500
+ }
501
+ panel.appendChild(dl);
502
+ }
503
+
504
+ // Field-level ownership colouring (#87, chant#1076/#1181): per-field drift
505
+ // derived from k8s managed-fields pruning — additive, only present when
506
+ // chant's `lifecycle diff --live --json` carried a `deep` section for this
507
+ // entity's lexicon (src/diff.ts's `nodeFieldDrift`). `null` on a substrate/
508
+ // chant with no deep reader — the whole-object `drift` section above is
509
+ // unaffected, per #87's fallback acceptance criterion.
510
+ //
511
+ // chant's wire contract doesn't carry a field-MANAGER name (which manager
512
+ // currently owns a path) — only whether the path is `changed` (a real
513
+ // deviation from what chant declared, whether purely chant-owned or
514
+ // contested by a foreign manager — chant's own pruning already drops a
515
+ // confidently foreign-owned, undeclared field before this ever runs),
516
+ // `undeclared` (present live, chant's source says nothing about it — the
517
+ // closest available signal to "foreign"), or `absent` (declared, not present
518
+ // live). These three are the closest honest mapping to "chant-owned vs
519
+ // foreign vs contested" the current wire data supports.
520
+ const FIELD_KIND_LABEL = { changed: "drifted", undeclared: "foreign (not declared)", absent: "declared, not present live" };
521
+ const FIELD_KIND_COLOR = { changed: "var(--degraded)", undeclared: "var(--foreign)", absent: "var(--muted)" };
522
+
523
+ function renderFieldDrift(panel, fieldDrift) {
524
+ if (!fieldDrift) return; // no lexicon in this diff ran a deep (field-level) read
525
+ if (!fieldDrift.drifted.length && !fieldDrift.accepted.length) return; // deep ran, nothing to report for this node
526
+ const h = document.createElement("h3");
527
+ h.textContent = "field ownership";
528
+ h.style.cssText = "font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);margin:14px 0 6px";
529
+ panel.appendChild(h);
530
+ const dl = document.createElement("dl");
531
+ for (const ch of fieldDrift.drifted) {
532
+ const dt = document.createElement("dt");
533
+ dt.textContent = ch.path;
534
+ dt.style.color = FIELD_KIND_COLOR[ch.kind] || "";
535
+ const dd = document.createElement("dd");
536
+ // The owning manager (#87, chant#1189) leads, because it is the part that
537
+ // decides what to do about the field: `hpa-controller` holding
538
+ // `spec.replicas` is a controller doing its job; `kubectl-client-side-apply`
539
+ // holding it is somebody editing around the pipeline. Both are `changed`.
540
+ // Absent on every substrate but k8s, where the line reads as it always did.
541
+ const owned = ch.owner ? `owned by ${ch.owner} — ` : "";
542
+ dd.textContent = `${owned}${FIELD_KIND_LABEL[ch.kind] || ch.kind} — declared: ${JSON.stringify(ch.declared)} · live: ${JSON.stringify(ch.live)}`;
543
+ dl.append(dt, dd);
544
+ }
545
+ for (const ch of fieldDrift.accepted) {
546
+ const dt = document.createElement("dt");
547
+ dt.textContent = ch.path;
548
+ dt.style.color = "var(--muted)";
549
+ const dd = document.createElement("dd");
550
+ dd.textContent = `accepted deviation — baseline: ${JSON.stringify(ch.baseline)} · live: ${JSON.stringify(ch.live)}`;
551
+ dl.append(dt, dd);
552
+ }
553
+ panel.appendChild(dl);
554
+ }
555
+
556
+ function wire(ir) {
557
+ const byId = new Map(ir.nodes.map((n) => [n.id, n]));
558
+ const host = document.getElementById("graph");
559
+ for (const g of host.querySelectorAll("[data-node-id]")) {
560
+ const node = byId.get(g.getAttribute("data-node-id"));
561
+ if (!node) continue;
562
+ g.style.cursor = "pointer";
563
+ g.addEventListener("click", () => {
564
+ if (panMoved) return; // a drag-pan ended here — don't also select
565
+ host.querySelectorAll(".sel").forEach((n) => n.classList.remove("sel"));
566
+ g.classList.add("sel");
567
+ inspect(node);
568
+ });
569
+ }
570
+ }
571
+
572
+ // View state driven by the ⌘K palette's lens commands (#17, moved off the
573
+ // header pickers in #73 — see paletteCommands()). env=null → the declared
574
+ // source graph; env set → the live overlay for that env (needs cloud creds).
575
+ // detail is chant's --detail tier. components (#56) toggles the component-DAG
576
+ // projection (nodes=components, wave-laned, dependsOn edges) in place of the
577
+ // AWS entity graph. With an env picked too, components mode gets its own live
578
+ // status join (#57, per-component AWS reconciliation) instead of the entity
579
+ // overlay — see load()'s endpoint choice. tier/target (M2, #54) are the two
580
+ // new lenses: a picked tier overrides LOOM_TIER, a picked target overrides
581
+ // AWS_ENDPOINT_URL, for every chant shell-out this page's fetches trigger (see
582
+ // lensParams()). stack (#76, follow-up to #71) is a THIRD kind of lens: a
583
+ // multi-stack project's `chant.config.ts` `stacks[]` names an independently-
584
+ // deployed source tree — picking one re-points the graph at that stack's
585
+ // source (chant.ts's `graphPath`), not an env override like tier/target.
586
+ // null on a project that declares no `stacks[]` at all — the picker (and the
587
+ // status strip's stack tag) then never renders. Every fetch reads this, so
588
+ // the `changed` SSE re-pull and a palette lens change go through the same path.
589
+ const view = { env: null, detail: 2, components: true, logical: false, runtime: false, tier: null, target: null, stack: null, radial: false };
590
+
591
+ // v0.1.0 preview lock (set from /api/project in initActions): hides the git/PR
592
+ // write ops (Rollback, Sync, Adopt, Run ▾) — the server also 403s them. Local
593
+ // deploy (Apply all / dial), Reset, Bring up, Approve, and reads stay on.
594
+ let previewMode = false;
595
+
596
+ // The unified "zoom" control (one granularity axis, coarse → fine). Underlying
597
+ // state stays (components, detail); zoom is just the single knob the header
598
+ // exposes, mapping "components" → the wave/component view and composites/
599
+ // resources/attributes → the entity graph at detail 1/2/3. (detail 0 / per-
600
+ // lexicon "stacks" is dropped from the UI — niche; the API still accepts it.)
601
+ // "logical" (#63) is a re-projection, not a granularity stop like the others —
602
+ // a traditional AWS architecture diagram: nested VPC/subnet ⊃ component boxes.
603
+ // region/VPC/subnet boxes with topology nodes only — but it rides the same dial
604
+ // as the coarse "infrastructure overview" the way a traditional AWS diagram sits
605
+ // beside the resource views.
606
+ const ZOOM_OPTS = [
607
+ ["zoom: components", "components"],
608
+ ["zoom: logical", "logical"],
609
+ ["zoom: composites", "composites"],
610
+ ["zoom: resources", "resources"],
611
+ ["zoom: attributes", "attributes"],
612
+ // Below the declaration boundary (#86): the owner-referenced children the
613
+ // cluster maintains — the Pods under a Deployment. Its own stop rather than a
614
+ // detail level, because it is a different axis: every tier above shows what
615
+ // you declared, and this one shows what your declaration produced.
616
+ ["zoom: runtime", "runtime"],
617
+ ];
618
+ const ZOOM_DETAIL = { composites: 1, resources: 2, attributes: 3, runtime: 3 };
619
+ /** Current zoom value from (components, logical, detail). */
620
+ function zoomValue() {
621
+ if (view.components) return "components";
622
+ if (view.logical) return "logical";
623
+ if (view.runtime) return "runtime";
624
+ return { 1: "composites", 2: "resources", 3: "attributes" }[view.detail] ?? "resources";
625
+ }
626
+ /** Apply a zoom value back onto (components, logical, detail). */
627
+ function applyZoom(z) {
628
+ view.components = z === "components";
629
+ view.logical = z === "logical";
630
+ view.runtime = z === "runtime";
631
+ if (z !== "components" && z !== "logical") view.detail = ZOOM_DETAIL[z] ?? 2;
632
+ }
633
+
634
+ // #73 "hide controls, never state": zoom/env/tier/radial used to live in
635
+ // header <select>s; the pickers moved into the ⌘K palette (paletteCommands()
636
+ // below), but the CURRENT value must stay visible without opening it — this
637
+ // is the one place that reads. Called after every render() and once before
638
+ // the first load (initPickers()). stack (#76) joins the strip the same way —
639
+ // only ever truthy on a project that declares `stacks[]` (initPickers seeds
640
+ // `view.stack` from `info.stacks`; a project with none leaves it null), so a
641
+ // single-stack project's strip is unaffected.
642
+ // The zoom picker, back in the header as a control you can see and click.
643
+ //
644
+ // #73 moved the pickers into ⌘K and left the current value on the strip, on the
645
+ // principle "hide controls, never state". That reads well until someone opens
646
+ // behold for the first time: the strip says "zoom: components", it looks like a
647
+ // dropdown because it is a value next to other values, and clicking it does
648
+ // nothing — renderStatusbar() only ever sets textContent. The discoverable path
649
+ // to the single most useful control is a keyboard shortcut hinted by a "⌘K"
650
+ // glyph at the far end of the header.
651
+ //
652
+ // So zoom gets a real <select> and the palette keeps its entry. The other axes
653
+ // stay where #73 put them: zoom is the one you reach for constantly, and one
654
+ // visible control is not the header of selects that issue was about.
655
+ function renderZoomPicker() {
656
+ // #zoom-slot, top-left after the brand — not #pickers on the far right,
657
+ // where it sat beside the theme select and read as chrome rather than as the
658
+ // control. Falls back to #pickers so a static export built from older markup
659
+ // still gets a picker rather than none.
660
+ const slot = document.getElementById("zoom-slot") || document.getElementById("pickers");
661
+ if (!slot) return;
662
+ let sel = document.getElementById("zoom-picker");
663
+ if (!sel) {
664
+ sel = document.createElement("select");
665
+ sel.id = "zoom-picker";
666
+ sel.title = "Zoom — components, logical, composites, resources, attributes, runtime (also ⌘K)";
667
+ sel.addEventListener("change", () => {
668
+ applyZoom(sel.value);
669
+ renderStatusbar();
670
+ load();
671
+ });
672
+ // Before the theme picker, which mounts into the same slot at load.
673
+ slot.insertBefore(sel, slot.firstChild);
674
+ }
675
+ const current = zoomValue();
676
+ // Rebuilt each render because runtime is only meaningful with an env: it
677
+ // descends below the declaration boundary to owner-referenced children,
678
+ // which exist in a cluster and never in your source.
679
+ const opts = ZOOM_OPTS.filter(([, v]) => v !== "runtime" || view.env);
680
+ const want = opts.map(([label, v]) => `${v}:${label}`).join("|") + "@" + current;
681
+ if (sel.dataset.built !== want) {
682
+ sel.innerHTML = "";
683
+ for (const [label, v] of opts) {
684
+ const o = document.createElement("option");
685
+ o.value = v;
686
+ o.textContent = label;
687
+ if (v === current) o.selected = true;
688
+ sel.appendChild(o);
689
+ }
690
+ sel.dataset.built = want;
691
+ }
692
+ sel.value = current;
693
+ }
694
+
695
+ function renderStatusbar() {
696
+ renderZoomPicker();
697
+ const el = document.getElementById("statusbar");
698
+ if (!el) return;
699
+ // The strip looks clickable whether or not it is, so make it act like it:
700
+ // clicking opens the palette rather than doing nothing. env, stack and tier
701
+ // live only there, and this is the only affordance pointing at them.
702
+ if (!el.dataset.clickable) {
703
+ el.dataset.clickable = "1";
704
+ el.style.cursor = "pointer";
705
+ el.title = "env · stack · tier — click, or ⌘K, to change";
706
+ el.addEventListener("click", () => openPalette());
707
+ }
708
+ // No zoom here any more. #73 put the current zoom on the strip because the
709
+ // control had moved into the palette; with a real picker two slots along,
710
+ // the same value in both places reads as two pickers, one of which does not
711
+ // work — which is exactly how it was reported. The strip keeps the axes that
712
+ // still have no on-screen control.
713
+ const parts = [view.env ? `env: ${view.env}` : "env: (source)"];
714
+ if (view.stack) parts.push(`stack: ${view.stack}`);
715
+ if (axes.tier) parts.push(`tier: ${axes.tier}`);
716
+ if (view.radial && !view.components && !view.logical) parts.push("radial");
717
+ el.textContent = parts.join(" · ");
718
+ // #131: why this level rendered empty, or as the one below it. The server
719
+ // decides (src/zoom-notes.ts) — the SPA never infers it, so the note always
720
+ // describes the graph that actually came back. Appended rather than mixed
721
+ // into `parts` so it can carry its own emphasis without the zoom/env strip
722
+ // changing shape when there is nothing to say.
723
+ if (lastNote) {
724
+ const note = document.createElement("span");
725
+ note.className = "statusbar-note";
726
+ note.textContent = " — " + lastNote;
727
+ el.appendChild(note);
728
+ }
729
+ }
730
+
731
+ // The note from the last /api/graph or /api/overlay response (`meta.note`),
732
+ // or null. Set on every load so a level that stops degrading stops explaining
733
+ // itself.
734
+ let lastNote = null;
735
+
736
+ // The deploy axes as currently displayed in the header (#59 unify, M2 #54
737
+ // lenses) — seeded once from /api/project (server-derived from the process
738
+ // env at launch; see deployAxes() in src/server.ts), then kept in sync with
739
+ // whatever the last /api/graph response actually observed (its `meta.tier`/
740
+ // `meta.target`, since a picked lens can differ from the launch-time default).
741
+ let axes = { tier: null, target: null };
742
+
743
+ // Query params for the tier/target lenses (M2, #54) — shared by every fetch
744
+ // this page makes, so picking a lens re-parameterizes the component graph,
745
+ // its CI/resources facets, and the reconcile summary all the same way.
746
+ // stack (#76) rides along too — server.ts's `optsFromQuery` parses `?stack=`
747
+ // the same way, but only `graphPath`'s callers (the graph/overlay fetches in
748
+ // load()) actually consult it; the CI/resources/reconcile facets don't take a
749
+ // source path at all, so they ignore an extra `stack=` harmlessly.
750
+ function lensParams(params) {
751
+ if (view.tier) params.set("tier", view.tier);
752
+ if (view.target) params.set("target", view.target);
753
+ if (view.stack) params.set("stack", view.stack);
754
+ return params;
755
+ }
756
+
757
+ // CI projection facet (M1.2, #58): the component DAG's GitLab CI reading —
758
+ // component name → {jobName, stage, needs, script}, from `/api/ci` (`chant
759
+ // build --components --generate gitlab`). Loaded once per components-mode
760
+ // load() and cached here so inspect() (fired per click, not per fetch) reads
761
+ // it synchronously. Component-DAG mode only — cleared otherwise.
762
+ let ciByComponent = new Map();
763
+ let ciForge = null; // the forge the pipeline was generated for (#164) — heads the inspect section
764
+
765
+ async function loadCi() {
766
+ try {
767
+ const q = lensParams(new URLSearchParams(view.env ? { env: view.env } : {}));
768
+ const res = await apiFetch(`/api/ci?${q}`);
769
+ const j = await res.json();
770
+ if (!res.ok) throw new Error(j.error || res.statusText);
771
+ ciByComponent = new Map((j.jobs || []).map((job) => [job.component, job]));
772
+ ciForge = j.forge || null;
773
+ } catch {
774
+ // Non-fatal: the component DAG still renders without the CI facet (e.g. a
775
+ // served chant predating generate mode) — inspect() just omits the section.
776
+ ciByComponent = new Map();
777
+ ciForge = null;
778
+ }
779
+ }
780
+
781
+ // Resources facet (#59 unify) — component name -> its AWS resources, from
782
+ // `/api/resources` (src/server.ts documents what this is and isn't: a
783
+ // source-location convention match, not the literal CFN stack — chant's own
784
+ // `groups.byStack` is lexicon-only today, not per-stack). Loaded once per
785
+ // components-mode load(), same caching shape as `ciByComponent`; a click
786
+ // reads it synchronously.
787
+ let resourcesByComponent = {};
788
+
789
+ async function loadResources() {
790
+ try {
791
+ const q = lensParams(new URLSearchParams(view.env ? { env: view.env } : {}));
792
+ const res = await apiFetch(`/api/resources?${q}`);
793
+ const j = await res.json();
794
+ if (!res.ok) throw new Error(j.error || res.statusText);
795
+ resourcesByComponent = j.byComponent || {};
796
+ } catch {
797
+ // Non-fatal, same rationale as loadCi(): the DAG and its other facets
798
+ // still render without the resources facet.
799
+ resourcesByComponent = {};
800
+ }
801
+ }
802
+
803
+ // The observe → reconcile → apply dial (M2 #54 observe/reconcile, M3 #54
804
+ // apply): where the selected target sits on the lifecycle progression, per
805
+ // the epic's design. `observe` is always live already — it's the
806
+ // component-status view above (render()'s `componentStatus` branch IS
807
+ // observe); clicking the step just switches into it. `reconcile` is a
808
+ // click-to-fetch summary (`/api/reconcile`, a full build + cloud query — on
809
+ // demand, like #27's live diff), cached until the env/tier/target lens
810
+ // changes. `apply` (M3) is a REAL delegated write: click opens a small
811
+ // component/all picker, confirm triggers `POST /api/apply`
812
+ // (`chant run <target> --components --env <env> --progress-json` — behold
813
+ // triggers, chant executes), and the structured wave/phase progress it
814
+ // streams back (see applyProgressReducer in src/apply.ts, broadcast as the
815
+ // `apply` SSE event) renders live below the dial — the primary surface for
816
+ // an apply, not the raw now-line.
817
+ let reconcileCache = null; // last ReconcileSummary for the current env/tier/target, or null
818
+ let applyProgress = null; // last ApplyProgressState (src/apply.ts) for the current env, or null — hydrated from /api/ops on load, then kept live by the `apply` SSE event
819
+ let applyPicker = false; // whether the inline "apply <component|all> →" prompt is open
820
+ let componentChoices = []; // component names for the apply picker — loaded lazily (independent of whether the graph pane is currently in components mode)
821
+ let componentStatusById = {}; // id -> _status ("good"|"accent"|"warn"|"neutral"), populated alongside componentChoices — lets the picker show which stacks are already applied
822
+
823
+ // How a component's live status reads in the apply picker (and the apply-all
824
+ // summary): a glyph + words, so "which stacks are applied" is legible at a
825
+ // glance rather than a bare name list. Mirrors COMPONENT_STATUS_LABEL's buckets.
826
+ const APPLY_STATUS_TAG = {
827
+ good: "✓ deployed",
828
+ accent: "⋯ in progress",
829
+ warn: "⚠ rolled back / failed",
830
+ neutral: "○ not deployed",
831
+ };
832
+ function applyOptionLabel(name) {
833
+ const tag = APPLY_STATUS_TAG[componentStatusById[name]];
834
+ return tag ? `${name} — ${tag}` : name;
835
+ }
836
+
837
+ // Reset the dial's per-target caches (reconcile summary, apply picker/progress,
838
+ // component-name list) when the env/tier/target lens changes — all three are
839
+ // scoped to "whatever target is currently picked", so switching targets must
840
+ // not show a stale reconcile count or a finished apply's progress from a
841
+ // DIFFERENT target as if it were current.
842
+ function resetDialCaches() {
843
+ reconcileCache = null;
844
+ applyProgress = null;
845
+ applyPicker = false;
846
+ componentChoices = [];
847
+ componentStatusById = {};
848
+ compositeMembersCache = null;
849
+ bulkDiffCache = null;
850
+ }
851
+
852
+ // Composite → member list, for the inspect pane. Derived once from the base
853
+ // (attribute-tier) source graph, where every node carries its `compositeInstance`
854
+ // — then a detail-1 composite node lists what it expands to. Cached per lens
855
+ // (reset in resetDialCaches); structural, so no live/env call needed.
856
+ let compositeMembersCache = null;
857
+ async function getCompositeMembers(instanceId) {
858
+ if (!compositeMembersCache) {
859
+ compositeMembersCache = {};
860
+ try {
861
+ const q = lensParams(new URLSearchParams({ detail: "3" }));
862
+ const j = await apiFetch(`/api/graph?${q}`).then((r) => r.json());
863
+ for (const n of (j.ir && j.ir.nodes) || []) {
864
+ if (n.compositeInstance) (compositeMembersCache[n.compositeInstance] ||= []).push({ id: n.id, kind: n.kind });
865
+ }
866
+ } catch {
867
+ /* leave the cache empty — the members section shows "(no members found)" */
868
+ }
869
+ }
870
+ return compositeMembersCache[instanceId] || [];
871
+ }
872
+
873
+ function dialArrow() {
874
+ const s = document.createElement("span");
875
+ s.className = "dial-arrow";
876
+ s.textContent = "→";
877
+ return s;
878
+ }
879
+
880
+ function renderDial() {
881
+ const host = document.getElementById("dial");
882
+ if (!view.env) {
883
+ // The dial used to vanish entirely here, with nothing saying why — someone
884
+ // who launched without --env saw no observe/reconcile/apply path at all.
885
+ // Say what's missing instead (a static export keeps the old silence: there
886
+ // is genuinely nothing to offer).
887
+ host.innerHTML = "";
888
+ if (staticMode || !environments.length) {
889
+ host.style.display = "none";
890
+ return;
891
+ }
892
+ host.style.display = "flex";
893
+ const hint = document.createElement("span");
894
+ hint.style.cssText = "font-size:11px;color:var(--muted);align-self:center";
895
+ hint.textContent = "observe → reconcile → apply needs an environment — pick one in ⌘K (env: …)";
896
+ host.appendChild(hint);
897
+ // A pipeline run (#163) is env-less — its progress still belongs here.
898
+ if (applyProgress && applyProgress.waves.length) host.appendChild(renderApplyProgress(applyProgress));
899
+ return;
900
+ }
901
+ host.style.display = "flex";
902
+ host.innerHTML = "";
903
+
904
+ const track = document.createElement("div");
905
+ track.className = "dial-track";
906
+
907
+ const observeBtn = button("observe", "dial-step" + (view.components ? " active" : ""), () => {
908
+ view.components = true;
909
+ load();
910
+ });
911
+ observeBtn.title = `Live per-component status for ${view.env} (chant components status --live) — the palette this graph paints when "components" is on.`;
912
+ track.appendChild(observeBtn);
913
+ track.appendChild(dialArrow());
914
+
915
+ // chant#1168 (#1089): `unobserved` is its own count, appended rather than
916
+ // folded into "pending" — reconcileCache.unobserved is 0 against an older
917
+ // chant, so this is a no-op suffix until #1168 ships.
918
+ // chant#1180 (#1077): `runtime` gets the identical treatment — its own
919
+ // count, 0 against a chant predating it.
920
+ const reconcileBtn = button(
921
+ reconcileCache
922
+ ? `reconcile · ${reconcileCache.total} pending` +
923
+ (reconcileCache.unobserved ? ` · ${reconcileCache.unobserved} unobserved` : "") +
924
+ (reconcileCache.runtime ? ` · ${reconcileCache.runtime} runtime` : "")
925
+ : "reconcile",
926
+ "dial-step",
927
+ loadReconcile,
928
+ );
929
+ reconcileBtn.title = `Pending change set for ${view.env} (chant lifecycle plan --live, read-only) — click to load.`;
930
+ track.appendChild(reconcileBtn);
931
+
932
+ // Apply is the write step — omitted in a static export (observe + reconcile
933
+ // above are reads and stay).
934
+ const applying = applyProgress && applyProgress.status === "running";
935
+ if (!staticMode) {
936
+ track.appendChild(dialArrow());
937
+ const applyBtn = button(
938
+ applying ? "apply · running…" : "apply",
939
+ "dial-step" + (applyPicker || applying ? " active" : ""),
940
+ () => {
941
+ if (applying) return; // a run is already in flight — its progress is on screen below
942
+ applyPicker = !applyPicker;
943
+ if (applyPicker) loadComponentChoices().then(renderDial);
944
+ renderDial();
945
+ },
946
+ );
947
+ applyBtn.title = `Delegated write: chant run <component|all> --components --env ${view.env} --progress-json — behold triggers, chant executes.`;
948
+ track.appendChild(applyBtn);
949
+ }
950
+
951
+ host.appendChild(track);
952
+ if (reconcileCache) host.appendChild(renderReconcileDetail(reconcileCache));
953
+ if (applyPicker && !applying && !staticMode) host.appendChild(renderApplyPicker());
954
+ if (applyProgress && applyProgress.waves.length) host.appendChild(renderApplyProgress(applyProgress));
955
+ }
956
+
957
+ // Apply picker (M3): "which component(s)?" prompt for the dial's apply step —
958
+ // mirrors openRollback's inline select+confirm+cancel shape. Defaults to "all
959
+ // components" (chant's own `run --components all` selector); loadComponentChoices()
960
+ // supplies the individual names regardless of the graph pane's current mode.
961
+ function renderApplyPicker() {
962
+ const wrap = document.createElement("div");
963
+ wrap.className = "dial-detail";
964
+ wrap.style.gap = "6px";
965
+ const sel = document.createElement("select");
966
+ sel.style.cssText =
967
+ "background:var(--panel);color:var(--fg);border:1px solid var(--line);border-radius:6px;padding:3px 8px;font-size:12px";
968
+ sel.add(new Option("all components", "all"));
969
+ for (const name of componentChoices) sel.add(new Option(applyOptionLabel(name), name));
970
+ const go = button("Apply →", "", () => {
971
+ const component = sel.value;
972
+ const what = component === "all" ? "ALL components" : component;
973
+ if (!window.confirm(`Apply ${what} to ${view.env}?\nThis is a real write — chant run --components --progress-json.`)) return;
974
+ applyPicker = false;
975
+ runApply(component);
976
+ });
977
+ const cancel = button("✕", "", () => {
978
+ applyPicker = false;
979
+ renderDial();
980
+ });
981
+ wrap.append(sel, go, cancel);
982
+ return wrap;
983
+ }
984
+
985
+ async function loadComponentChoices() {
986
+ if (componentChoices.length) return componentChoices;
987
+ try {
988
+ const q = lensParams(new URLSearchParams({ components: "1", ...(view.env ? { env: view.env } : {}) }));
989
+ const res = await apiFetch(`/api/graph?${q}`);
990
+ const j = await res.json();
991
+ const nodes = (j.ir && j.ir.nodes ? j.ir.nodes : []).filter((n) => n.kind === "Component");
992
+ componentChoices = nodes.map((n) => n.id);
993
+ // Per-component live status for the picker labels — only present when an env
994
+ // is picked (the status join needs one); source-only graphs leave it blank.
995
+ componentStatusById = {};
996
+ for (const n of nodes) if (n.attrs && n.attrs._status) componentStatusById[n.id] = n.attrs._status;
997
+ } catch {
998
+ componentChoices = []; // the picker still offers "all components" — just no per-name list
999
+ componentStatusById = {};
1000
+ }
1001
+ return componentChoices;
1002
+ }
1003
+
1004
+ // "Apply all" from the header (M3): the discoverable equivalent of Sync when a
1005
+ // project has no committed ApplyOp — deploy every component. Uses the current
1006
+ // env (kept live by the picker); confirms since it's a real write. The dial's
1007
+ // structured progress then renders the run.
1008
+ async function confirmApplyAll() {
1009
+ const env = view.env;
1010
+ if (!env) {
1011
+ nowline("✗ apply all: pick an env first (env drives the target)");
1012
+ return;
1013
+ }
1014
+ // Route around Floci #16 (github.com/lex00/floci/issues/16): re-applying an
1015
+ // already-deployed stack collides on its fixed-name resources ("... already
1016
+ // exists") and rolls the stack back — the emulator can't no-op an unchanged
1017
+ // resource on update. So the honest states, from the accurate per-component
1018
+ // live status (the stack-status seam):
1019
+ // all healthy -> nothing to apply; re-apply would only break things.
1020
+ // some undeployed -> apply is fine for a fresh emulator.
1021
+ // some rolled-back -> re-apply won't recover them; a reset is the clean path.
1022
+ let deployed = 0;
1023
+ let total = 0;
1024
+ let rolledBack = 0;
1025
+ try {
1026
+ const j = await apiFetch(`/api/graph?components=1&env=${encodeURIComponent(env)}`).then((r) => r.json());
1027
+ const nodes = (j.ir && j.ir.nodes) || [];
1028
+ total = nodes.length;
1029
+ deployed = nodes.filter((n) => n.attrs && n.attrs._status === "good").length;
1030
+ rolledBack = nodes.filter((n) => n.attrs && n.attrs._status === "warn").length;
1031
+ } catch {
1032
+ /* couldn't check — fall through to the plain confirm */
1033
+ }
1034
+ // The Floci #16 story (re-apply collides on the emulator's fixed-name
1035
+ // resources) only exists where applies actually hit Floci. On a k8s/helm
1036
+ // estate a re-apply is the normal sync gesture, and warning about an
1037
+ // emulator the project doesn't use was wrong twice over — the server's
1038
+ // /api/apply pre-flight is gated the same way.
1039
+ const onFloci = lastSubstrates.some((s) => s.name === "floci" && s.status === "up");
1040
+ if (onFloci && total > 0 && deployed === total) {
1041
+ showToast(`✓ nothing to apply — all ${total} components are already deployed & in sync (re-applying would collide on Floci #16)`, true);
1042
+ return;
1043
+ }
1044
+ const brokenNote = onFloci && rolledBack > 0
1045
+ ? `⚠ ${rolledBack} component(s) are rolled back. Re-applying WON'T recover them on the emulator — their fixed-name resources still exist (Floci #16). Use the "Reset" button on the Floci substrate pill — it reboots the emulator and redeploys clean (don't apply after).\n\n`
1046
+ : "";
1047
+ const reapplyNote = onFloci && deployed > 0
1048
+ ? `Note: ${deployed} of ${total} are already deployed and will be re-applied — that can fail on the local emulator (Floci #16).\n\n`
1049
+ : "";
1050
+ if (
1051
+ !window.confirm(
1052
+ `${brokenNote}${reapplyNote}Apply ALL components to ${env}?\nReal write — chant run --components all --env ${env} --progress-json.\nLive progress appears in the dial.`,
1053
+ )
1054
+ )
1055
+ return;
1056
+ runApply("all");
1057
+ }
1058
+
1059
+ function runApply(component) {
1060
+ const q = new URLSearchParams({ env: view.env, component });
1061
+ fetch(`/api/apply?${q}`, { method: "POST" })
1062
+ .then((r) => r.json())
1063
+ .then((j) => {
1064
+ if (j.error) {
1065
+ showToast(`✗ apply: ${j.error}`, false);
1066
+ nowline("✗ apply: " + j.error);
1067
+ } else {
1068
+ showToast(`▶ applying ${component} → ${view.env} — progress on the dial`, true);
1069
+ nowline(`▶ apply ${component} → ${view.env}`);
1070
+ }
1071
+ renderDial();
1072
+ });
1073
+ }
1074
+
1075
+ // Structured wave/phase progress (M3): the primary surface for an apply — an
1076
+ // ordered list of waves, each showing its components' current phase/step and
1077
+ // status, coloured the same way the rest of the SPA colours health (managed=
1078
+ // ok, pending=running, degraded=failed, muted=not-yet-reached). Replaces the
1079
+ // raw-log-tail now-line as the thing you actually watch during a deploy; the
1080
+ // now-line still gets chant's human summary + any non-progress line as a
1081
+ // fallback (src/op-runner.ts's apply() only filters OUT recognized
1082
+ // RunProgressEvent lines from that channel).
1083
+ const APPLY_STATUS_COLOR = { pending: "var(--muted)", running: "var(--pending)", ok: "var(--managed)", failed: "var(--degraded)" };
1084
+
1085
+ function renderApplyProgress(state) {
1086
+ const wrap = document.createElement("div");
1087
+ wrap.style.cssText = "display:flex;flex-direction:column;gap:6px;width:100%;margin-top:4px";
1088
+ const summary = document.createElement("div");
1089
+ summary.style.cssText = `font-size:11px;color:${APPLY_STATUS_COLOR[state.status] || "var(--muted)"}`;
1090
+ // A pipeline run (#163) reuses this whole panel — same shape, different
1091
+ // executor — and says so instead of claiming to be an apply.
1092
+ summary.textContent = `${state.kind || "apply"}: ${state.status}`;
1093
+ wrap.appendChild(summary);
1094
+ for (const w of state.waves) {
1095
+ const row = document.createElement("div");
1096
+ row.style.cssText = "display:flex;align-items:center;gap:8px;flex-wrap:wrap";
1097
+ const label = document.createElement("span");
1098
+ label.style.cssText = `font-size:11px;color:${APPLY_STATUS_COLOR[w.status] || "var(--muted)"};min-width:52px`;
1099
+ label.textContent = `wave ${w.wave}`;
1100
+ row.appendChild(label);
1101
+ for (const cname of w.components) {
1102
+ const c = (state.components || []).find((x) => x.component === cname) || { status: "pending" };
1103
+ const color = APPLY_STATUS_COLOR[c.status] || "var(--muted)";
1104
+ const chip = document.createElement("span");
1105
+ chip.style.cssText = `border:1px solid ${color};color:${color};border-radius:6px;padding:2px 8px;font-size:11px`;
1106
+ const detail = [c.phase, c.step].filter(Boolean).join(" · ");
1107
+ chip.textContent = `${cname}${detail ? " · " + detail : ""} (${c.status})`;
1108
+ if (c.error) chip.title = c.error;
1109
+ row.appendChild(chip);
1110
+ }
1111
+ wrap.appendChild(row);
1112
+ }
1113
+ return wrap;
1114
+ }
1115
+
1116
+ function renderReconcileDetail(r) {
1117
+ const wrap = document.createElement("div");
1118
+ wrap.className = "dial-detail";
1119
+ const rows = Object.entries(r.byComponent).sort((a, b) => b[1] - a[1]);
1120
+ // chant#1168 (#1089): `unobserved` is its own category — a plan that's ALL
1121
+ // unobserved (nothing pending, nothing uncorrelated) must not read as
1122
+ // "no pending changes", which would claim everything's confirmed in sync.
1123
+ const hasUnobserved = !!r.unobserved;
1124
+ // chant#1180 (#1077): `runtime` gets the identical treatment.
1125
+ const hasRuntime = !!r.runtime;
1126
+ if (!rows.length && !r.uncorrelated && !hasUnobserved && !hasRuntime) {
1127
+ wrap.textContent = "no pending changes";
1128
+ return wrap;
1129
+ }
1130
+ for (const [component, count] of rows) {
1131
+ const span = document.createElement("span");
1132
+ span.textContent = `${component}: ${count}`;
1133
+ wrap.appendChild(span);
1134
+ }
1135
+ if (r.uncorrelated) {
1136
+ const span = document.createElement("span");
1137
+ span.textContent = `${r.uncorrelated} uncorrelated`;
1138
+ span.title = "Pending changes that couldn't be mapped to a component by source location.";
1139
+ wrap.appendChild(span);
1140
+ }
1141
+ if (hasUnobserved) {
1142
+ const span = document.createElement("span");
1143
+ span.style.color = "var(--muted)";
1144
+ span.textContent = `${r.unobserved} unobserved`;
1145
+ span.title = "Declared entities chant could not read live state for — not a pending change, not confirmed in sync.";
1146
+ wrap.appendChild(span);
1147
+ }
1148
+ if (hasRuntime) {
1149
+ const span = document.createElement("span");
1150
+ span.style.color = "var(--runtime)";
1151
+ span.textContent = `${r.runtime} runtime`;
1152
+ span.title = "Live, undeclared resources whose owner chain reaches a declared entity — expected runtime, not a pending change.";
1153
+ wrap.appendChild(span);
1154
+ }
1155
+ return wrap;
1156
+ }
1157
+
1158
+ async function loadReconcile() {
1159
+ try {
1160
+ const q = lensParams(new URLSearchParams({ env: view.env }));
1161
+ const res = await apiFetch(`/api/reconcile?${q}`);
1162
+ const j = await res.json();
1163
+ // #72: the structured {error, code, remedy} src/server.ts's errorResponse
1164
+ // now sends for every classified failure (tier included — `error` already
1165
+ // carries the full tier-scoped message, replacing the old `tierNote`).
1166
+ if (!res.ok) throw new Error(j.error || res.statusText);
1167
+ reconcileCache = j;
1168
+ renderDial();
1169
+ } catch (e) {
1170
+ nowline("✗ reconcile: " + e.message);
1171
+ }
1172
+ }
1173
+
1174
+ // Edge hover-highlight (helps trace one edge through an overlapping bundle — see
1175
+ // index.html's `.pin-edge-line` rules). pinhole already paints a fat transparent
1176
+ // hit-path per edge, so the CSS `:hover` does the visual work; this adds two
1177
+ // things CSS can't: raise the hovered edge to the top of the paint order (SVG has
1178
+ // no z-index) so it's not buried, and light up every edge touching a hovered node
1179
+ // (the `.edge-hi` class) so hovering a card traces all its connections.
1180
+ // Human-readable "why does this edge exist" from the IR edge's `viaAttr`
1181
+ // (src/logical.ts tags them). Shown as a native SVG <title> tooltip on hover;
1182
+ // the value also drives a dashed style for data-dependency links (index.html).
1183
+ const EDGE_REASON = {
1184
+ "security-group ingress": "security-group ingress — traffic is allowed here",
1185
+ "data dependency": "data dependency — reads/uses this store",
1186
+ };
1187
+ function wireEdgeHighlight(svgEl, ir) {
1188
+ const edges = [...svgEl.querySelectorAll("g[data-edge-from]")];
1189
+ if (!edges.length) return;
1190
+ // Reason per edge, keyed by from|to (and its reverse, since pinhole may paint
1191
+ // an edge in either direction relative to the IR).
1192
+ const viaOf = new Map();
1193
+ for (const e of ir.edges || []) {
1194
+ if (!e.viaAttr) continue;
1195
+ viaOf.set(`${e.from}|${e.to}`, e.viaAttr);
1196
+ viaOf.set(`${e.to}|${e.from}`, e.viaAttr);
1197
+ }
1198
+ for (const g of edges) {
1199
+ const from = g.getAttribute("data-edge-from");
1200
+ const to = g.getAttribute("data-edge-to");
1201
+ const via = viaOf.get(`${from}|${to}`);
1202
+ if (via) g.setAttribute("data-edge-via", via); // drives the dashed style
1203
+ const title = document.createElementNS(SVGNS, "title");
1204
+ title.textContent = `${from} → ${to}${via ? "\n" + (EDGE_REASON[via] || via) : ""}`;
1205
+ g.insertBefore(title, g.firstChild);
1206
+ }
1207
+ const raise = (g) => g.parentNode && g.parentNode.appendChild(g);
1208
+ // Delegated: raise whichever edge the pointer is over (its hit-path catches it).
1209
+ svgEl.addEventListener("mouseover", (e) => {
1210
+ const g = e.target.closest && e.target.closest("g[data-edge-from]");
1211
+ if (g) raise(g);
1212
+ });
1213
+ // Hovering a node lights (and raises) every edge on it.
1214
+ for (const card of svgEl.querySelectorAll("[data-node-id]")) {
1215
+ const id = card.getAttribute("data-node-id");
1216
+ const touching = edges.filter((g) => g.getAttribute("data-edge-from") === id || g.getAttribute("data-edge-to") === id);
1217
+ if (!touching.length) continue;
1218
+ card.addEventListener("mouseenter", () => touching.forEach((g) => (g.classList.add("edge-hi"), raise(g))));
1219
+ card.addEventListener("mouseleave", () => touching.forEach((g) => g.classList.remove("edge-hi")));
1220
+ }
1221
+ }
1222
+
1223
+ // GitLab's tanuki, one filled silhouette (the widely-used single-path mark),
1224
+ // scaled into a small badge. Self-contained (no external URL) so it survives the
1225
+ // static export + the CSP.
1226
+ const GITLAB_TANUKI =
1227
+ "M23.6004 9.5927l-.0337-.0862L20.3.9814a.851.851 0 00-.3362-.405.8748.8748 0 00-.9997.0539.8748.8748 0 00-.29.4399l-2.2055 6.748H7.5375l-2.2055-6.748a.8573.8573 0 00-.29-.4412.8748.8748 0 00-.9997-.0537.8585.8585 0 00-.3362.405L.4332 9.5065l-.0325.0862a6.0657 6.0657 0 002.0119 7.0105l.0113.0087.03.0213 4.976 3.7264 2.462 1.8633 1.4995 1.1321a1.0085 1.0085 0 001.2197 0l1.4995-1.1321 2.462-1.8633 5.006-3.7489.0125-.01a6.0682 6.0682 0 002.0094-7.003z";
1228
+ const SVGNS = "http://www.w3.org/2000/svg";
1229
+
1230
+ // Stamp a GitLab tanuki in the top-right corner of each wave lane — a visual cue
1231
+ // that the waves ARE the GitLab CI pipeline's stages (job `stage` = `wave-N`).
1232
+ // Post-processes the inlined SVG: each wave box is a `<rect>` immediately
1233
+ // followed by its `<text>wave-N</text>` title, so the rect gives the corner.
1234
+ function addGitlabWaveBadges(svgEl) {
1235
+ const SIZE = 17;
1236
+ for (const text of svgEl.querySelectorAll("text")) {
1237
+ if (!/^wave-/i.test((text.textContent || "").trim())) continue;
1238
+ const rect = text.previousElementSibling;
1239
+ if (!rect || rect.tagName.toLowerCase() !== "rect") continue;
1240
+ const rx = parseFloat(rect.getAttribute("x"));
1241
+ const ry = parseFloat(rect.getAttribute("y"));
1242
+ const rw = parseFloat(rect.getAttribute("width"));
1243
+ const g = document.createElementNS(SVGNS, "g");
1244
+ g.setAttribute("class", "gitlab-wave-badge");
1245
+ g.setAttribute("transform", `translate(${rx + rw - SIZE - 12}, ${ry + 10}) scale(${SIZE / 24})`);
1246
+ const path = document.createElementNS(SVGNS, "path");
1247
+ path.setAttribute("d", GITLAB_TANUKI);
1248
+ path.setAttribute("fill", "#FC6D26"); // GitLab brand orange
1249
+ const title = document.createElementNS(SVGNS, "title");
1250
+ title.textContent = "This wave is a GitLab CI stage (chant → GitLab pipeline)";
1251
+ g.append(title, path);
1252
+ rect.parentNode.insertBefore(g, text);
1253
+ }
1254
+ }
1255
+
1256
+ // Paint a fetched graph: the SVG, the meta line (with a drift summary in overlay
1257
+ // mode), the legend, and click-inspect wiring. Shared by load() and refresh().
1258
+ function render(ir, svg, m) {
1259
+ // #131: set before anything can early-return, so a level that stopped
1260
+ // degrading stops explaining itself on the very next render.
1261
+ lastNote = m.note || null;
1262
+ const overlay = m.mode === "overlay";
1263
+ // Logical/architecture lens (#63): its own mode, but when an env is picked the
1264
+ // projected nodes still carry the drift `_status`, so it reads as a drift view
1265
+ // (same summary + legend as the entity overlay).
1266
+ const logical = m.mode === "logical";
1267
+ const drift = overlay || (logical && !!m.env);
1268
+ // M1.1 (#57): the component DAG's live per-component AWS status — a
1269
+ // different join than the entity overlay (see server's /api/graph), so it
1270
+ // gets its own summary + legend rather than reusing `overlay`'s.
1271
+ const componentStatus = m.mode === "component-status";
1272
+ let tail = ` · ${ir.edges.length} edges`;
1273
+ if (drift) {
1274
+ // Summarise drift so "everything's blue" reads as "N pending". `neutral`
1275
+ // (chant#1168, #1089) is its own bucket — a declared node chant couldn't
1276
+ // read live state for is neither managed, foreign, nor (confirmed)
1277
+ // pending, and folding it into any of those would misreport a hole in
1278
+ // the observation as a verdict. Additive: a chant predating #1168 never
1279
+ // tags a node `neutral` here, so `c.neutral` stays 0 against an older chant.
1280
+ // `runtime` (chant#1180, #1077) is its own bucket too — a live,
1281
+ // undeclared node whose owner chain reaches a declared entity is neither
1282
+ // managed, foreign, nor pending; folding it into any of those would
1283
+ // misreport expected runtime as drift or as a proposed create. Additive
1284
+ // the same way — `c.runtime` stays 0 against a chant predating #1180.
1285
+ const c = { good: 0, warn: 0, accent: 0, neutral: 0, runtime: 0 };
1286
+ for (const n of ir.nodes) {
1287
+ const s = n.attrs && n.attrs._status;
1288
+ if (s in c) c[s]++;
1289
+ }
1290
+ tail = ` · ${c.good} managed · ${c.warn} foreign · ${c.accent} pending`;
1291
+ if (c.neutral) tail += ` · ${c.neutral} unobserved`;
1292
+ if (c.runtime) tail += ` · ${c.runtime} runtime`;
1293
+ // Nothing observed live in this env — explain the all-blue rather than let it
1294
+ // read as a bug (#32).
1295
+ if (c.good === 0 && c.warn === 0 && c.accent > 0) tail += ` — nothing deployed in ${m.env} yet`;
1296
+ } else if (componentStatus) {
1297
+ // M2 (#54): 4 buckets now (good/accent/warn/neutral) — see
1298
+ // COMPONENT_STATUS_LABEL and src/component-status.ts's palette doc comment.
1299
+ const c = { good: 0, accent: 0, warn: 0, neutral: 0 };
1300
+ for (const n of ir.nodes) {
1301
+ const s = n.attrs && n.attrs._status;
1302
+ if (s in c) c[s]++;
1303
+ }
1304
+ tail = ` · ${c.good} healthy · ${c.accent} in progress · ${c.warn} rollback/failed · ${c.neutral} not deployed`;
1305
+ }
1306
+ // Multi-estate (#31): note the composed project count; the graph draws one box per project.
1307
+ const scope = m.estate ? `estate of ${m.estate} projects` : m.projectDir;
1308
+ // The deploy axes (#59 unify, M2 #54 lenses) — tier/target, kept in sync with
1309
+ // what this response actually observed (falls back to the launch-time value
1310
+ // from /api/project when a route doesn't echo them, e.g. /api/overlay).
1311
+ if (m.tier !== undefined) axes.tier = m.tier;
1312
+ if (m.target !== undefined) axes.target = m.target;
1313
+ const axesTail = `${axes.tier ? " · tier " + axes.tier : ""}${axes.target ? " · target " + axes.target : ""}`;
1314
+ document.getElementById("meta").textContent =
1315
+ `${scope}${m.env ? " · env " + m.env : ""}${axesTail}${overlay ? " · overlay" : ""}${logical ? " · logical" : ""}${m.components ? " · components" : ""}${componentStatus ? " · live status" : ""} · ${ir.nodes.length} nodes${tail}`;
1316
+ document.getElementById("legend").style.display = drift ? "flex" : "none";
1317
+ document.getElementById("component-legend").style.display = componentStatus ? "flex" : "none";
1318
+ // Keep the persistent state strip in sync — zoom/env/tier/radial are picked
1319
+ // via the ⌘K palette now (#73), but stay visible here regardless (radial
1320
+ // only applies to the entity zooms; renderStatusbar() drops it for
1321
+ // components/logical, both of which lay themselves out: waves / nested
1322
+ // architecture boxes).
1323
+ renderStatusbar();
1324
+ const g = document.getElementById("graph");
1325
+ // Ghostty theming (#62): strip pinhole's baked-in `:root{--pin-*}` defaults from the
1326
+ // inlined SVG so its var(--pin-*) fills resolve from behold's live documentElement tokens
1327
+ // (theme.js applyTheme), not the frozen dark palette. Without this, every theme renders
1328
+ // green — the SVG's own :root shadows behold's override within the graph subtree.
1329
+ g.innerHTML = svg.replace(/:root\s*\{[^{}]*--pin-[^{}]*\}/g, "");
1330
+ recolorNodesByCategory(ir); // #62: category-hued fills from the theme's full palette
1331
+ const svgEl = g.querySelector("svg");
1332
+ if (svgEl) {
1333
+ // Drop pinhole's fixed pixel size so the viewBox drives sizing; behold then
1334
+ // pans/zooms by mutating the viewBox (setupGraphViewBox + the wheel/drag
1335
+ // handlers). Starts fit-to-pane, then pinch / ⌘+scroll zooms, drag pans.
1336
+ svgEl.removeAttribute("width");
1337
+ svgEl.removeAttribute("height");
1338
+ svgEl.setAttribute("preserveAspectRatio", "xMidYMid meet");
1339
+ setupGraphViewBox(svgEl);
1340
+ wireEdgeHighlight(svgEl, ir);
1341
+ // Mark each wave lane as a GitLab CI stage when the CI projection is loaded
1342
+ // (components view only) — waves ARE the pipeline's stages (`chant build
1343
+ // --components --generate gitlab`, see loadCi / #58).
1344
+ if (view.components && ciByComponent.size) addGitlabWaveBadges(svgEl);
1345
+ }
1346
+ ensureZoomControls(g);
1347
+ ensureBackToInfra(g);
1348
+ wire(ir);
1349
+ if (view.radial && !view.components && !view.logical) addRadialLabels(ir);
1350
+ renderDial();
1351
+ }
1352
+
1353
+ // The radial layout clusters each component into an angular wedge, but nothing
1354
+ // says which wedge is which. Label each: read every node's position from its
1355
+ // `data-node-id` transform (SVG/viewBox coords, so labels pan/zoom with the
1356
+ // graph), group by component, and drop the component name just outside its
1357
+ // wedge at its mean angle. Cue only — pointer-events off so clicks pass through.
1358
+ function radialGroupOf(node) {
1359
+ const p = (node.sourceLoc?.file || "").split("/");
1360
+ if (p[0] === "src" && p[1] === "examples") return "examples";
1361
+ if (p[0] === "src" && p.length >= 3) return p[1];
1362
+ return node.lexicon || "other";
1363
+ }
1364
+ function addRadialLabels(ir) {
1365
+ const svg = document.querySelector("#graph svg");
1366
+ if (!svg) return;
1367
+ const groupOf = new Map(ir.nodes.map((n) => [n.id, radialGroupOf(n)]));
1368
+ const pos = new Map();
1369
+ for (const g of svg.querySelectorAll("[data-node-id]")) {
1370
+ const m = (g.getAttribute("transform") || "").match(/translate\(\s*([-\d.]+)[\s,]+([-\d.]+)/);
1371
+ if (m) pos.set(g.getAttribute("data-node-id"), { x: +m[1], y: +m[2] });
1372
+ }
1373
+ if (pos.size < 2) return;
1374
+ let cx = 0, cy = 0;
1375
+ pos.forEach((p) => { cx += p.x; cy += p.y; });
1376
+ cx /= pos.size; cy /= pos.size;
1377
+ const groups = new Map(); // key -> {sx,sy,n,maxR}
1378
+ pos.forEach((p, id) => {
1379
+ const k = groupOf.get(id);
1380
+ if (!k) return;
1381
+ const dx = p.x - cx, dy = p.y - cy;
1382
+ const g = groups.get(k) || { sx: 0, sy: 0, n: 0, maxR: 0 };
1383
+ g.sx += dx; g.sy += dy; g.n++; g.maxR = Math.max(g.maxR, Math.hypot(dx, dy));
1384
+ groups.set(k, g);
1385
+ });
1386
+ const NS = "http://www.w3.org/2000/svg";
1387
+ const layer = document.createElementNS(NS, "g");
1388
+ layer.setAttribute("id", "radial-labels");
1389
+ layer.setAttribute("pointer-events", "none");
1390
+ const vb = (svg.getAttribute("viewBox") || "0 0 1000 1000").split(/\s+/).map(Number);
1391
+ const fontSize = Math.max(18, Math.round(vb[2] / 60));
1392
+ groups.forEach((g, key) => {
1393
+ const ang = Math.atan2(g.sy, g.sx);
1394
+ const r = g.maxR + fontSize * 2.2;
1395
+ const x = cx + r * Math.cos(ang), y = cy + r * Math.sin(ang);
1396
+ const t = document.createElementNS(NS, "text");
1397
+ t.setAttribute("x", x);
1398
+ t.setAttribute("y", y);
1399
+ t.setAttribute("text-anchor", Math.abs(Math.cos(ang)) < 0.4 ? "middle" : Math.cos(ang) < 0 ? "end" : "start");
1400
+ t.setAttribute("dominant-baseline", "middle");
1401
+ t.setAttribute("fill", "var(--fg)");
1402
+ t.setAttribute("opacity", "0.72");
1403
+ t.setAttribute("font-size", String(fontSize));
1404
+ t.setAttribute("font-weight", "700");
1405
+ t.textContent = key;
1406
+ layer.appendChild(t);
1407
+ });
1408
+ svg.appendChild(layer);
1409
+ }
1410
+
1411
+ // When observe (or the zoom picker) drops you into the components view, float a
1412
+ // "zoom in ⤢" link on the graph itself — the exit where the eye already is, not
1413
+ // buried in the toolbar. Zooms one step finer (components → resources). Shown
1414
+ // only in the components view.
1415
+ function ensureBackToInfra(host) {
1416
+ let link = document.getElementById("back-to-infra");
1417
+ if (!link) {
1418
+ link = document.createElement("button");
1419
+ link.id = "back-to-infra";
1420
+ link.textContent = "zoom in ⤢ resources";
1421
+ link.title = "Zoom finer — from components to the resource graph.";
1422
+ link.addEventListener("click", (e) => {
1423
+ e.stopPropagation();
1424
+ applyZoom("resources");
1425
+ load();
1426
+ });
1427
+ host.appendChild(link);
1428
+ }
1429
+ link.style.display = view.components ? "" : "none";
1430
+ }
1431
+
1432
+ // --- Graph zoom/pan, driven by the SVG viewBox (works for a 7-node DAG or a
1433
+ // 180-node estate alike: fit-to-pane by default, then zoom IN to read). Pinch
1434
+ // or ⌘/Ctrl+scroll zooms at the cursor; drag pans; "⤢ fit" resets. ---
1435
+ let vb = null; // current viewBox [x,y,w,h]
1436
+ let vbInit = null; // the graph's natural viewBox (fit)
1437
+ let panMoved = false; // true once a drag moved — suppresses the node click on release
1438
+ let zoomWired = false;
1439
+
1440
+ function setupGraphViewBox(svg) {
1441
+ const a = (svg.getAttribute("viewBox") || "").split(/\s+/).map(Number);
1442
+ if (a.length === 4 && a.every((n) => !Number.isNaN(n))) {
1443
+ vbInit = a.slice();
1444
+ vb = a.slice();
1445
+ } else {
1446
+ vbInit = vb = null;
1447
+ }
1448
+ }
1449
+ function currentSvg() {
1450
+ return document.querySelector("#graph svg");
1451
+ }
1452
+ function applyVB() {
1453
+ const s = currentSvg();
1454
+ if (s && vb) s.setAttribute("viewBox", vb.join(" "));
1455
+ }
1456
+ function fitGraph() {
1457
+ if (vbInit) {
1458
+ vb = vbInit.slice();
1459
+ applyVB();
1460
+ }
1461
+ }
1462
+ function ensureZoomControls(host) {
1463
+ let btn = document.getElementById("zoom-toggle");
1464
+ if (!btn || btn.parentElement !== host) {
1465
+ btn = document.createElement("button");
1466
+ btn.id = "zoom-toggle";
1467
+ btn.textContent = "⤢ fit";
1468
+ btn.title = "Reset to fit. Pinch or ⌘/Ctrl+scroll to zoom at the cursor; drag to pan.";
1469
+ btn.addEventListener("click", (e) => {
1470
+ e.stopPropagation();
1471
+ fitGraph();
1472
+ });
1473
+ host.appendChild(btn);
1474
+ }
1475
+ if (zoomWired) return;
1476
+ zoomWired = true;
1477
+ host.addEventListener(
1478
+ "wheel",
1479
+ (e) => {
1480
+ // Trackpad pinch fires wheel+ctrlKey; ⌘/Ctrl+scroll is the explicit gesture.
1481
+ // Plain scroll is left alone (nothing to scroll when fit).
1482
+ if (!vb || !(e.ctrlKey || e.metaKey)) return;
1483
+ e.preventDefault();
1484
+ const s = currentSvg();
1485
+ if (!s) return;
1486
+ const r = s.getBoundingClientRect();
1487
+ const cx = vb[0] + ((e.clientX - r.left) / r.width) * vb[2];
1488
+ const cy = vb[1] + ((e.clientY - r.top) / r.height) * vb[3];
1489
+ const f = Math.exp(e.deltaY * 0.0025); // scroll up → f<1 → zoom in
1490
+ const minW = vbInit[2] / 60;
1491
+ const maxW = vbInit[2] * 3;
1492
+ const nw = Math.min(maxW, Math.max(minW, vb[2] * f));
1493
+ const nh = nw * (vb[3] / vb[2]);
1494
+ vb[0] = cx - ((cx - vb[0]) * nw) / vb[2];
1495
+ vb[1] = cy - ((cy - vb[1]) * nh) / vb[3];
1496
+ vb[2] = nw;
1497
+ vb[3] = nh;
1498
+ applyVB();
1499
+ },
1500
+ { passive: false },
1501
+ );
1502
+ let drag = false;
1503
+ let px = 0;
1504
+ let py = 0;
1505
+ host.addEventListener("mousedown", (e) => {
1506
+ if (!vb) return;
1507
+ drag = true;
1508
+ panMoved = false;
1509
+ px = e.clientX;
1510
+ py = e.clientY;
1511
+ });
1512
+ window.addEventListener("mousemove", (e) => {
1513
+ if (!drag || !vb) return;
1514
+ const s = currentSvg();
1515
+ if (!s) return;
1516
+ const r = s.getBoundingClientRect();
1517
+ const dx = e.clientX - px;
1518
+ const dy = e.clientY - py;
1519
+ if (Math.abs(dx) + Math.abs(dy) > 3) {
1520
+ panMoved = true;
1521
+ s.classList.add("grabbing");
1522
+ }
1523
+ vb[0] -= (dx / r.width) * vb[2];
1524
+ vb[1] -= (dy / r.height) * vb[3];
1525
+ px = e.clientX;
1526
+ py = e.clientY;
1527
+ applyVB();
1528
+ });
1529
+ window.addEventListener("mouseup", () => {
1530
+ drag = false;
1531
+ const s = currentSvg();
1532
+ if (s) s.classList.remove("grabbing");
1533
+ });
1534
+ }
1535
+
1536
+ // Precondition-failure codes (#72) → a short, human title for the entry/error
1537
+ // screen below. Mirrors every `code` a read route's structured error can
1538
+ // carry (src/server.ts RouteErrorCode: chant.ts's lint/not-installed/eval,
1539
+ // plus "tier" — a picked tier that needs creds this host lacks, M2 #54).
1540
+ const PRECONDITION_TITLE = {
1541
+ lint: "This project doesn't pass chant lint",
1542
+ "not-installed": "This project isn't installed",
1543
+ tier: "This tier needs credentials",
1544
+ eval: "chant couldn't evaluate this project",
1545
+ };
1546
+
1547
+ // A precondition failure — the lint gate, a not-installed/no-typegen project,
1548
+ // or a tier that needs credentials (#72) — gets a readable card with chant's
1549
+ // own message and a suggested remedy, not a blank canvas or a raw stack
1550
+ // trace. Generalizes what used to be a tier-only `tierNote` (the server now
1551
+ // sends the same structured {error, code, remedy} for every classified
1552
+ // failure — src/server.ts errorResponse). Text content only (never
1553
+ // innerHTML) — `error` embeds chant's own stderr verbatim.
1554
+ function renderPreconditionError(body) {
1555
+ const host = document.getElementById("graph");
1556
+ host.innerHTML = "";
1557
+ const card = document.createElement("div");
1558
+ card.className = "precondition-error";
1559
+ const title = document.createElement("div");
1560
+ title.className = "precondition-error-title";
1561
+ title.textContent = PRECONDITION_TITLE[body.code] || "chant couldn't evaluate this project";
1562
+ card.appendChild(title);
1563
+ if (body.error) {
1564
+ const message = document.createElement("div");
1565
+ message.className = "precondition-error-message";
1566
+ message.textContent = body.error;
1567
+ card.appendChild(message);
1568
+ }
1569
+ if (body.remedy) {
1570
+ const remedy = document.createElement("div");
1571
+ remedy.className = "precondition-error-remedy";
1572
+ remedy.textContent = body.remedy;
1573
+ card.appendChild(remedy);
1574
+ }
1575
+ host.appendChild(card);
1576
+ document.getElementById("legend").style.display = "none";
1577
+ document.getElementById("component-legend").style.display = "none";
1578
+ }
1579
+
1580
+ // Fetch the current view (source graph, or the picked env's live overlay).
1581
+ async function load(opts = {}) {
1582
+ const meta = document.getElementById("meta");
1583
+ // A background settle re-pull (post-apply) shouldn't flash the meta/overlay.
1584
+ if (!opts.quiet) {
1585
+ meta.textContent = view.env ? `loading overlay for ${view.env}…` : "loading…";
1586
+ // A view change shells chant live (seconds on a slow box) — cover the UI with
1587
+ // a blocking overlay so clicks can't queue a second pull mid-flight.
1588
+ showLoading(`loading ${zoomValue()}${view.env ? " · " + view.env : ""}…`);
1589
+ }
1590
+ try {
1591
+ const q = lensParams(new URLSearchParams({ detail: String(view.detail) }));
1592
+ let endpoint = "/api/graph";
1593
+ if (view.components) {
1594
+ // M1.1 (#57): the component DAG stays on /api/graph even with an env
1595
+ // picked — the server joins live per-component status onto it there
1596
+ // (component name -> CFN stack), instead of routing to /api/overlay's
1597
+ // cross-substrate entity overlay, which components never use.
1598
+ q.set("components", "1");
1599
+ if (view.env) q.set("env", view.env);
1600
+ } else if (view.logical) {
1601
+ // Logical/architecture lens (#63): the server re-projects at detail 3
1602
+ // regardless of the dial, so detail/radial don't apply. With an env it
1603
+ // projects the live overlay (drift colours preserved), else the source.
1604
+ q.set("logical", "1");
1605
+ if (view.env) {
1606
+ endpoint = "/api/overlay";
1607
+ q.set("env", view.env);
1608
+ }
1609
+ } else if (view.env) {
1610
+ endpoint = "/api/overlay";
1611
+ q.set("env", view.env);
1612
+ // The runtime tier (#86) descends below the declaration boundary. It is
1613
+ // live-only by nature — owner-referenced children exist in the cluster,
1614
+ // never in your source — so it rides the overlay and means nothing
1615
+ // without an env.
1616
+ if (view.runtime) q.set("runtime", "1");
1617
+ }
1618
+ // Radial layout (entity view only) — curl the wide DAG onto concentric rings.
1619
+ if (view.radial && !view.components && !view.logical) q.set("radial", "1");
1620
+ // The CI + resources facets are component-DAG-mode-only details. Load
1621
+ // both whenever components mode is on, env picked or not — #59 unifies
1622
+ // the CI facet (#58), the live-status join (#57), and resources (#59) so
1623
+ // a component node's inspect panel shows all of them at once, not just one.
1624
+ if (view.components) {
1625
+ await Promise.all([loadCi(), loadResources()]);
1626
+ } else {
1627
+ ciByComponent = new Map();
1628
+ resourcesByComponent = {};
1629
+ }
1630
+ const res = await apiFetch(`${endpoint}?${q}`);
1631
+ const body = await res.json();
1632
+ if (!res.ok) {
1633
+ // #72: a classified precondition failure (lint gate, not installed, a
1634
+ // tier that needs credentials) gets the calmer entry/error card instead
1635
+ // of the generic red error box — see renderPreconditionError().
1636
+ if (body.code) {
1637
+ renderPreconditionError(body);
1638
+ meta.textContent = body.code === "tier" ? `tier ${view.tier} unavailable here` : "error";
1639
+ renderDial();
1640
+ return;
1641
+ }
1642
+ throw new Error(body.error || res.statusText);
1643
+ }
1644
+ render(body.ir, body.svg, body.meta);
1645
+ } catch (err) {
1646
+ // A background settle poll must not blow away a good graph on a transient error.
1647
+ if (!opts.quiet) {
1648
+ document.getElementById("graph").innerHTML = `<div class="err">graph failed: ${err.message}</div>`;
1649
+ meta.textContent = "error";
1650
+ }
1651
+ } finally {
1652
+ if (!opts.quiet) hideLoading();
1653
+ }
1654
+ }
1655
+
1656
+ // Refresh (#24): re-check live drift now and capture a lanes frame, in one
1657
+ // round-trip. Renders the returned graph directly (no second pull); the server's
1658
+ // `frames` event updates the lanes view.
1659
+ async function refresh() {
1660
+ const meta = document.getElementById("meta");
1661
+ const prev = meta.textContent;
1662
+ meta.textContent = "refreshing…";
1663
+ showLoading("refreshing live state…");
1664
+ try {
1665
+ // Carry the active zoom's runtime flag so a refresh from the resources
1666
+ // tier doesn't come back wearing the runtime tier's Pods (#144).
1667
+ const runtime = zoomValue() === "runtime" ? "&runtime=1" : "";
1668
+ const q = view.env ? `?env=${encodeURIComponent(view.env)}${runtime}` : "";
1669
+ const res = await fetch(`/api/refresh${q}`, { method: "POST" });
1670
+ if (!res.ok) throw new Error((await res.json()).error || res.statusText);
1671
+ const { ir, svg, meta: m, captured } = await res.json();
1672
+ render(ir, svg, m);
1673
+ nowline(captured ? "↻ refreshed — new lanes frame" : "↻ refreshed — no change");
1674
+ } catch (err) {
1675
+ meta.textContent = prev;
1676
+ nowline("✗ refresh: " + err.message);
1677
+ } finally {
1678
+ hideLoading();
1679
+ }
1680
+ }
1681
+
1682
+ // The env/tier/target/stack lenses (M2 #54; stack #76) — used to be header
1683
+ // <select>s; #73 moved picking them into the ⌘K palette (paletteCommands()
1684
+ // reads these lists), so they're module-level now instead of local to the
1685
+ // picker-building closure that used to render them. Populated once in
1686
+ // initPickers().
1687
+ let environments = [];
1688
+ let tiers = [];
1689
+ let targets = [];
1690
+ let stacks = [];
1691
+
1692
+ // Fetch the project once, seed view/axes + the lens lists above, then do the
1693
+ // first load. previously also built the header's env/zoom/radial/tier/target
1694
+ // controls; those are now ⌘K palette commands (#73) — renderStatusbar() is
1695
+ // what keeps their CURRENT value visible without opening the palette.
1696
+ async function initPickers() {
1697
+ const info = await apiFetch("/api/project")
1698
+ .then((r) => r.json())
1699
+ .catch(() => ({ environments: [], currentEnv: null }));
1700
+ view.env = info.currentEnv || null;
1701
+ view.tier = info.tier || null;
1702
+ view.target = (info.targets && info.targets[0] && info.targets[0].endpoint) || null;
1703
+ // The stack picker's default (#76, design: "default = the FIRST stack when
1704
+ // the project declares stacks[] and none is selected") — mirrors `target`
1705
+ // above, which defaults to its own first option the same way. `info.stacks`
1706
+ // is only present at all when `chant.config.ts` declares `stacks[]` (see
1707
+ // server.ts's `/api/project`, gated on `info.stacks?.length`), so a project
1708
+ // with none leaves `view.stack` (and the picker + status tag) null.
1709
+ stacks = info.stacks || [];
1710
+ view.stack = stacks[0] || null;
1711
+ axes = { tier: info.tier || null, target: info.target || null };
1712
+ environments = info.environments || [];
1713
+ tiers = info.tiers || [];
1714
+ targets = info.targets || [];
1715
+ renderStatusbar();
1716
+ load();
1717
+ }
1718
+ initPickers();
1719
+
1720
+ // Live updates (#3): re-pull when the server signals the served source changed.
1721
+ // EventSource reconnects on its own if the server restarts.
1722
+ // No backend in a static export → no live event stream; a no-op keeps the
1723
+ // `events.addEventListener(...)` wiring below harmless.
1724
+ const events = staticMode ? { addEventListener() {} } : new EventSource("/api/events");
1725
+ // Post-op settle re-pull: an apply's CLI can exit while the last stacks are still
1726
+ // flipping to *_COMPLETE, so the immediate reload catches a few components mid-
1727
+ // deploy ("all done, 3 still pending"). Quietly re-pull a couple more times so
1728
+ // the graph lands on the final colours without a manual Re-check live.
1729
+ let settleTimers = [];
1730
+ function scheduleSettle() {
1731
+ settleTimers.forEach(clearTimeout);
1732
+ settleTimers = [3000, 8000, 15000].map((ms) => setTimeout(() => load({ quiet: true }), ms));
1733
+ }
1734
+ events.addEventListener("changed", () => {
1735
+ bulkDiffCache = null; // an op ran → per-node live state may have changed
1736
+ load();
1737
+ loadSubstrates(); // a bring-up (or any op) finished → re-detect readiness
1738
+ scheduleSettle();
1739
+ });
1740
+
1741
+ // Substrate readiness strip (M5, #54): is each substrate the project needs
1742
+ // actually up? Poll /api/substrates, render status pills — pure state (#73:
1743
+ // the "Bring up" / "Reset" affordances that used to sit inside each pill moved
1744
+ // into the ⌘K palette; see paletteCommands()'s use of lastSubstrates below).
1745
+ async function loadSubstrates() {
1746
+ try {
1747
+ const { substrates } = await apiFetch("/api/substrates").then((r) => r.json());
1748
+ renderSubstrates(substrates || []);
1749
+ } catch {
1750
+ /* transient — leave whatever's shown */
1751
+ }
1752
+ }
1753
+
1754
+ // The last substrates list — read by paletteCommands() to build "Bring up
1755
+ // <label>" / "Reset local emulator" entries with the same gating renderSubstrates()
1756
+ // used to apply to its now-removed inline buttons.
1757
+ let lastSubstrates = [];
1758
+
1759
+ function renderSubstrates(subs) {
1760
+ lastSubstrates = subs;
1761
+ const host = document.getElementById("substrates");
1762
+ if (!subs.length) {
1763
+ host.style.display = "none";
1764
+ return;
1765
+ }
1766
+ host.style.display = "flex";
1767
+ host.innerHTML = "";
1768
+ const lbl = document.createElement("span");
1769
+ lbl.className = "label";
1770
+ lbl.textContent = "substrates:";
1771
+ host.appendChild(lbl);
1772
+ for (const s of subs) {
1773
+ const pill = document.createElement("span");
1774
+ pill.className = `sub ${s.status}`;
1775
+ pill.title = s.bringUp || s.name === "floci" ? `${s.detail} (⌘K for actions)` : s.detail;
1776
+ const dot = document.createElement("span");
1777
+ dot.className = "dot";
1778
+ pill.appendChild(dot);
1779
+ const name = document.createElement("span");
1780
+ name.textContent = s.label;
1781
+ pill.appendChild(name);
1782
+ host.appendChild(pill);
1783
+ }
1784
+ }
1785
+
1786
+ function resetLocal() {
1787
+ if (
1788
+ !window.confirm(
1789
+ "Reset the local emulator?\nRuns local-down + local-up: wipes every stack, reboots the emulator, and redeploys all components clean.\nThis is the recovery for rolled-back stacks (Floci #16) — do NOT apply afterward (that re-apply is what collides).\n\nOutput streams in the log below; takes a few minutes.",
1790
+ )
1791
+ )
1792
+ return;
1793
+ nowline("▶ resetting local emulator (local-down + local-up, redeploys clean) …");
1794
+ fetch("/api/local/reset", { method: "POST" })
1795
+ .then((r) => r.json())
1796
+ .then((j) => nowline(j.error ? "✗ " + j.error : `▶ reset: ${j.ran} — reboots + redeploys; watch the log, no apply needed`))
1797
+ .catch((e) => nowline("✗ reset: " + e.message));
1798
+ }
1799
+
1800
+ function bringUpSubstrate(s) {
1801
+ if (
1802
+ !window.confirm(
1803
+ `Bring up ${s.label}?\nRuns: ${s.bringUp.cmd} ${s.bringUp.args.join(" ")}\nOutput streams in the log below — this can take a minute.`,
1804
+ )
1805
+ )
1806
+ return;
1807
+ nowline(`▶ bringing up ${s.label} …`);
1808
+ fetch(`/api/substrates/${encodeURIComponent(s.name)}/up`, { method: "POST" })
1809
+ .then((r) => r.json())
1810
+ .then((j) => nowline(j.error ? "✗ " + j.error : `▶ ${s.label}: ${j.ran}`))
1811
+ .catch((e) => nowline("✗ bring up: " + e.message));
1812
+ }
1813
+
1814
+ loadSubstrates();
1815
+ // Poll readiness so pills update as things come up on their own (Docker
1816
+ // starting, a bring-up provisioning) without needing a `changed` event.
1817
+ setInterval(loadSubstrates, 5000);
1818
+
1819
+ // Delegated writes (#7 Sync / #8 Adopt). behold never mutates — these buttons
1820
+ // trigger the project's committed Ops on the executor; the now-line streams phases.
1821
+ // A blocking load overlay (#slow-box): a live view change shells chant and can
1822
+ // take seconds. Cover the whole app with a scrim + spinner so a stray click
1823
+ // can't fire a second pull while one's in flight. Ref-counted — nested loads
1824
+ // (graph + CI + resources) only lift the scrim when the last finishes.
1825
+ let loadingDepth = 0;
1826
+ function showLoading(msg) {
1827
+ loadingDepth++;
1828
+ const o = document.getElementById("loading-overlay");
1829
+ if (!o) return;
1830
+ document.getElementById("loading-msg").textContent = msg || "loading…";
1831
+ o.hidden = false;
1832
+ }
1833
+ function hideLoading() {
1834
+ loadingDepth = Math.max(0, loadingDepth - 1);
1835
+ if (loadingDepth > 0) return;
1836
+ const o = document.getElementById("loading-overlay");
1837
+ if (o) o.hidden = true;
1838
+ }
1839
+
1840
+ function nowline(line) {
1841
+ const p = document.getElementById("nowline");
1842
+ p.style.display = "block";
1843
+ const d = document.createElement("div");
1844
+ d.textContent = line;
1845
+ p.appendChild(d);
1846
+ p.scrollTop = p.scrollHeight;
1847
+ }
1848
+ events.addEventListener("op", (e) => nowline(e.data));
1849
+
1850
+ // Transient toast for action feedback. The now-line is a bottom log pane that
1851
+ // is display:none until something writes to it and may be scrolled out of
1852
+ // view — an error that only lands there after the palette closed is an error
1853
+ // nobody sees. Errors get BOTH: the toast for now, the now-line for the
1854
+ // record. Auto-dismisses; click to dismiss sooner.
1855
+ function showToast(msg, ok) {
1856
+ let host = document.getElementById("toasts");
1857
+ if (!host) {
1858
+ host = document.createElement("div");
1859
+ host.id = "toasts";
1860
+ host.style.cssText = "position:fixed;top:52px;right:16px;display:flex;flex-direction:column;gap:8px;z-index:60;max-width:420px";
1861
+ document.body.appendChild(host);
1862
+ }
1863
+ const t = document.createElement("div");
1864
+ const color = ok ? "var(--managed)" : "var(--degraded)";
1865
+ t.style.cssText = `background:var(--panel);color:var(--fg);border:1px solid ${color};border-left:4px solid ${color};border-radius:8px;padding:8px 12px;font-size:12px;box-shadow:0 4px 16px rgba(0,0,0,.35);cursor:pointer;white-space:pre-wrap`;
1866
+ t.textContent = msg;
1867
+ t.onclick = () => t.remove();
1868
+ host.appendChild(t);
1869
+ setTimeout(() => t.remove(), ok ? 5000 : 10000);
1870
+ }
1871
+
1872
+ // Structured apply progress (M3, #54): the server broadcasts the full
1873
+ // ApplyProgressState (src/apply.ts) after every recognized RunProgressEvent —
1874
+ // see src/op-runner.ts's apply(). Re-render the dial's progress panel each
1875
+ // time; renderDial() is cheap (rebuilds a small DOM subtree) so no diffing.
1876
+ events.addEventListener("apply", (e) => {
1877
+ try {
1878
+ applyProgress = JSON.parse(e.data);
1879
+ } catch {
1880
+ return;
1881
+ }
1882
+ renderDial();
1883
+ });
1884
+
1885
+ function button(label, cls, onClick) {
1886
+ const b = document.createElement("button");
1887
+ b.textContent = label;
1888
+ if (cls) b.className = cls;
1889
+ b.addEventListener("click", onClick);
1890
+ return b;
1891
+ }
1892
+ function runOp(name) {
1893
+ fetch(`/api/ops/${encodeURIComponent(name)}/run`, { method: "POST" })
1894
+ .then((r) => r.json())
1895
+ .then((j) => {
1896
+ if (j.error) {
1897
+ showToast(`✗ ${name}: ${j.error}`, false);
1898
+ nowline("✗ " + j.error);
1899
+ } else {
1900
+ showToast(`▶ running ${name} — output streams in the log below`, true);
1901
+ }
1902
+ });
1903
+ }
1904
+ function signal(name, gate) {
1905
+ fetch(`/api/ops/${encodeURIComponent(name)}/signal/${encodeURIComponent(gate)}`, { method: "POST" })
1906
+ .then((r) => r.json())
1907
+ .then((j) => {
1908
+ if (j.error) {
1909
+ showToast(`✗ approve ${gate}: ${j.error}`, false);
1910
+ nowline("✗ " + j.error);
1911
+ } else {
1912
+ showToast(`✓ approved ${gate}`, true);
1913
+ }
1914
+ });
1915
+ }
1916
+ // Adopt is a per-node gesture (a *foreign* node → ReconcileOp → PR), so it lives
1917
+ // in the inspect panel, not the global bar. Stash the reconcile op + the
1918
+ // live-import lexicons the server allows so inspect() can gate the button.
1919
+ let adopt = { reconcile: null, lexicons: [] };
1920
+ function adoptable(node) {
1921
+ // `warn` is chant's overlay tag for foreign — "foreign" is only ever the
1922
+ // display label (STATUS_LABEL above), never the attr's value (#145).
1923
+ return (
1924
+ adopt.reconcile &&
1925
+ node.attrs &&
1926
+ node.attrs._status === "warn" &&
1927
+ adopt.lexicons.includes(node.lexicon)
1928
+ );
1929
+ }
1930
+
1931
+ // Rollback (#28): fetch recent source commits, let the user pick one, and trigger
1932
+ // the delegated rollback (opens a PR). Replaces the button with a picker + confirm.
1933
+ async function openRollback(btn) {
1934
+ const commits = await fetch("/api/history")
1935
+ .then((r) => r.json())
1936
+ .then((j) => j.commits)
1937
+ .catch(() => []);
1938
+ if (!commits.length) {
1939
+ nowline("rollback: no git history found");
1940
+ return;
1941
+ }
1942
+ const wrap = document.createElement("span");
1943
+ wrap.style.cssText = "display:flex;gap:6px;align-self:center";
1944
+ const sel = document.createElement("select");
1945
+ sel.style.cssText =
1946
+ "background:var(--panel);color:var(--fg);border:1px solid var(--line);border-radius:6px;padding:4px 8px;font-size:12px;max-width:340px";
1947
+ for (const c of commits) sel.add(new Option(`${c.sha} · ${c.subject} (${c.date})`, c.sha));
1948
+ const go = button("Roll back →", "", () => {
1949
+ const to = sel.value;
1950
+ if (!window.confirm(`Open a rollback PR restoring source to ${to}?\nA human reviews + merges, then Sync applies it.`)) return;
1951
+ fetch(`/api/rollback?to=${encodeURIComponent(to)}`, { method: "POST" })
1952
+ .then((r) => r.json())
1953
+ .then((j) => nowline(j.error ? "✗ " + j.error : `▶ rollback → ${to} (opening PR…)`));
1954
+ wrap.remove();
1955
+ });
1956
+ const cancel = button("✕", "", () => wrap.remove());
1957
+ wrap.append(sel, go, cancel);
1958
+ btn.replaceWith(wrap);
1959
+ }
1960
+
1961
+ // #73: Rollback used to be a permanent toolbar button (openRollback(rb) swapped
1962
+ // it for the picker, then restored it after). Triggered from the ⌘K palette
1963
+ // instead now — there's no permanent button to restore, so this drops a
1964
+ // throwaway one into the (now otherwise action-free) actions bar just to host
1965
+ // the inline picker; openRollback's own wrap.remove() above cleans it up.
1966
+ function paletteRollback() {
1967
+ const bar = document.getElementById("actions");
1968
+ const rb = button("Rollback", "", () => {});
1969
+ bar.appendChild(rb);
1970
+ openRollback(rb);
1971
+ }
1972
+
1973
+ // Deploy/ops state for the ⌘K palette (#73) — populated once in initActions(),
1974
+ // which used to turn each of these straight into a toolbar button. previewMode
1975
+ // (declared above, near view) is what the palette gates on: it hides exactly
1976
+ // the git/PR write actions the buttons hid (Rollback, Sync, Run <op>) — Apply
1977
+ // all, Reset, Bring up, Approve, and every read stay reachable in preview,
1978
+ // same as before.
1979
+ let opsApply = null; // the committed ApplyOp ({name, gate}), or null
1980
+ let opsRunnable = []; // generic Ops (backup/restore/audit/…) for "Run: <name>"
1981
+ let opsInitialEnv = null; // env behold launched with — gates "Apply all"
1982
+
1983
+ async function initActions() {
1984
+ const bar = document.getElementById("actions");
1985
+ if (staticMode) {
1986
+ // A frozen snapshot — no live actions at all. Show when it was captured.
1987
+ const pill = document.createElement("span");
1988
+ pill.textContent = `● static snapshot${manifest && manifest.capturedAt ? " · " + manifest.capturedAt.slice(0, 16).replace("T", " ") : ""}`;
1989
+ pill.title = "An exported, read-only snapshot — no live observe or deploy.";
1990
+ pill.style.cssText = "align-self:center;font-size:11px;color:var(--muted);border:1px solid var(--line);border-radius:6px;padding:2px 8px";
1991
+ bar.appendChild(pill);
1992
+ previewMode = true;
1993
+ return; // nothing else in the bar is a read
1994
+ }
1995
+ // The env behold launched with (reliable regardless of picker-init ordering) —
1996
+ // gates the first-class "Apply all" affordance below; also carries the preview
1997
+ // lock that hides the git/PR ops.
1998
+ const project = await apiFetch("/api/project").then((r) => r.json()).catch(() => ({}));
1999
+ opsInitialEnv = project.currentEnv || null;
2000
+ previewMode = staticMode || !!project.previewMode; // static ⇒ read-only, no writes at all
2001
+ const { ops, adoptLexicons, autoSync, local, applyProgress: apInit } = await apiFetch("/api/ops")
2002
+ .then((r) => r.json())
2003
+ .catch(() => ({ ops: [], adoptLexicons: [] }));
2004
+ // M3 (#54): hydrate the dial's apply progress from the server's last known
2005
+ // state — a page load (or reload) mid-apply picks up the structured view
2006
+ // instead of starting blank; the `apply` SSE listener keeps it live from here.
2007
+ if (apInit && apInit.waves && apInit.waves.length) {
2008
+ applyProgress = apInit;
2009
+ renderDial();
2010
+ }
2011
+ // Local-mode banner (#46) — the emulator(s) behold booted with --local, so it's
2012
+ // obvious deploys/overlay hit them (no cloud creds), not a real account.
2013
+ if (local && local.emulators && local.emulators.length) {
2014
+ const pill = document.createElement("span");
2015
+ const names = local.emulators.map((e) => e.name).join(", ");
2016
+ pill.textContent = `● local · ${names} up`;
2017
+ pill.title =
2018
+ "Emulator(s) booted by --local: " +
2019
+ local.emulators.map((e) => `${e.lexicon} ${e.name} @ ${e.endpoint}`).join("; ") +
2020
+ ". Deploys and the overlay observe them — no cloud creds.";
2021
+ pill.style.cssText =
2022
+ "align-self:center;font-size:11px;color:var(--managed);border:1px solid var(--managed);border-radius:6px;padding:2px 8px";
2023
+ bar.appendChild(pill);
2024
+ }
2025
+ // Auto-sync banner (#29) — make an active self-heal loop visible, not silent.
2026
+ if (autoSync && autoSync !== "off") {
2027
+ const pill = document.createElement("span");
2028
+ pill.textContent = `⟳ auto-sync: ${autoSync}`;
2029
+ pill.title = `On polled drift, behold triggers the ${autoSync === "apply" ? "ApplyOp (heal)" : "ReconcileOp (adopt)"}. Gated applies still wait for Approve.`;
2030
+ pill.style.cssText =
2031
+ "align-self:center;font-size:11px;color:var(--pending);border:1px solid var(--pending);border-radius:6px;padding:2px 8px";
2032
+ bar.appendChild(pill);
2033
+ }
2034
+ opsApply = ops.find((o) => o.kind === "apply") ?? null;
2035
+ adopt = { reconcile: ops.find((o) => o.kind === "reconcile") ?? null, lexicons: adoptLexicons ?? [] };
2036
+ // Generic Ops (backup, restore, seed, watch, teardown, …) — "Run: <name>" in
2037
+ // the palette, same set the old "Run ▾" dropdown offered.
2038
+ opsRunnable = ops.filter((o) => o.kind === "op" || o.kind === "audit");
2039
+
2040
+ // The one visible deploy affordance (#73 follow-up). Moving every write into
2041
+ // the ⌘K palette left the header with zero action buttons — behold looked
2042
+ // read-only unless you happened to press ⌘K, and the docs' "click Run/Sync"
2043
+ // still described the old toolbar. The PRIMARY deploy gesture gets a real
2044
+ // button back; everything else stays in the palette. One intent, one word:
2045
+ // the button says Deploy whether the project routes it through a committed
2046
+ // ApplyOp or a raw `chant run --components` — the tooltip carries the
2047
+ // mechanism.
2048
+ if (opsApply && !previewMode) {
2049
+ const deploy = button(`▶ Deploy (${opsApply.name})`, "", () => {
2050
+ if (window.confirm(`Run the committed ApplyOp "${opsApply.name}"?\nDelegated write: behold triggers, chant's executor applies.`)) runOp(opsApply.name);
2051
+ });
2052
+ deploy.title = `chant run ${opsApply.name} — the committed ApplyOp (Build → Plan → Apply). Also in ⌘K as "Deploy: Sync".`;
2053
+ bar.appendChild(deploy);
2054
+ if (opsApply.gate) {
2055
+ const approve = button(`Approve ${opsApply.gate}`, "approve", () => signal(opsApply.name, opsApply.gate));
2056
+ approve.title = `chant run signal ${opsApply.name} ${opsApply.gate} — releases the Op's human gate.`;
2057
+ bar.appendChild(approve);
2058
+ }
2059
+ } else if (opsInitialEnv || view.env) {
2060
+ const deploy = button("▶ Deploy…", "", () => {
2061
+ if (!view.env) {
2062
+ showToast("Deploy needs an environment — pick one in ⌘K (env: …)", false);
2063
+ return;
2064
+ }
2065
+ applyPicker = true;
2066
+ loadComponentChoices().then(renderDial);
2067
+ renderDial();
2068
+ document.getElementById("dial").scrollIntoView({ block: "nearest" });
2069
+ });
2070
+ deploy.title = `chant run <component|all> --components --env ${view.env || opsInitialEnv} --progress-json — opens the component picker on the dial. behold triggers, chant executes.`;
2071
+ bar.appendChild(deploy);
2072
+ }
2073
+
2074
+ // Only complain when there's genuinely nothing to do (never in the preview —
2075
+ // its deploy path is Apply all, not committed Ops).
2076
+ if (ops.length === 0 && !previewMode && !opsInitialEnv) {
2077
+ const hint = document.createElement("span");
2078
+ hint.style.cssText = "color:var(--muted);font-size:11px;align-self:center";
2079
+ hint.textContent = "no Ops — commit an *.op.ts (ApplyOp / ReconcileOp / any deploy Op) to act";
2080
+ hint.title = "behold triggers committed Ops on your executor. Add one to enable Deploy / Adopt / Run.";
2081
+ bar.appendChild(hint);
2082
+ }
2083
+ }
2084
+ initActions();
2085
+ initInspectPane();
2086
+
2087
+ // Inspect pane chrome (#15): collapse (chevron / edge tab) + drag-to-resize, with
2088
+ // the width and collapsed state persisted so the layout survives reloads.
2089
+ // setInspectCollapsed/toggleInspect are module-level (not local to initInspectPane)
2090
+ // so the ⌘K palette (#73) can drive the same collapse/reopen the chevron does.
2091
+ function setInspectCollapsed(on) {
2092
+ document.getElementById("app").classList.toggle("inspect-collapsed", on);
2093
+ localStorage.setItem("behold.inspectCollapsed", on ? "1" : "0");
2094
+ }
2095
+ function toggleInspect() {
2096
+ setInspectCollapsed(!document.getElementById("app").classList.contains("inspect-collapsed"));
2097
+ }
2098
+
2099
+ function initInspectPane() {
2100
+ const app = document.getElementById("app");
2101
+ const pane = document.getElementById("inspect");
2102
+ const MIN = 240, MAX = 720;
2103
+ // Restore persisted width + collapsed state.
2104
+ const savedW = Number(localStorage.getItem("behold.inspectW"));
2105
+ if (savedW >= MIN && savedW <= MAX) document.documentElement.style.setProperty("--inspect-w", savedW + "px");
2106
+ if (localStorage.getItem("behold.inspectCollapsed") === "1") app.classList.add("inspect-collapsed");
2107
+
2108
+ document.getElementById("inspect-collapse").addEventListener("click", () => setInspectCollapsed(true));
2109
+ document.getElementById("inspect-reopen").addEventListener("click", () => setInspectCollapsed(false));
2110
+
2111
+ // Drag the left edge to resize; width grows as the handle moves left.
2112
+ const handle = document.getElementById("inspect-resize");
2113
+ handle.addEventListener("mousedown", (e) => {
2114
+ e.preventDefault();
2115
+ const startX = e.clientX;
2116
+ const startW = pane.getBoundingClientRect().width;
2117
+ document.body.style.cursor = "col-resize";
2118
+ const onMove = (ev) => {
2119
+ const w = Math.min(MAX, Math.max(MIN, startW + (startX - ev.clientX)));
2120
+ document.documentElement.style.setProperty("--inspect-w", w + "px");
2121
+ };
2122
+ const onUp = () => {
2123
+ document.body.style.cursor = "";
2124
+ window.removeEventListener("mousemove", onMove);
2125
+ window.removeEventListener("mouseup", onUp);
2126
+ const w = parseInt(getComputedStyle(document.documentElement).getPropertyValue("--inspect-w"));
2127
+ if (w) localStorage.setItem("behold.inspectW", String(w));
2128
+ // Graph viewBox is pane-relative; refit so nothing clips after a resize.
2129
+ if (typeof fitGraph === "function") fitGraph();
2130
+ };
2131
+ window.addEventListener("mousemove", onMove);
2132
+ window.addEventListener("mouseup", onUp);
2133
+ });
2134
+ }
2135
+
2136
+ // The opened PR (chant #841 surfaces it as a ReconcileOp outcome). Link it in the
2137
+ // now-line and pin it in the header so the review target is one click away.
2138
+ events.addEventListener("pr", (e) => {
2139
+ const url = e.data;
2140
+ nowline("→ opened PR: " + url);
2141
+ let slot = document.getElementById("pr-link");
2142
+ if (!slot) {
2143
+ slot = document.createElement("a");
2144
+ slot.id = "pr-link";
2145
+ slot.target = "_blank";
2146
+ slot.rel = "noopener";
2147
+ slot.style.cssText = "color:var(--managed);text-decoration:none;font-size:12px;align-self:center";
2148
+ document.getElementById("actions").after(slot);
2149
+ }
2150
+ slot.href = url;
2151
+ slot.textContent = "PR opened →";
2152
+ });
2153
+
2154
+ // Export the current graph as a standalone SVG file — the inlined pinhole SVG
2155
+ // already IS the full rendered graph (see render()), so this is a pure client-
2156
+ // side download, no server round-trip. Works in a static export too.
2157
+ function exportSvg() {
2158
+ const svg = currentSvg();
2159
+ if (!svg) {
2160
+ nowline("✗ export: no graph loaded yet");
2161
+ return;
2162
+ }
2163
+ const blob = new Blob([svg.outerHTML], { type: "image/svg+xml" });
2164
+ const url = URL.createObjectURL(blob);
2165
+ const a = document.createElement("a");
2166
+ a.href = url;
2167
+ a.download = `behold-${zoomValue()}${view.env ? "-" + view.env : ""}.svg`;
2168
+ a.style.display = "none";
2169
+ document.body.appendChild(a);
2170
+ a.click();
2171
+ a.remove();
2172
+ URL.revokeObjectURL(url);
2173
+ nowline(`↓ exported ${a.download}`);
2174
+ }
2175
+
2176
+ // --- Command palette (⌘K / Ctrl+K, #73) -----------------------------------
2177
+ // Ported from spicypath's FG-036 pattern (../spicypath/src/index.html: search
2178
+ // `#palette`, `paletteCommands`, `openPalette`) — same shape (module-level
2179
+ // palCmds/palSel/palCurrent, paletteCommands() rebuilding a fresh {label, run}
2180
+ // list from live state on every open, palRender() filtering + repainting,
2181
+ // openPalette()/closePalette() toggling the `.on` class), retargeted at
2182
+ // behold's own handlers instead of spicypath's. "Hide controls, never state"
2183
+ // (spicypath's own design rule, carried over): every action this moves out of
2184
+ // the toolbar is still reachable here; zoom/env/tier/drift/substrates stay
2185
+ // visible in the header regardless (renderStatusbar(), #substrates pills,
2186
+ // #meta) — see index.html's #statusbar comment.
2187
+ const palette = document.getElementById("palette");
2188
+ const palInput = document.getElementById("pal-input");
2189
+ const palList = document.getElementById("pal-list");
2190
+ let palCmds = [], palSel = 0, palCurrent = [];
2191
+
2192
+ // Builds the live command list fresh on every open, so it always reflects
2193
+ // current state (which env is picked, whether the inspect pane is collapsed,
2194
+ // what previewMode hides) rather than a snapshot from page load.
2195
+ function paletteCommands() {
2196
+ const c = [];
2197
+
2198
+ // Reads — always available, even in a static export or the preview lock.
2199
+ if (!staticMode) c.push(["Re-check live (refresh drift)", () => refresh()]);
2200
+ c.push(["Fit graph to view", () => fitGraph()]);
2201
+ c.push(["Export: current graph as SVG", () => exportSvg()]);
2202
+ const inspectCollapsed = document.getElementById("app").classList.contains("inspect-collapsed");
2203
+ c.push([inspectCollapsed ? "Show inspect panel" : "Hide inspect panel", () => toggleInspect()]);
2204
+
2205
+ // Lens/zoom switches (#56, #63) — replaces the old header zoom picker.
2206
+ for (const [label, v] of ZOOM_OPTS) {
2207
+ c.push([label + (v === zoomValue() ? " ✓" : ""), () => { applyZoom(v); load(); }]);
2208
+ }
2209
+ // Radial toggle — entity zooms only, same gate the removed checkbox had
2210
+ // (components/logical both lay themselves out: waves / nested arch boxes).
2211
+ if (!view.components && !view.logical) {
2212
+ c.push([(view.radial ? "Disable" : "Enable") + " radial layout", () => { view.radial = !view.radial; load(); }]);
2213
+ }
2214
+
2215
+ // Env/stack/tier/target selection — replaces the old header pickers.
2216
+ c.push(["env: (source)" + (!view.env ? " ✓" : ""), () => { view.env = null; resetDialCaches(); load(); }]);
2217
+ for (const e of environments) {
2218
+ c.push([`env: ${e}` + (view.env === e ? " ✓" : ""), () => { view.env = e; resetDialCaches(); load(); }]);
2219
+ }
2220
+ // The stack picker (#76, follow-up to #71) — only ever populated (`stacks`,
2221
+ // seeded in initPickers()) when the served project declares `chant.config.
2222
+ // ts`'s `stacks[]`; empty on every single-stack/sourceDir-only/legacy
2223
+ // project, so this loop (and the status strip's `stack:` tag) is a no-op
2224
+ // there. No "stack: (none)" escape hatch like env's "(source)" above —
2225
+ // the design's locked default is always the first declared stack, never "no
2226
+ // stack selected".
2227
+ for (const s of stacks) {
2228
+ c.push([`stack: ${s}` + (view.stack === s ? " ✓" : ""), () => { view.stack = s; resetDialCaches(); load(); }]);
2229
+ }
2230
+ for (const t of tiers) {
2231
+ c.push([`tier: ${t}` + (view.tier === t ? " ✓" : ""), () => { view.tier = t; resetDialCaches(); load(); }]);
2232
+ }
2233
+ for (const t of targets) {
2234
+ c.push([`target: ${t.endpoint}` + (view.target === t.endpoint ? " ✓" : ""), () => { view.target = t.endpoint; resetDialCaches(); load(); }]);
2235
+ }
2236
+
2237
+ if (staticMode) return c.map(([label, run]) => ({ label, run })); // no writes at all in a static export
2238
+
2239
+ // Deploy / write actions — previewMode hides exactly the git/PR write
2240
+ // affordances the toolbar hid (Rollback, Sync, Run <op>); Apply all, Reset,
2241
+ // Bring up, and Approve stay reachable, mirroring initActions()'s and
2242
+ // renderSubstrates()'s previous button-gating precisely.
2243
+ if (!previewMode) {
2244
+ if (opsApply) {
2245
+ c.push([`Deploy: Sync (${opsApply.name})`, () => runOp(opsApply.name)]);
2246
+ if (opsApply.gate) c.push([`Approve ${opsApply.gate}`, () => signal(opsApply.name, opsApply.gate)]);
2247
+ }
2248
+ c.push(["Rollback to a prior revision…", () => paletteRollback()]);
2249
+ for (const op of opsRunnable) {
2250
+ c.push([`Run: ${op.name}`, () => { if (window.confirm(`Run Op "${op.name}"?`)) runOp(op.name); }]);
2251
+ }
2252
+ }
2253
+ // No committed ApplyOp, but there's an env → "Apply all" is the equivalent
2254
+ // deploy action (M3) — stays available in preview (a local write, not git/PR).
2255
+ if (!opsApply && opsInitialEnv) c.push([`Deploy: Apply all → ${opsInitialEnv}`, () => confirmApplyAll()]);
2256
+
2257
+ for (const s of lastSubstrates) {
2258
+ if (s.bringUp) c.push([`Bring up ${s.label}`, () => bringUpSubstrate(s)]);
2259
+ if (s.name === "floci" && s.status === "up") c.push(["Reset local emulator (Floci)", () => resetLocal()]);
2260
+ // Dispatch a GitHub Actions run (#164) — through the operator's own `gh`
2261
+ // login. Offered whenever the pill exists; the server refuses honestly
2262
+ // (no gh, unauthenticated, no matching workflow_dispatch workflow) and
2263
+ // the reason lands as a toast.
2264
+ if (s.name === "github" && !previewMode) {
2265
+ c.push([
2266
+ "Run pipeline: GitHub Actions",
2267
+ () => {
2268
+ if (!window.confirm("Dispatch the GitHub Actions pipeline?\nRuns via YOUR gh login (gh workflow run); behold follows the run on the dial.")) return;
2269
+ fetch("/api/ci/dispatch", { method: "POST" })
2270
+ .then((r) => r.json())
2271
+ .then((j) => {
2272
+ if (j.error) {
2273
+ showToast(`✗ dispatch: ${j.error}`, false);
2274
+ nowline("✗ dispatch: " + j.error);
2275
+ } else {
2276
+ showToast(`▶ dispatched ${j.workflow} @ ${j.ref} (${j.jobs} jobs) — following on the dial`, true);
2277
+ }
2278
+ });
2279
+ },
2280
+ ]);
2281
+ }
2282
+ }
2283
+
2284
+ return c.map(([label, run]) => ({ label, run }));
2285
+ }
2286
+
2287
+ function palRender() {
2288
+ const q = palInput.value.toLowerCase().trim();
2289
+ palCurrent = q ? palCmds.filter((c) => c.label.toLowerCase().includes(q)) : palCmds;
2290
+ palSel = Math.max(0, Math.min(palSel, palCurrent.length - 1));
2291
+ palList.replaceChildren();
2292
+ if (!palCurrent.length) {
2293
+ const e = document.createElement("div");
2294
+ e.className = "empty";
2295
+ e.textContent = "no matching command";
2296
+ palList.appendChild(e);
2297
+ return;
2298
+ }
2299
+ palCurrent.forEach((c, i) => {
2300
+ const d = document.createElement("div");
2301
+ d.className = "row" + (i === palSel ? " sel" : "");
2302
+ d.textContent = c.label;
2303
+ d.onmousedown = (ev) => {
2304
+ ev.preventDefault();
2305
+ closePalette();
2306
+ c.run();
2307
+ };
2308
+ palList.appendChild(d);
2309
+ });
2310
+ }
2311
+ function openPalette() {
2312
+ palCmds = paletteCommands();
2313
+ palSel = 0;
2314
+ palInput.value = "";
2315
+ palRender();
2316
+ palette.classList.add("on");
2317
+ palInput.focus();
2318
+ }
2319
+ function closePalette() {
2320
+ palette.classList.remove("on");
2321
+ }
2322
+ palInput.oninput = () => { palSel = 0; palRender(); };
2323
+ palInput.onkeydown = (e) => {
2324
+ if (e.key === "ArrowDown") { palSel++; palRender(); e.preventDefault(); }
2325
+ else if (e.key === "ArrowUp") { palSel--; palRender(); e.preventDefault(); }
2326
+ else if (e.key === "Enter") { const c = palCurrent[palSel]; closePalette(); if (c) c.run(); e.preventDefault(); }
2327
+ else if (e.key === "Escape") { closePalette(); e.stopPropagation(); e.preventDefault(); }
2328
+ };
2329
+ palette.onmousedown = (e) => { if (e.target === palette) closePalette(); }; // backdrop click
2330
+ document.getElementById("hintk").addEventListener("click", openPalette);
2331
+
2332
+ // Global ⌘K / Ctrl+K toggle — fires even while focus is inside another input
2333
+ // (e.g. an apply-picker <select>), matching spicypath's own keymap.
2334
+ window.addEventListener("keydown", (e) => {
2335
+ const mod = e.metaKey || e.ctrlKey;
2336
+ if (mod && (e.key === "k" || e.key === "K")) {
2337
+ e.preventDefault();
2338
+ palette.classList.contains("on") ? closePalette() : openPalette();
2339
+ }
2340
+ });
2341
+
2342
+ // Readable text on the accent-filled selected row (spicypath's --on-accent),
2343
+ // across all 552 Ghostty themes — behold has no static equivalent since
2344
+ // --pending's lightness varies per theme, so this reuses theme.js's own
2345
+ // contrast helper (the same one recolorNodesByCategory() uses for node labels)
2346
+ // instead of assuming light-on-dark or dark-on-light.
2347
+ function applyPaletteContrast() {
2348
+ const t = getTokens();
2349
+ if (t) document.documentElement.style.setProperty("--pal-sel-fg", readableOn(t.pending));
2350
+ }
2351
+ applyPaletteContrast();
2352
+ onThemeChange(applyPaletteContrast);