@intentius/behold 0.28.0 → 0.29.1

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.28.0",
3
+ "version": "0.29.1",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "description": "behold — a live control plane on chant. See your whole estate (every substrate in one graph), coloured by drift; act through delegated, gated Ops.",
package/web/app.js CHANGED
@@ -18,6 +18,7 @@ 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";
@@ -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}`);
@@ -4659,6 +4768,7 @@ function render(ir, svg, m) {
4659
4768
  markTerragucciCards(); // #490: and what terragucci's reports found, dated
4660
4769
  markYodelCards(); // #526: and SQL Yodeler's drift, dated
4661
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
4662
4772
  renderDial();
4663
4773
  }
4664
4774
 
@@ -5472,7 +5582,7 @@ async function pullServerLayout(lens) {
5472
5582
  * read-only project, older behold) simply never pushes, and a failed push is
5473
5583
  * not reported — the localStorage tier already has it. */
5474
5584
  const pushServerLayout = debounce((lens, deltas) => {
5475
- if (staticMode || !layoutServer.writable || layoutServer.lens !== lens) return;
5585
+ if (staticMode || publicMode || !layoutServer.writable || layoutServer.lens !== lens) return;
5476
5586
  postServerLayout((url, init) => fetch(url, init), lens, deltas);
5477
5587
  }, 600);
5478
5588
  // A reload (or a tab closing) within the debounce window would otherwise drop
@@ -5980,6 +6090,7 @@ events.addEventListener("changed", () => {
5980
6090
  // status dot + name + state, with the bring-up/reset/pipeline actions inline
5981
6091
  // again (#73 moved those into ⌘K; they remain there too, via lastSubstrates).
5982
6092
  async function loadSubstrates() {
6093
+ if (publicMode) return; // #569: /api/substrates is refused in a public view
5983
6094
  try {
5984
6095
  const { substrates } = await apiFetch("/api/substrates").then((r) => r.json());
5985
6096
  renderSubstrates(substrates || []);
@@ -6022,7 +6133,7 @@ function renderSubstrates(subs) {
6022
6133
  row.append(dot, name, status);
6023
6134
  // Writes — none in a static export; the GitHub dispatch also respects the
6024
6135
  // preview lock, mirroring paletteCommands()'s gating exactly.
6025
- if (!staticMode) {
6136
+ if (!staticMode && !publicMode) {
6026
6137
  if (s.bringUp) row.appendChild(actButton("bring up", () => bringUpSubstrate(s)));
6027
6138
  if (s.name === "floci" && s.status === "up")
6028
6139
  row.appendChild(actButton("reset", () => resetLocal(), "Reset the local emulator — wipes every stack, reboots, redeploys clean"));
@@ -6142,7 +6253,7 @@ function hideLoading() {
6142
6253
  // ask. Without this guard the bundle logs a 404 on every page load and relies on
6143
6254
  // the fetch failing to stay silent, which is an outcome rather than a rule.
6144
6255
  async function refreshReadCost() {
6145
- if (staticMode) return;
6256
+ if (staticMode || publicMode) return; // #569: /api/doctor is refused in a public view
6146
6257
  try {
6147
6258
  const res = await fetch("/api/doctor?reads=1");
6148
6259
  if (!res.ok) return;
@@ -6263,7 +6374,7 @@ function runStatusTarget() {
6263
6374
  return opsApply && opsApply.gate ? opsApply.name : null;
6264
6375
  }
6265
6376
  function pollRunStatus() {
6266
- if (staticMode || runStatusInFlight) return;
6377
+ if (staticMode || publicMode || runStatusInFlight) return; // #569: a public serve refuses the op tools
6267
6378
  const name = runStatusTarget();
6268
6379
  if (!name) return;
6269
6380
  // Only a durable run has gate state at all. A local run's playhead is
@@ -6292,7 +6403,7 @@ function pollRunStatus() {
6292
6403
  * strip standing in for "behold couldn't ask".
6293
6404
  */
6294
6405
  function pollOperatorStatus() {
6295
- if (staticMode || operatorInFlight) return;
6406
+ if (staticMode || publicMode || operatorInFlight) return;
6296
6407
  operatorInFlight = true;
6297
6408
  apiFetch("/api/operator/status")
6298
6409
  .then((r) => r.json())
@@ -6332,7 +6443,7 @@ function toggleOperatorHistory() {
6332
6443
  * empty timeline must never stand in for "behold couldn't ask".
6333
6444
  */
6334
6445
  function loadOperatorHistory() {
6335
- if (staticMode || operatorHistory.loading) return;
6446
+ if (staticMode || publicMode || operatorHistory.loading) return;
6336
6447
  operatorHistory = { ...operatorHistory, loading: true, error: null };
6337
6448
  apiFetch("/api/operator/log")
6338
6449
  .then((r) => r.json())
@@ -6506,7 +6617,7 @@ async function initActions() {
6506
6617
  // #165: which envs deploy through a forge, and whether Deploy can dispatch
6507
6618
  // them right now (`/api/project`'s executor block).
6508
6619
  executorInfo = project.executor || {};
6509
- previewMode = staticMode || !!project.previewMode; // static ⇒ read-only, no writes at all
6620
+ previewMode = staticMode || publicMode || !!project.previewMode; // static ⇒ read-only, no writes at all
6510
6621
  // #517: a SQL Yodeler repo is guarded the same way.
6511
6622
  const guarded = project.terragucci || project.sqlYodeler;
6512
6623
  if (guarded) {
@@ -6519,6 +6630,16 @@ async function initActions() {
6519
6630
  bar.appendChild(pill);
6520
6631
  return; // no Deploy, no Run, no Approve: the pipeline is the only way to apply
6521
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
+ }
6522
6643
  const { ops, adoptLexicons, autoSync, local, applyProgress: apInit, runState: runInit, operatorState: opInit } = await apiFetch("/api/ops")
6523
6644
  .then((r) => r.json())
6524
6645
  .catch(() => ({ ops: [], adoptLexicons: [] }));
@@ -6762,12 +6883,13 @@ function paletteCommands() {
6762
6883
  const c = [];
6763
6884
 
6764
6885
  // Reads — always available, even in a static export or the preview lock.
6765
- 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()]);
6766
6888
  // #404: the expensive sibling, listed beside it and named for what it costs.
6767
6889
  // Offered only with an env picked — there is no overlay to plan against
6768
6890
  // without one — and disabled-with-its-reason otherwise, the convention the
6769
6891
  // colour-mode and carve rows below already follow.
6770
- if (!staticMode) {
6892
+ if (!staticMode && !publicMode) {
6771
6893
  c.push(
6772
6894
  view.env
6773
6895
  ? ["Re-check live with plan (attribute drift) — a full read of the account", () => refreshWithPlan()]
@@ -6864,7 +6986,7 @@ function paletteCommands() {
6864
6986
  c.push([`target: ${t.endpoint}` + (view.target === t.endpoint ? " ✓" : ""), () => { view.target = t.endpoint; resetDialCaches(); load(); }]);
6865
6987
  }
6866
6988
 
6867
- 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
6868
6990
 
6869
6991
  // Deploy / write actions — previewMode hides exactly the git/PR write
6870
6992
  // affordances the toolbar hid (Rollback, Sync, Run <op>); Apply all, Reset,
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>