@intentius/behold 0.27.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/behold",
3
- "version": "0.27.0",
3
+ "version": "0.29.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "description": "behold — a live control plane on chant. See your whole estate (every substrate in one graph), coloured by drift; act through delegated, gated Ops.",
@@ -41,9 +41,9 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "@hono/node-server": "^2.0.5",
44
- "@intentius/chant": "^0.124.20",
45
- "@intentius/chant-k8s-client": "^0.124.20",
46
- "@intentius/pinhole": "^0.4.0",
44
+ "@intentius/chant": "^0.124.24",
45
+ "@intentius/chant-k8s-client": "^0.124.24",
46
+ "@intentius/pinhole": "^0.5.0",
47
47
  "ajv": "^8.20.0",
48
48
  "hono": "^4.12.34"
49
49
  },
package/web/app.js CHANGED
@@ -18,10 +18,11 @@ import { renderLane } from "./terragucci-lifecycle.js";
18
18
  import { renderReleases } from "./releases.js";
19
19
  import { renderYodelLane } from "./sql-yodeler-lane.js";
20
20
  import { initTerragucciEstate } from "./terragucci-estate.js";
21
+ import { changeSection as tgChangeSection, readCardIds as tgReadCardIds, readEdges as tgReadEdges, readLine as tgReadLine, readTag as tgReadTag } from "./terragucci-change.js";
21
22
  import { auditLines as yrAuditLines, cornerText as yrCorner, datedWords as yrDated, environmentLines as yrEnvLines, joinMarks as yrJoin, legend as yrLegend, markLine as yrMarkLine } from "./yodel-report.js";
22
23
  import { cardProgress as tgCardProgress, progressLines as tgProgressLines, progressSection as tgProgressSection, stampProgress as tgStampProgress } from "./terragucci-progress.js";
23
24
  import { seconds, whyQuery, whyView } from "./why.js";
24
- import { edgeLabel, edgeLegend, edgeTitle, isCiKind } from "./edge-kind.js";
25
+ import { edgeKey, edgeLabel, edgeLegend, edgeTitle, isCiKind, viaIndex } from "./edge-kind.js";
25
26
  import { readCostLine } from "./read-cost.js";
26
27
  import { mountHistoryPicker } from "./history-picker.js";
27
28
  import { applyMemberFrame, pendingLine, stillPending } from "./pending.js";
@@ -305,6 +306,10 @@ function markStatusChanges(ir, statusVar) {
305
306
  // replay every read from `snapshots/` — the graph, zoom dial, radial, inspect,
306
307
  // and env/tier pickers all work; live observe + all writes are off.
307
308
  const staticMode = !!window.__BEHOLD_STATIC__;
309
+ // #569: `behold serve --public` injects this flag into the page it serves. The
310
+ // reads stay live (the SSE stream, the polls); every control that writes is
311
+ // hidden, and the server refuses every method but GET, HEAD and OPTIONS anyway.
312
+ const publicMode = !!window.__BEHOLD_PUBLIC__;
308
313
  let manifest = null;
309
314
  if (staticMode) {
310
315
  try {
@@ -620,6 +625,7 @@ function stopWhyRead() {
620
625
  }
621
626
 
622
627
  function renderWhySection(panel, ask) {
628
+ if (publicMode) return; // #569: /api/workspace/why is refused in a public view
623
629
  stopWhyRead();
624
630
  if (staticMode) return;
625
631
  const memberName = ask.member || nodeMember(ask.node || "");
@@ -941,6 +947,21 @@ function inspect(node) {
941
947
  panel.appendChild(p);
942
948
  }
943
949
  }
950
+ // #568: the reads this card's root makes or answers, from the pull
951
+ // request's plan or the run view, dated by its run.
952
+ const tgReadRoot = tgAnswer && !tgAnswer.error && tgRootOfCard(tgAnswer, node.id);
953
+ const tgReads = tgReadRoot ? tgReadEdges(tgAnswer, tgProgress).filter((e) => e.from === tgReadRoot || e.to === tgReadRoot) : [];
954
+ if (tgReads.length) {
955
+ const h = document.createElement("h3");
956
+ h.textContent = "terragucci reads between roots (not live)";
957
+ panel.appendChild(h);
958
+ for (const e of tgReads) {
959
+ const p = document.createElement("p");
960
+ p.className = "tg-inspect";
961
+ p.textContent = tgReadLine(e);
962
+ panel.appendChild(p);
963
+ }
964
+ }
944
965
  // #506: the audit record's entries for this card's root (the record names
945
966
  // roots, never a resource), newest first.
946
967
  const tgRoot = tgTimeline && tgRootOfCard(tgAnswer, node.id);
@@ -1280,6 +1301,7 @@ let bulkDiffCache = null; // { env, nodes: { <id>: { observed, diff, health } }
1280
1301
  // without anyone paying for a second full pass over the account.
1281
1302
  let planDriftOnce = false;
1282
1303
  async function loadNodeDiff(id) {
1304
+ if (publicMode) return null; // #569: /api/diff is refused in a public view
1283
1305
  if (!bulkDiffCache || bulkDiffCache.env !== view.env) {
1284
1306
  bulkDiffCache = null;
1285
1307
  try {
@@ -1585,7 +1607,7 @@ function bootZoom(memberKinds) {
1585
1607
  // v0.1.0 preview lock (set from /api/project in initActions): hides the git/PR
1586
1608
  // write ops (Rollback, Sync, Adopt, Run ▾) — the server also 403s them. Local
1587
1609
  // deploy (Apply all / dial), Reset, Bring up, Approve, and reads stay on.
1588
- let previewMode = false;
1610
+ let previewMode = publicMode; // #569: a public view hides what preview hides, and more
1589
1611
  // #489: set when /api/project says the repo is a terragucci repo — applied by
1590
1612
  // its pipeline only, so the page offers no write at all.
1591
1613
  let terragucciRepo = null;
@@ -1957,7 +1979,7 @@ function copyToClipboard(text, el) {
1957
1979
  * `carve.json`.
1958
1980
  */
1959
1981
  function renderChoudoufuMoves(host, info) {
1960
- if (!info || !info.plans || !info.plans.length || staticMode) return;
1982
+ if (!info || !info.plans || !info.plans.length || staticMode || publicMode) return;
1961
1983
  host.appendChild(panelHeading("moves (choudoufu)"));
1962
1984
  for (const plan of info.plans) {
1963
1985
  const head = document.createElement("div");
@@ -2112,7 +2134,7 @@ function renderPanelScope() {
2112
2134
  name.textContent = pathBasename(dir) + (estateDirs.length > 1 && dir === info.projectDir ? " · primary" : "");
2113
2135
  name.title = dir;
2114
2136
  row.appendChild(name);
2115
- if (!staticMode) row.appendChild(actButton("⌖ reveal", () => revealProject(dir), "Open this project's folder in your file manager"));
2137
+ if (!staticMode && !publicMode) row.appendChild(actButton("⌖ reveal", () => revealProject(dir), "Open this project's folder in your file manager"));
2116
2138
  host.appendChild(row);
2117
2139
  host.appendChild(panelMuted(dir));
2118
2140
  }
@@ -2195,7 +2217,7 @@ function renderPanelScope() {
2195
2217
  // #234's last remainder — "is prod running what staging tested". Only when
2196
2218
  // an env is picked and there is another to compare against; ledger-only, so
2197
2219
  // it never adds a cloud read to opening the panel (fetch happens on pick).
2198
- if (view.env && environments.length > 1 && !staticMode) {
2220
+ if (view.env && environments.length > 1 && !staticMode && !publicMode) {
2199
2221
  if (view.compareTo === view.env) view.compareTo = null; // env re-picked onto the compared one
2200
2222
  host.appendChild(panelHeading("compare releases"));
2201
2223
  const sel = document.createElement("select");
@@ -2517,7 +2539,7 @@ function gatesEnv() {
2517
2539
  return view.gates || view.env || "";
2518
2540
  }
2519
2541
  function loadWorkspaceGates({ force = false } = {}) {
2520
- if (staticMode) return;
2542
+ if (staticMode || publicMode) return;
2521
2543
  const env = gatesEnv();
2522
2544
  if (!force && wsGates.env === env) return paintWorkspaceGates();
2523
2545
  // Every read is numbered; only the newest one paints, so an answer for an
@@ -3388,8 +3410,89 @@ async function loadTgProgress({ fresh = false } = {}) {
3388
3410
  }
3389
3411
  function paintTgProgress() {
3390
3412
  tgStampProgress(document.querySelector("#graph svg"), tgProgress, new Date());
3413
+ drawTgReads(); // #568: the run view's reads, once the change merged
3391
3414
  renderTerragucciPanel();
3392
3415
  }
3416
+ // #568: the reads edges between roots of one repo, drawn over the card view:
3417
+ // a dashed line from the reader (its data card when the checkout draws one,
3418
+ // else its root's cards) to the root it reads, behind the cards, plus a tag on
3419
+ // the reader's data card ("reads network · planned · 1 unknown"). From the
3420
+ // pull request's plan, or from the run view once merged. Not live; redrawn on
3421
+ // each render, so a card dragged since moves its line on the next one.
3422
+ function drawTgReads() {
3423
+ const svg = document.querySelector("#graph svg");
3424
+ if (!svg) return;
3425
+ for (const old of svg.querySelectorAll("[data-tg-reads]")) old.remove();
3426
+ if (!tgAnswer || tgAnswer.error || !tgAnswer.roots) return;
3427
+ const edges = tgReadEdges(tgAnswer, tgProgress);
3428
+ if (!edges.length) return;
3429
+ const toSvg = svg.getScreenCTM && svg.getScreenCTM();
3430
+ if (!toSvg) return;
3431
+ const inv = toSvg.inverse();
3432
+ // A card's box in the svg's own coordinates, whatever transform a layout put on it.
3433
+ const boxOf = (g) => {
3434
+ let b;
3435
+ try {
3436
+ b = g.getBBox();
3437
+ } catch {
3438
+ return null;
3439
+ }
3440
+ const ctm = g.getScreenCTM && g.getScreenCTM();
3441
+ if (!ctm || !b.width) return null;
3442
+ const m = inv.multiply(ctm);
3443
+ const pts = [[b.x, b.y], [b.x + b.width, b.y + b.height]].map(([x, y]) => new DOMPoint(x, y).matrixTransform(m));
3444
+ return { x: Math.min(pts[0].x, pts[1].x), y: Math.min(pts[0].y, pts[1].y), x2: Math.max(pts[0].x, pts[1].x), y2: Math.max(pts[0].y, pts[1].y) };
3445
+ };
3446
+ const union = (boxes) => (boxes.length ? { x: Math.min(...boxes.map((b) => b.x)), y: Math.min(...boxes.map((b) => b.y)), x2: Math.max(...boxes.map((b) => b.x2)), y2: Math.max(...boxes.map((b) => b.y2)) } : null);
3447
+ const rootBox = (path) => {
3448
+ const r = tgAnswer.roots.find((x) => x.path === path);
3449
+ if (!r) return null;
3450
+ const prefix = `${r.member}/${r.name}/`;
3451
+ return union([...svg.querySelectorAll("[data-node-id]")].filter((g) => (g.getAttribute("data-node-id") || "").startsWith(prefix)).map(boxOf).filter(Boolean));
3452
+ };
3453
+ const NS = "http://www.w3.org/2000/svg";
3454
+ const layer = document.createElementNS(NS, "g");
3455
+ layer.setAttribute("data-tg-reads", "1");
3456
+ layer.setAttribute("pointer-events", "none");
3457
+ for (const e of edges) {
3458
+ const card = tgReadCardIds(tgAnswer, e).map((id) => svg.querySelector('[data-node-id="' + CSS.escape(id) + '"]')).find(Boolean);
3459
+ const from = (card && boxOf(card)) || rootBox(e.from);
3460
+ const to = rootBox(e.to);
3461
+ if (card) {
3462
+ const tag = document.createElementNS(NS, "text");
3463
+ tag.setAttribute("data-tg-reads", "tag");
3464
+ const rect = card.querySelector("rect");
3465
+ const local = rect && rect.getBBox ? rect.getBBox() : null;
3466
+ tag.setAttribute("x", String((local ? local.x : 0) + 10));
3467
+ tag.setAttribute("y", String((local ? local.y + local.height : 60) - 9));
3468
+ tag.setAttribute("font-size", "11");
3469
+ tag.setAttribute("font-weight", "600");
3470
+ tag.setAttribute("fill", e.unknown.length ? "var(--pending)" : "var(--muted)");
3471
+ tag.textContent = tgReadTag(e);
3472
+ const t = document.createElementNS(NS, "title");
3473
+ t.setAttribute("data-tg-reads", "title");
3474
+ t.textContent = tgReadLine(e) + "\n(from terragucci reports, not live)";
3475
+ card.append(t, tag);
3476
+ }
3477
+ if (!from || !to) continue;
3478
+ const line = document.createElementNS(NS, "line");
3479
+ line.setAttribute("x1", String((from.x + from.x2) / 2));
3480
+ line.setAttribute("y1", String((from.y + from.y2) / 2));
3481
+ line.setAttribute("x2", String((to.x + to.x2) / 2));
3482
+ line.setAttribute("y2", String((to.y + to.y2) / 2));
3483
+ line.setAttribute("stroke", e.unknown.length ? "var(--pending)" : "var(--muted)");
3484
+ line.setAttribute("stroke-width", "2");
3485
+ line.setAttribute("stroke-dasharray", e.outputs === "planned" ? "6 4" : "2 4");
3486
+ line.setAttribute("data-tg-read-from", e.from);
3487
+ line.setAttribute("data-tg-read-to", e.to);
3488
+ const t = document.createElementNS(NS, "title");
3489
+ t.textContent = tgReadLine(e);
3490
+ line.appendChild(t);
3491
+ layer.appendChild(line);
3492
+ }
3493
+ // Behind every card: first in the svg.
3494
+ svg.insertBefore(layer, svg.firstChild);
3495
+ }
3393
3496
  function markTerragucciCards() {
3394
3497
  const svg = document.querySelector("#graph svg");
3395
3498
  tgStampProgress(svg, tgProgress, new Date()); // #511
@@ -3419,6 +3522,7 @@ function markTerragucciCards() {
3419
3522
  title.textContent = marks.map((m) => markLine(m, now)).join("\n") + "\n(from terragucci reports, not live: the Terragucci tab links each run)";
3420
3523
  g.append(title, tag);
3421
3524
  }
3525
+ drawTgReads(); // #568
3422
3526
  }
3423
3527
  function markAge(marks) {
3424
3528
  const newest = marks.map((m) => m.run.finished).sort().pop();
@@ -3470,6 +3574,7 @@ function renderTerragucciPanel() {
3470
3574
  }
3471
3575
  renderTerragucciLane(host, now);
3472
3576
  if (tgProgress) host.appendChild(tgProgressSection(tgProgress, now, tgLink)); // #511
3577
+ host.appendChild(tgChangeSection(a, tgProgress, now, { link: tgLink, button, copy: copyToClipboard })); // #568
3473
3578
  for (const w of waveCards(a, now)) {
3474
3579
  const box = document.createElement("div");
3475
3580
  box.className = "ws-gate tg-wave";
@@ -3708,6 +3813,7 @@ initYodelReport();
3708
3813
  // the gate ledgers' history (#522). Read-only and dated; never live.
3709
3814
  let ylHost = null;
3710
3815
  async function initYodelLane() {
3816
+ if (publicMode) return; // #569: /api/sql-yodeler/lifecycle is refused in a public view
3711
3817
  // #537: a static export carries the lane as a snapshot (src/export.ts), so it draws here too.
3712
3818
  const project = await apiFetch("/api/project").then((r) => r.json()).catch(() => ({}));
3713
3819
  if (!project.sqlYodeler) return;
@@ -3872,6 +3978,7 @@ let ciByComponent = new Map();
3872
3978
  let ciForge = null; // the forge the pipeline was generated for (#164) — heads the inspect section
3873
3979
 
3874
3980
  async function loadCi() {
3981
+ if (publicMode) return; // #569: /api/ci is refused in a public view
3875
3982
  try {
3876
3983
  const q = lensParams(new URLSearchParams(view.env ? { env: view.env } : {}));
3877
3984
  const res = await apiFetch(`/api/ci?${q}`);
@@ -3896,6 +4003,7 @@ async function loadCi() {
3896
4003
  let resourcesByComponent = {};
3897
4004
 
3898
4005
  async function loadResources() {
4006
+ if (publicMode) return; // #569: /api/resources is refused in a public view
3899
4007
  try {
3900
4008
  const q = lensParams(new URLSearchParams(view.env ? { env: view.env } : {}));
3901
4009
  const res = await apiFetch(`/api/resources?${q}`);
@@ -4116,7 +4224,7 @@ function renderDial() {
4116
4224
  // Apply is the write step — omitted in a static export (observe + reconcile
4117
4225
  // above are reads and stay).
4118
4226
  const applying = applyProgress && applyProgress.status === "running";
4119
- if (!staticMode) {
4227
+ if (!staticMode && !publicMode) {
4120
4228
  track.appendChild(dialArrow());
4121
4229
  const applyBtn = button(
4122
4230
  applying ? "apply · running…" : "apply",
@@ -4134,7 +4242,7 @@ function renderDial() {
4134
4242
 
4135
4243
  host.appendChild(track);
4136
4244
  if (reconcileCache) host.appendChild(renderReconcileDetail(reconcileCache));
4137
- if (applyPicker && !applying && !staticMode) host.appendChild(renderApplyPicker());
4245
+ if (applyPicker && !applying && !staticMode && !publicMode) host.appendChild(renderApplyPicker());
4138
4246
  if (applyProgress && applyProgress.waves.length) host.appendChild(renderApplyProgress(applyProgress));
4139
4247
  mountOperator(host);
4140
4248
  mountPlayhead(host);
@@ -4154,7 +4262,7 @@ function mountOperator(host) {
4154
4262
  {
4155
4263
  approve: embedOpts.embed || previewMode || staticMode || terragucciRepo ? null : (op, gate) => approveConvergeGate(op, gate),
4156
4264
  approver: projectInfo && projectInfo.approver,
4157
- why: embedOpts.embed ? "host" : previewMode ? "preview" : "static",
4265
+ why: embedOpts.embed ? "host" : publicMode ? "public" : previewMode ? "preview" : "static",
4158
4266
  // A static export has no server to ask, so it gets no history affordance
4159
4267
  // at all rather than a button that can only fail.
4160
4268
  ...(staticMode ? {} : { history: () => toggleOperatorHistory() }),
@@ -4172,7 +4280,7 @@ function mountPlayhead(host) {
4172
4280
  if (!hasPlayhead(runState)) return;
4173
4281
  const box = document.createElement("div");
4174
4282
  box.className = "run-playhead";
4175
- renderPlayhead(box, runState, gateCard, { approve: embedOpts.embed || previewMode || staticMode || terragucciRepo ? null : (op, gate) => signal(op, gate), approver: projectInfo && projectInfo.approver, why: embedOpts.embed ? "host" : previewMode ? "preview" : "static" });
4283
+ renderPlayhead(box, runState, gateCard, { approve: embedOpts.embed || previewMode || staticMode || terragucciRepo ? null : (op, gate) => signal(op, gate), approver: projectInfo && projectInfo.approver, why: embedOpts.embed ? "host" : publicMode ? "public" : previewMode ? "preview" : "static" });
4176
4284
  host.appendChild(box);
4177
4285
  }
4178
4286
 
@@ -4409,6 +4517,7 @@ function renderReconcileDetail(r) {
4409
4517
  }
4410
4518
 
4411
4519
  async function loadReconcile() {
4520
+ if (publicMode) return; // #569: /api/reconcile is refused in a public view
4412
4521
  try {
4413
4522
  const q = lensParams(new URLSearchParams({ env: view.env }));
4414
4523
  const res = await apiFetch(`/api/reconcile?${q}`);
@@ -4457,21 +4566,16 @@ function relabelEdgeChip(chip, kind, via) {
4457
4566
  function wireEdgeHighlight(svgEl, ir) {
4458
4567
  const edges = [...svgEl.querySelectorAll("g[data-edge-from]")];
4459
4568
  if (!edges.length) return;
4460
- // Reason per edge, keyed by from|to (and its reverse, since pinhole may paint
4461
- // an edge in either direction relative to the IR). pinhole stamps
4462
- // data-edge-via itself; this fills it in where it did not.
4463
- const viaOf = new Map();
4464
- for (const e of ir.edges || []) {
4465
- if (!e.viaAttr) continue;
4466
- viaOf.set(`${e.from}|${e.to}`, e.viaAttr);
4467
- viaOf.set(`${e.to}|${e.from}`, e.viaAttr);
4468
- }
4569
+ // Reason per edge, keyed by from, to and kind (pinhole's edgeKey, #564), so
4570
+ // two kinds on one pair keep their own reason. pinhole stamps data-edge-via
4571
+ // itself; this fills it in where it did not.
4572
+ const viaOf = viaIndex(ir.edges || []);
4469
4573
  for (const g of edges) {
4470
4574
  const from = g.getAttribute("data-edge-from");
4471
4575
  const to = g.getAttribute("data-edge-to");
4472
- const via = g.getAttribute("data-edge-via") || viaOf.get(`${from}|${to}`);
4473
- if (via) g.setAttribute("data-edge-via", via); // drives the dashed style
4474
4576
  const kind = g.getAttribute("data-edge-kind") || "ref";
4577
+ const via = g.getAttribute("data-edge-via") || viaOf.get(edgeKey({ from, to, kind }));
4578
+ if (via) g.setAttribute("data-edge-via", via); // drives the dashed style
4475
4579
  const cross = g.getAttribute("data-edge-cross") === "true";
4476
4580
  if (!g.querySelector("path")) relabelEdgeChip(g, kind, via);
4477
4581
  const title = document.createElementNS(SVGNS, "title");
@@ -4664,6 +4768,7 @@ function render(ir, svg, m) {
4664
4768
  markTerragucciCards(); // #490: and what terragucci's reports found, dated
4665
4769
  markYodelCards(); // #526: and SQL Yodeler's drift, dated
4666
4770
  applyLayout(); // #228: last, so the hand-placed deltas ride on top of every other pass
4771
+ drawTgReads(); // #568: after the layout, so the reads lines meet the cards where they now sit
4667
4772
  renderDial();
4668
4773
  }
4669
4774
 
@@ -5280,17 +5385,24 @@ function userRect(el, toUser) {
5280
5385
  */
5281
5386
  function edgeLabelOf(g) {
5282
5387
  if (!(g.getAttribute("data-edge-via") || "").trim()) return null;
5283
- const from = g.getAttribute("data-edge-from");
5284
- const to = g.getAttribute("data-edge-to");
5388
+ // The pair and the kind (pinhole#128, #564): two kinds on one pair draw two
5389
+ // lines with a chip each, and each line keeps its own chip.
5390
+ const key = edgeKeyOf(g);
5285
5391
  for (const cand of g.parentNode ? g.parentNode.children : []) {
5286
5392
  if (cand === g || cand.tagName.toLowerCase() !== "g") continue;
5287
- if (cand.getAttribute("data-edge-from") !== from || cand.getAttribute("data-edge-to") !== to) continue;
5393
+ if (!cand.hasAttribute("data-edge-from") || edgeKeyOf(cand) !== key) continue;
5288
5394
  if (cand.hasAttribute("data-node-id") || cand.querySelector("path")) continue;
5289
5395
  return cand;
5290
5396
  }
5291
5397
  return null;
5292
5398
  }
5293
5399
 
5400
+ /** A drawn edge group's identity, pinhole's `edgeKey` over its stamped
5401
+ * attributes (no `data-edge-kind` is a reference). */
5402
+ function edgeKeyOf(g) {
5403
+ return edgeKey({ from: g.getAttribute("data-edge-from"), to: g.getAttribute("data-edge-to"), kind: g.getAttribute("data-edge-kind") || "ref" });
5404
+ }
5405
+
5294
5406
  /** Index the freshly rendered SVG: node groups, containment boxes, edge paths. */
5295
5407
  function indexLayout(svgEl) {
5296
5408
  const toUser = userSpaceMapper(svgEl);
@@ -5470,7 +5582,7 @@ async function pullServerLayout(lens) {
5470
5582
  * read-only project, older behold) simply never pushes, and a failed push is
5471
5583
  * not reported — the localStorage tier already has it. */
5472
5584
  const pushServerLayout = debounce((lens, deltas) => {
5473
- if (staticMode || !layoutServer.writable || layoutServer.lens !== lens) return;
5585
+ if (staticMode || publicMode || !layoutServer.writable || layoutServer.lens !== lens) return;
5474
5586
  postServerLayout((url, init) => fetch(url, init), lens, deltas);
5475
5587
  }, 600);
5476
5588
  // A reload (or a tab closing) within the debounce window would otherwise drop
@@ -5978,6 +6090,7 @@ events.addEventListener("changed", () => {
5978
6090
  // status dot + name + state, with the bring-up/reset/pipeline actions inline
5979
6091
  // again (#73 moved those into ⌘K; they remain there too, via lastSubstrates).
5980
6092
  async function loadSubstrates() {
6093
+ if (publicMode) return; // #569: /api/substrates is refused in a public view
5981
6094
  try {
5982
6095
  const { substrates } = await apiFetch("/api/substrates").then((r) => r.json());
5983
6096
  renderSubstrates(substrates || []);
@@ -6020,7 +6133,7 @@ function renderSubstrates(subs) {
6020
6133
  row.append(dot, name, status);
6021
6134
  // Writes — none in a static export; the GitHub dispatch also respects the
6022
6135
  // preview lock, mirroring paletteCommands()'s gating exactly.
6023
- if (!staticMode) {
6136
+ if (!staticMode && !publicMode) {
6024
6137
  if (s.bringUp) row.appendChild(actButton("bring up", () => bringUpSubstrate(s)));
6025
6138
  if (s.name === "floci" && s.status === "up")
6026
6139
  row.appendChild(actButton("reset", () => resetLocal(), "Reset the local emulator — wipes every stack, reboots, redeploys clean"));
@@ -6140,7 +6253,7 @@ function hideLoading() {
6140
6253
  // ask. Without this guard the bundle logs a 404 on every page load and relies on
6141
6254
  // the fetch failing to stay silent, which is an outcome rather than a rule.
6142
6255
  async function refreshReadCost() {
6143
- if (staticMode) return;
6256
+ if (staticMode || publicMode) return; // #569: /api/doctor is refused in a public view
6144
6257
  try {
6145
6258
  const res = await fetch("/api/doctor?reads=1");
6146
6259
  if (!res.ok) return;
@@ -6261,7 +6374,7 @@ function runStatusTarget() {
6261
6374
  return opsApply && opsApply.gate ? opsApply.name : null;
6262
6375
  }
6263
6376
  function pollRunStatus() {
6264
- if (staticMode || runStatusInFlight) return;
6377
+ if (staticMode || publicMode || runStatusInFlight) return; // #569: a public serve refuses the op tools
6265
6378
  const name = runStatusTarget();
6266
6379
  if (!name) return;
6267
6380
  // Only a durable run has gate state at all. A local run's playhead is
@@ -6290,7 +6403,7 @@ function pollRunStatus() {
6290
6403
  * strip standing in for "behold couldn't ask".
6291
6404
  */
6292
6405
  function pollOperatorStatus() {
6293
- if (staticMode || operatorInFlight) return;
6406
+ if (staticMode || publicMode || operatorInFlight) return;
6294
6407
  operatorInFlight = true;
6295
6408
  apiFetch("/api/operator/status")
6296
6409
  .then((r) => r.json())
@@ -6330,7 +6443,7 @@ function toggleOperatorHistory() {
6330
6443
  * empty timeline must never stand in for "behold couldn't ask".
6331
6444
  */
6332
6445
  function loadOperatorHistory() {
6333
- if (staticMode || operatorHistory.loading) return;
6446
+ if (staticMode || publicMode || operatorHistory.loading) return;
6334
6447
  operatorHistory = { ...operatorHistory, loading: true, error: null };
6335
6448
  apiFetch("/api/operator/log")
6336
6449
  .then((r) => r.json())
@@ -6504,7 +6617,7 @@ async function initActions() {
6504
6617
  // #165: which envs deploy through a forge, and whether Deploy can dispatch
6505
6618
  // them right now (`/api/project`'s executor block).
6506
6619
  executorInfo = project.executor || {};
6507
- previewMode = staticMode || !!project.previewMode; // static ⇒ read-only, no writes at all
6620
+ previewMode = staticMode || publicMode || !!project.previewMode; // static ⇒ read-only, no writes at all
6508
6621
  // #517: a SQL Yodeler repo is guarded the same way.
6509
6622
  const guarded = project.terragucci || project.sqlYodeler;
6510
6623
  if (guarded) {
@@ -6517,6 +6630,16 @@ async function initActions() {
6517
6630
  bar.appendChild(pill);
6518
6631
  return; // no Deploy, no Run, no Approve: the pipeline is the only way to apply
6519
6632
  }
6633
+ // #569: a public view. Nothing in the bar writes; the pill says why.
6634
+ if (publicMode || project.publicMode) {
6635
+ previewMode = true;
6636
+ const pill = document.createElement("span");
6637
+ pill.textContent = "● public view, read-only";
6638
+ pill.title = "behold serve --public: this page reads and follows; it starts nothing and approves nothing.";
6639
+ pill.style.cssText = "align-self:center;font:var(--t-caption)/1.4 var(--font-mono);color:var(--muted);border:1px solid var(--line);border-radius:var(--r-ctl);padding:2px 8px";
6640
+ bar.appendChild(pill);
6641
+ return;
6642
+ }
6520
6643
  const { ops, adoptLexicons, autoSync, local, applyProgress: apInit, runState: runInit, operatorState: opInit } = await apiFetch("/api/ops")
6521
6644
  .then((r) => r.json())
6522
6645
  .catch(() => ({ ops: [], adoptLexicons: [] }));
@@ -6760,12 +6883,13 @@ function paletteCommands() {
6760
6883
  const c = [];
6761
6884
 
6762
6885
  // Reads — always available, even in a static export or the preview lock.
6763
- if (!staticMode) c.push(["Re-check live (refresh drift)", () => refresh()]);
6886
+ // #569: not in a public view — Refresh is a POST.
6887
+ if (!staticMode && !publicMode) c.push(["Re-check live (refresh drift)", () => refresh()]);
6764
6888
  // #404: the expensive sibling, listed beside it and named for what it costs.
6765
6889
  // Offered only with an env picked — there is no overlay to plan against
6766
6890
  // without one — and disabled-with-its-reason otherwise, the convention the
6767
6891
  // colour-mode and carve rows below already follow.
6768
- if (!staticMode) {
6892
+ if (!staticMode && !publicMode) {
6769
6893
  c.push(
6770
6894
  view.env
6771
6895
  ? ["Re-check live with plan (attribute drift) — a full read of the account", () => refreshWithPlan()]
@@ -6862,7 +6986,7 @@ function paletteCommands() {
6862
6986
  c.push([`target: ${t.endpoint}` + (view.target === t.endpoint ? " ✓" : ""), () => { view.target = t.endpoint; resetDialCaches(); load(); }]);
6863
6987
  }
6864
6988
 
6865
- if (staticMode || terragucciRepo) return c.map(([label, run]) => ({ label, run })); // no writes at all in a static export or a terragucci repo
6989
+ if (staticMode || publicMode || terragucciRepo) return c.map(([label, run]) => ({ label, run })); // no writes at all in a static export, a public view (#569) or a terragucci repo
6866
6990
 
6867
6991
  // Deploy / write actions — previewMode hides exactly the git/PR write
6868
6992
  // affordances the toolbar hid (Rollback, Sync, Run <op>); Apply all, Reset,
package/web/edge-kind.js CHANGED
@@ -32,6 +32,37 @@ export function isCiKind(kind) {
32
32
  return !!kind && kind !== "ref";
33
33
  }
34
34
 
35
+ /**
36
+ * An edge's identity, the same key pinhole 0.5.0's `edgeKey` gives
37
+ * (pinhole#128): `from>to` for a reference, `from>to>kind` otherwise. Two
38
+ * kinds on one pair (a GitLab job that both `needs` and `extends` another)
39
+ * are two edges, so anything the SPA looks up per edge goes through this
40
+ * (#564). Read off a drawn edge, pass `data-edge-kind`, which pinhole leaves
41
+ * off for "ref".
42
+ */
43
+ export function edgeKey({ from, to, kind } = {}) {
44
+ return `${from}>${to}${isCiKind(kind) ? `>${kind}` : ""}`;
45
+ }
46
+
47
+ /**
48
+ * The `viaAttr` per edge key, for the drawn edges pinhole did not stamp
49
+ * `data-edge-via` on. The reverse direction is filled in as well, where
50
+ * nothing already holds it, since pinhole may paint an edge either way round
51
+ * relative to the IR.
52
+ */
53
+ export function viaIndex(edges = []) {
54
+ const via = new Map();
55
+ for (const e of edges) {
56
+ if (!e.viaAttr) continue;
57
+ via.set(edgeKey(e), e.viaAttr);
58
+ }
59
+ for (const e of edges) {
60
+ const back = edgeKey({ from: e.to, to: e.from, kind: e.kind });
61
+ if (e.viaAttr && !via.has(back)) via.set(back, e.viaAttr);
62
+ }
63
+ return via;
64
+ }
65
+
35
66
  /**
36
67
  * The chip text, as chant's Mermaid and DOT writers label the edge
37
68
  * (graph-mermaid.ts `edgeLabel`): a CI edge by its kind, plus the attribute
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { edgeLabel, edgeLegend, edgeTitle } from "./edge-kind.js";
2
+ import { edgeKey, edgeLabel, edgeLegend, edgeTitle, viaIndex } from "./edge-kind.js";
3
3
 
4
4
  // The edges chant 0.124.20's GitHub lexicon writes (lexicons/github/src/
5
5
  // graph-edges.ts) and one from its GitLab lexicon.
@@ -68,3 +68,33 @@ describe("edgeLegend (#553)", () => {
68
68
  expect(edgeLegend([ref, { from: "a", to: "b", kind: "ref" }])).toEqual([]);
69
69
  });
70
70
  });
71
+
72
+ // #564: pinhole 0.5.0 keys an edge on from, to and kind (pinhole#128), so two
73
+ // kinds on one pair are two edges, each with its own chip and tooltip.
74
+ describe("edgeKey and viaIndex (#564)", () => {
75
+ it("gives the key pinhole's edgeKey gives: a reference keeps from>to", () => {
76
+ expect(edgeKey({ from: "api", to: "db", kind: "ref" })).toBe("api>db");
77
+ expect(edgeKey({ from: "api", to: "db" })).toBe("api>db");
78
+ expect(edgeKey({ from: "test", to: "build", kind: "needs" })).toBe("test>build>needs");
79
+ });
80
+
81
+ it("keeps each kind's reason on a pair joined twice", () => {
82
+ const via = viaIndex([
83
+ { from: "test", to: "build", kind: "needs", viaAttr: "stage" },
84
+ { from: "test", to: "build", kind: "extends", viaAttr: "extends" },
85
+ ]);
86
+ expect(via.get(edgeKey({ from: "test", to: "build", kind: "needs" }))).toBe("stage");
87
+ expect(via.get(edgeKey({ from: "test", to: "build", kind: "extends" }))).toBe("extends");
88
+ });
89
+
90
+ it("fills in the reverse direction without overwriting an edge drawn that way", () => {
91
+ const via = viaIndex([
92
+ { from: "a", to: "b", kind: "ref", viaAttr: "role" },
93
+ { from: "b", to: "a", kind: "ref", viaAttr: "policy_arn" },
94
+ { from: "c", to: "d", kind: "ref", viaAttr: "zone_id" },
95
+ ]);
96
+ expect(via.get("a>b")).toBe("role");
97
+ expect(via.get("b>a")).toBe("policy_arn");
98
+ expect(via.get("d>c")).toBe("zone_id");
99
+ });
100
+ });
package/web/kiosk.html ADDED
@@ -0,0 +1,67 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>behold kiosk</title>
7
+ <link
8
+ rel="icon"
9
+ href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='6' fill='none' stroke='%23a6adc8' stroke-width='1.6'/%3E%3Ccircle cx='8' cy='8' r='2.2' fill='%23a6adc8'/%3E%3C/svg%3E"
10
+ />
11
+ <style>
12
+ /* #569 step 2. The pre-paint fallback is index.html's: the default
13
+ theme's tokens, which initTheme() replaces with the viewer's theme. */
14
+ :root {
15
+ --bg: #1e1e2e; --panel: #2e2f40; --line: #3b3c4a; --fg: #cdd6f4; --muted: #777a8c;
16
+ --edge: #56596d; --managed: #a6e3a1; --foreign: #f9e2af; --pending: #89b4fa;
17
+ --degraded: #f38ba8; --neutral: #6d7187; --runtime: #94e2d5;
18
+ --rule: #4e4f64; --focus: #a3b4d0; --active: #3d4660; --well: #11101f; --shadow: #050507;
19
+ --font-ui: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
20
+ --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
21
+ }
22
+ * { box-sizing: border-box; }
23
+ html, body { margin: 0; background: var(--bg); color: var(--fg); font: 18px/1.45 var(--font-ui); }
24
+ #kiosk { max-width: 1100px; margin: 0 auto; padding: 2rem 1rem 3rem; display: grid; gap: 1.25rem; }
25
+ .k-kicker { font: 600 0.8rem var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
26
+ .k-title { margin: 0.2rem 0; font-size: 2rem; line-height: 1.2; }
27
+ .k-muted { color: var(--muted); margin: 0.25rem 0; }
28
+ section { background: var(--panel); border: 1px solid var(--rule); border-radius: 10px; padding: 1rem 1.25rem; }
29
+ .k-strip { list-style: none; display: flex; flex-wrap: wrap; gap: 0.4rem; padding: 0; margin: 0; }
30
+ .k-stop { font: 0.85rem var(--font-mono); padding: 0.3rem 0.7rem; border-radius: 999px; border: 1px solid var(--rule); color: var(--muted); }
31
+ .k-done { color: var(--fg); border-color: var(--managed); }
32
+ .k-current { color: var(--bg); background: var(--pending); border-color: var(--pending); font-weight: 600; }
33
+ .k-failed { color: var(--bg); background: var(--degraded); border-color: var(--degraded); font-weight: 600; }
34
+ .k-label { font-size: 1.35rem; margin: 0.4rem 0 0; }
35
+ .k-step-waiting { border-color: var(--foreign); }
36
+ .k-step-failed, .k-step-stalled { border-color: var(--degraded); }
37
+ .k-step-applied { border-color: var(--managed); }
38
+ .k-next-line { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: baseline; margin: 0.4rem 0 0; font-size: 1.2rem; }
39
+ .k-action { font-family: var(--font-mono); }
40
+ .k-when { color: var(--foreign); font-family: var(--font-mono); }
41
+ .k-approve { margin-top: 0.75rem; }
42
+ .k-command { display: block; padding: 0.6rem 0.8rem; background: var(--well); border-radius: 6px; font: 0.95rem var(--font-mono); overflow-wrap: anywhere; user-select: none; }
43
+ .k-read { font-family: var(--font-mono); font-size: 0.95rem; margin: 0.3rem 0; }
44
+ .k-unknown { color: var(--foreign); }
45
+ .k-unreviewed { margin: 0.4rem 0; padding-left: 0.6rem; border-left: 3px solid var(--foreign); }
46
+ .k-waves { list-style: none; padding: 0; margin: 0.6rem 0; display: grid; gap: 0.5rem; }
47
+ .k-wave { border: 1px solid var(--rule); border-left-width: 4px; border-radius: 6px; padding: 0.5rem 0.8rem; background: var(--bg); }
48
+ .k-wave-title { font-family: var(--font-mono); }
49
+ .k-wave-fact { color: var(--muted); font-size: 0.9rem; }
50
+ .k-tone-ok { border-left-color: var(--managed); }
51
+ .k-tone-wave { border-left-color: var(--foreign); }
52
+ .k-tone-failed { border-left-color: var(--degraded); }
53
+ .k-tone-plan { border-left-color: var(--pending); }
54
+ .k-tone-none { border-left-color: var(--neutral); }
55
+ .k-foot { font-size: 0.8rem; }
56
+ </style>
57
+ </head>
58
+ <body>
59
+ <main id="kiosk" aria-live="polite"></main>
60
+ <script type="module">
61
+ import { initTheme } from "/theme.js";
62
+ import { startKiosk } from "/kiosk.js";
63
+ initTheme();
64
+ startKiosk(document.getElementById("kiosk"));
65
+ </script>
66
+ </body>
67
+ </html>