@intentius/behold 0.29.0 → 0.29.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/kiosk.html CHANGED
@@ -20,48 +20,117 @@
20
20
  --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
21
21
  }
22
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); }
23
+ /* #569 step 3: one screen. The bar is the narration, the frame is
24
+ behold's own view of the change, the foot is the plan's facts. Sizes
25
+ follow the screen's width, so a TV reads it from across a room. */
26
+ html, body { margin: 0; height: 100%; background: var(--bg); color: var(--fg); font: clamp(13px, 0.82vw, 20px)/1.4 var(--font-ui); overflow: hidden; }
27
+ body { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; }
28
+ /* #569: the pipeline's runs (left), the selected run's jobs, steps and
29
+ log (middle), behold's view of the change (right). */
30
+ #stage { display: grid; grid-template-columns: minmax(15em, 18%) minmax(26em, 34%) minmax(0, 1fr); min-height: 0; }
31
+ #runs-side { overflow-y: auto; border-right: 1px solid var(--rule); background: var(--panel); }
32
+ #runs-main { overflow: hidden; display: flex; flex-direction: column; min-height: 0; border-right: 1px solid var(--rule); }
33
+ .r-side-head { padding: 0.8em 1em 0.5em; }
34
+ .r-side-title { font-weight: 700; font-size: 1.05em; }
35
+ .r-side-note { color: var(--muted); font-size: 0.82em; margin-top: 0.15em; }
36
+ .r-runs { list-style: none; margin: 0; padding: 0 0 1em; }
37
+ .r-group { font: 600 0.72em var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); padding: 0.7em 1em 0.25em; }
38
+ .r-run { display: grid; grid-template-columns: 1.2em 1fr; gap: 0.6em; padding: 0.5em 1em; cursor: pointer; border-left: 3px solid transparent; }
39
+ .r-run:hover { background: color-mix(in srgb, var(--active) 50%, transparent); }
40
+ .r-run.r-selected { background: var(--active); border-left-color: var(--focus); }
41
+ .r-run-title { font-weight: 600; line-height: 1.25; }
42
+ .r-run-place { color: var(--muted); font-size: 0.85em; }
43
+ .r-run-meta { display: flex; flex-wrap: wrap; gap: 0 0.7em; color: var(--muted); font-size: 0.8em; }
44
+ .r-icon { width: 1.15em; height: 1.15em; flex: none; margin-top: 0.15em; }
45
+ .r-ok { color: var(--managed); } .r-bad { color: var(--degraded); } .r-wait { color: var(--foreign); } .r-busy { color: var(--foreign); } .r-idle { color: var(--neutral); }
46
+ .r-word.r-idle { color: var(--muted); }
47
+ @media (prefers-reduced-motion: no-preference) { .r-spin { animation: r-spin 1s linear infinite; } }
48
+ @keyframes r-spin { to { transform: rotate(360deg); } }
49
+ .r-head { padding: 0.8em 1em 0.6em; border-bottom: 1px solid var(--rule); }
50
+ .r-head-title { display: flex; gap: 0.5em; align-items: center; font-weight: 700; font-size: 1.15em; }
51
+ .r-head-title .r-icon { margin: 0; }
52
+ .r-head-sub { color: var(--fg); font-size: 0.88em; margin-top: 0.15em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
53
+ .r-head-meta { color: var(--muted); font-size: 0.82em; }
54
+ .r-label { font: 600 0.7em var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); padding: 0.6em 1em 0; }
55
+ .r-jobs { list-style: none; margin: 0; padding: 0.3em 0 0.5em; border-bottom: 1px solid var(--rule); }
56
+ .r-job { display: flex; gap: 0.5em; align-items: center; padding: 0.22em 1em 0.22em calc(1em + var(--depth, 0) * 0.9em); cursor: pointer; }
57
+ .r-job .r-icon { margin: 0; }
58
+ .r-job.r-selected { background: var(--active); }
59
+ .r-job-name { font: 600 0.92em var(--font-mono); }
60
+ .r-job-needs { color: var(--muted); font-size: 0.78em; }
61
+ .r-dur { margin-left: auto; color: var(--muted); font-size: 0.8em; white-space: nowrap; }
62
+ .r-job-skipped { color: var(--muted); font-size: 0.78em; cursor: default; }
63
+ .r-steps { display: flex; flex-direction: column; min-height: 0; flex: 1; overflow-y: auto; padding-bottom: 0.5em; }
64
+ .r-steps-head { display: flex; gap: 0.5em; align-items: center; padding: 0.3em 1em 0.3em; }
65
+ .r-steps-head .r-icon { margin: 0; }
66
+ .r-steps-job { font: 700 0.95em var(--font-mono); }
67
+ .r-steps-word { color: var(--muted); font-size: 0.85em; }
68
+ .r-step-line { display: flex; gap: 0.45em; align-items: center; padding: 0.2em 1em; cursor: pointer; }
69
+ .r-step-line .r-icon { margin: 0; width: 1em; height: 1em; }
70
+ .r-caret { color: var(--muted); width: 0.8em; font-size: 0.8em; }
71
+ .r-step-name { font-size: 0.88em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
72
+ .r-open > .r-step-line { background: color-mix(in srgb, var(--active) 60%, transparent); }
73
+ .r-log { margin: 0.2em 0.6em 0.4em 1.6em; padding: 0.5em 0; background: var(--well); border-radius: 6px; font: 0.76em/1.45 var(--font-mono);
74
+ white-space: pre-wrap; overflow-wrap: anywhere; overflow-y: auto; max-height: 52vh; min-height: 6em; }
75
+ .r-log-line { display: grid; grid-template-columns: 3.2em 1fr; gap: 0.8em; padding: 0 0.8em 0 0; }
76
+ .r-ln { color: var(--muted); text-align: right; user-select: none; opacity: 0.7; }
77
+ .r-log-cut { color: var(--muted); padding: 0 0.8em 0.2em 4em; font-style: italic; }
78
+ .r-log-live { color: var(--foreign); padding-left: 4em; }
79
+ @media (prefers-reduced-motion: no-preference) { .r-log-live { animation: r-blink 1s steps(2) infinite; } }
80
+ @keyframes r-blink { 50% { opacity: 0; } }
81
+ .r-note { color: var(--muted); padding: 0 1em; font-size: 0.88em; }
82
+ #bar { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); gap: 1.6em; align-items: start;
83
+ padding: 0.7em 1.4em 0.7em; background: var(--panel); border-bottom: 1px solid var(--rule); }
84
+ #bar > .k-empty { grid-column: 1 / -1; }
85
+ .k-kicker { display: flex; gap: 1.2em; font: 600 0.78em var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
86
+ .k-cycle { text-transform: none; letter-spacing: 0; font-weight: 400; }
87
+ .k-title { margin: 0.05em 0 0.25em; font-size: 1.45em; line-height: 1.15; }
88
+ .k-muted { color: var(--muted); margin: 0.25em 0; }
89
+ .k-step { border-left: 4px solid var(--pending); padding: 0.1em 0 0.1em 0.8em; }
90
+ .k-step-word { font: 600 0.75em var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
91
+ .k-label { font-size: 1.02em; margin: 0.1em 0 0; }
92
+ .k-step-waiting { border-left-color: var(--foreign); }
93
+ .k-step-failed, .k-step-stalled { border-left-color: var(--degraded); }
94
+ .k-step-applied { border-left-color: var(--managed); }
95
+ .k-step-reset { border-left-color: var(--neutral); }
96
+ .k-right { display: grid; gap: 0.6em; align-content: start; }
97
+ .k-strip { list-style: none; display: flex; flex-wrap: wrap; gap: 0.35em; padding: 0; margin: 0; }
98
+ .k-stop { font: 0.82em var(--font-mono); padding: 0.25em 0.7em; border-radius: 999px; border: 1px solid var(--rule); color: var(--muted); }
31
99
  .k-done { color: var(--fg); border-color: var(--managed); }
32
100
  .k-current { color: var(--bg); background: var(--pending); border-color: var(--pending); font-weight: 600; }
33
101
  .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; }
102
+ .k-next { display: flex; flex-wrap: wrap; gap: 0.3em 0.8em; align-items: baseline; font-size: 1.05em; }
103
+ .k-next-word { font: 600 0.72em var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
39
104
  .k-action { font-family: var(--font-mono); }
40
105
  .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; }
106
+ .k-approve { border-left: 4px solid var(--foreign); padding-left: 0.8em; }
107
+ .k-approve p { margin: 0.15em 0; }
108
+ .k-approve-head, .k-approve-then { color: var(--muted); font-size: 0.92em; }
109
+ .k-command { display: block; margin: 0.25em 0; padding: 0.45em 0.7em; background: var(--well); border-radius: 6px; font: 0.88em var(--font-mono); overflow-wrap: anywhere; user-select: none; }
110
+ .k-inline { font-family: var(--font-mono); color: var(--fg); background: var(--well); padding: 0.05em 0.35em; border-radius: 4px; user-select: none; }
111
+ .k-note { color: var(--muted); margin: 0; font-size: 0.92em; }
44
112
  .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; }
113
+ #view { display: block; width: 100%; height: 100%; border: 0; background: var(--bg); }
114
+ #foot { display: flex; flex-wrap: wrap; gap: 0.2em 1.6em; padding: 0.55em 1.6em; border-top: 1px solid var(--rule); background: var(--panel); font-size: 0.85em; }
115
+ .k-legend { color: var(--muted); flex-basis: 100%; }
116
+ .k-fact { color: var(--fg); }
117
+ .k-source { color: var(--muted); margin-left: auto; }
118
+ @media (max-width: 900px) { #bar { grid-template-columns: 1fr; } #stage { grid-template-columns: 1fr; } }
56
119
  </style>
57
120
  </head>
58
121
  <body>
59
- <main id="kiosk" aria-live="polite"></main>
122
+ <header id="bar" aria-live="polite"></header>
123
+ <div id="stage">
124
+ <nav id="runs-side" aria-label="pipeline runs"></nav>
125
+ <section id="runs-main" aria-live="polite"></section>
126
+ <iframe id="view" title="behold's view of the change"></iframe>
127
+ </div>
128
+ <footer id="foot"></footer>
60
129
  <script type="module">
61
130
  import { initTheme } from "/theme.js";
62
131
  import { startKiosk } from "/kiosk.js";
63
132
  initTheme();
64
- startKiosk(document.getElementById("kiosk"));
133
+ startKiosk({ bar: document.getElementById("bar"), frame: document.getElementById("view"), foot: document.getElementById("foot"), side: document.getElementById("runs-side"), main: document.getElementById("runs-main") });
65
134
  </script>
66
135
  </body>
67
136
  </html>
package/web/kiosk.js CHANGED
@@ -1,4 +1,5 @@
1
- // #569 step 2: the kiosk. A looping terragucci demo (terragucci#1014),
1
+ // #569 step 2: the kiosk. #569 step 3: a status bar over behold's own view of
2
+ // the change, framed (`/?embed=1&bare=1`), moved to the roots each step is about. A looping terragucci demo (terragucci#1014),
2
3
  // narrated from the demo.json its driver writes at every step, beside the
3
4
  // newest run: the pull request's plan before the merge, the run view's waves
4
5
  // from the merge on, and back to the start when the loop resets.
@@ -12,7 +13,8 @@
12
13
  // A wave that failed says which waves applied before it and stay applied.
13
14
  // Nothing here is all-or-nothing, and nothing says so.
14
15
 
15
- import { STATE_WORDS, costLine, readEdges, readLine, totalsLine, waveRows, blastRows } from "./terragucci-change.js";
16
+ import { startRuns } from "./kiosk-runs.js";
17
+ import { STATE_WORDS, changingRoots, costLine, readEdges, rootsCount, totalsLine, waveRows, blastRows } from "./terragucci-change.js";
16
18
 
17
19
  /** The scenarios terragucci#1014 numbers. An id not here is shown by the title demo.json carries. */
18
20
  export const SCENARIOS = {
@@ -198,13 +200,92 @@ export function pendingApproval(demo, progress) {
198
200
  return {
199
201
  wave: number,
200
202
  digest: w && w.digest ? w.digest : null,
201
- roots: w ? w.roots : [],
203
+ // #569 review: only the roots whose plan the approval covers a change in.
204
+ roots: w ? changingRoots(w, [...new Set([...w.roots, ...(w.composed ? w.composed.roots : [])])]) : [],
202
205
  ...driverApproveLines(number, demo.pull),
203
206
  };
204
207
  }
205
208
 
209
+ /**
210
+ * #569: the roots the step is about, for the framed view to bring forward:
211
+ * the pull request's roots while it is planned; from the merge on, the wave
212
+ * that is waiting, applying, failed or refused (its roots and, for a composed
213
+ * run, every root in it; for a failed or refused wave, the roots of the waves
214
+ * before it that applied, which stay applied); nothing in particular once applied or reset, so the
215
+ * whole picture shows. Null brings nothing forward.
216
+ */
217
+ export function focusRoots(demo, answer, progress) {
218
+ if (!demo) return null;
219
+ const stage = demoStage(demo);
220
+ if (stage === "none" || demo.step === "applied") return null;
221
+ if (stage === "pull-request") {
222
+ const pr = matchingPull(answer, demo);
223
+ return pr && pr.roots && pr.roots.length ? [...pr.roots] : null;
224
+ }
225
+ const run = matchingRun(progress, demo);
226
+ if (!run) {
227
+ const pr = matchingPull(answer, demo);
228
+ return pr && pr.roots && pr.roots.length ? [...pr.roots] : null;
229
+ }
230
+ const order = ["failed", "refused", "waiting", "applying"];
231
+ for (const want of order) {
232
+ const w = progress.states.find((x) => x.state === want);
233
+ if (!w) continue;
234
+ const inRun = progress.reads.filter((r) => r.composed && r.composed.wave === w.number).map((r) => r.root);
235
+ // A wave that failed or refused: the waves before it that applied too, since they stay applied.
236
+ const before = want === "failed" || want === "refused" ? progress.states.filter((x) => x.number < w.number && x.state === "applied").flatMap((x) => changingRoots(x, x.roots)) : [];
237
+ // A root its wave plans no change in is not what the step is about.
238
+ const roots = [...new Set([...changingRoots(w, [...w.roots, ...(w.composed ? w.composed.roots : []), ...inRun]), ...before])];
239
+ if (roots.length) return roots;
240
+ }
241
+ return null;
242
+ }
243
+
244
+ /**
245
+ * #569: which run the framed view draws for this step, so the graph stays on
246
+ * the cycle the narration describes while the loop's next runs (the reset's
247
+ * push back to the base) already go:
248
+ *
249
+ * - before the merge, `none`: no run view, the pull request's plan;
250
+ * - from the merge to `applied`, the cycle's commit (demo.json's `commit`:
251
+ * the merge, whose run view the `/terragucci apply` resume rewrites, or
252
+ * scenario 5's push);
253
+ * - at `reset`, and on the base, `base`: nothing in flight.
254
+ *
255
+ * Never the reset's or the base's run while the step is before `reset`.
256
+ */
257
+ export function runPin(demo) {
258
+ if (!demo) return null;
259
+ if (demo.scenario === "base" || demo.step === "reset") return "base";
260
+ if (demo.step === "pull-request" || demo.step === "planned") return "none";
261
+ if (demo.step === "stalled") return demo.commit ? demo.commit : "none";
262
+ return demo.commit || "none";
263
+ }
264
+
265
+ /** The framed view's address: behold's own page, bare, at the resources zoom, told who its host is. */
266
+ export function frameSrc(origin) {
267
+ const q = new URLSearchParams({ embed: "1", bare: "1", zoom: "resources", host: origin });
268
+ return `/?${q.toString()}`;
269
+ }
270
+
271
+ /** The one-line facts under the view: the pull request's totals, cost and blast radius, as #568 has them. */
272
+ export function factLines(demo, answer) {
273
+ const pr = demo ? matchingPull(answer, demo) : null;
274
+ if (!pr) return [];
275
+ const out = [];
276
+ const totals = totalsLine(pr.totals);
277
+ if (totals) out.push(`pull request #${pr.run.pull_request} changes ${rootsCount(pr.totals_roots) || "across roots"}: ${totals}`);
278
+ const b = blastRows(answer);
279
+ if (b && b.changed.length) out.push(`blast radius: ${b.changed.join(", ")}${b.downstream.length ? `; downstream, nearest first: ${b.downstream.join(", ")}` : ""}`);
280
+ if (b && b.unplanned.length) out.push(`not touched: ${b.unplanned.join(", ")}`);
281
+ const cost = costLine(pr.cost);
282
+ if (cost) out.push(cost);
283
+ return out;
284
+ }
285
+
206
286
  // ---------------------------------------------------------------------------
207
- // The page.
287
+ // The page: a status bar over behold's own view in a frame, and a line of
288
+ // facts under it. The bar is the narration; the frame shows the change.
208
289
 
209
290
  const el = (tag, cls, text) => {
210
291
  const n = document.createElement(tag);
@@ -213,101 +294,72 @@ const el = (tag, cls, text) => {
213
294
  return n;
214
295
  };
215
296
 
216
- /** Draw everything from what the kiosk holds. Pure over its arguments, but for the DOM. */
217
- export function renderKiosk(root, s, now = new Date()) {
218
- root.replaceChildren();
297
+ /** Draw the bar and the foot from what the kiosk holds. Pure over its arguments, but for the DOM. */
298
+ export function renderKiosk(bar, s, now = new Date(), foot = null) {
299
+ bar.replaceChildren();
300
+ if (foot) foot.replaceChildren();
219
301
  if (!s.demo || !s.demo.demo) {
220
- const box = el("section", "k-empty");
302
+ const box = el("div", "k-empty");
221
303
  box.appendChild(el("h1", "k-title", "Waiting for the demo"));
222
304
  box.appendChild(el("p", "k-muted", s.demo ? s.demo.error || s.demo.absent : "Reading demo.json…"));
223
- root.appendChild(box);
305
+ bar.appendChild(box);
224
306
  return;
225
307
  }
226
308
  const demo = s.demo.demo;
227
309
  const head = scenarioHead(demo);
228
310
 
229
- const top = el("header", "k-head");
230
- top.appendChild(el("div", "k-kicker", head.kicker));
231
- top.appendChild(el("h1", "k-title", head.title));
232
- top.appendChild(el("div", "k-muted", cycleWords(demo, now)));
233
- root.appendChild(top);
234
-
311
+ const left = el("div", "k-left");
312
+ const kick = el("div", "k-kicker");
313
+ kick.append(el("span", null, head.kicker), el("span", "k-cycle", cycleWords(demo, now)));
314
+ left.appendChild(kick);
315
+ left.appendChild(el("h1", "k-title", head.title));
316
+ const said = el("div", `k-step k-step-${demo.step}`);
317
+ said.appendChild(el("span", "k-step-word", STEP_WORDS[demo.step] || demo.step));
318
+ said.appendChild(el("p", "k-label", demo.label));
319
+ left.appendChild(said);
320
+ bar.appendChild(left);
321
+
322
+ const right = el("div", "k-right");
235
323
  const strip = el("ol", "k-strip");
236
- for (const stop of stripStops(demo.step)) {
237
- const li = el("li", `k-stop k-${stop.state}`, stop.words);
238
- strip.appendChild(li);
239
- }
240
- root.appendChild(strip);
241
-
242
- const step = el("section", `k-step k-step-${demo.step}`);
243
- step.appendChild(el("div", "k-kicker", STEP_WORDS[demo.step] || demo.step));
244
- step.appendChild(el("p", "k-label", demo.label));
245
- root.appendChild(step);
324
+ for (const stop of stripStops(demo.step)) strip.appendChild(el("li", `k-stop k-${stop.state}`, stop.words));
325
+ right.appendChild(strip);
246
326
 
247
327
  const nx = nextWords(demo, now);
248
- const next = el("section", "k-next");
249
- next.appendChild(el("div", "k-kicker", "next, by the driver"));
250
- const line = el("p", "k-next-line");
251
- line.appendChild(el("span", "k-action", nx.action));
252
- line.appendChild(el("span", "k-when", nx.when));
253
- next.appendChild(line);
328
+ const next = el("div", "k-next");
329
+ next.appendChild(el("span", "k-next-word", "next, by the driver"));
330
+ next.appendChild(el("span", "k-action", nx.action));
331
+ next.appendChild(el("span", "k-when", nx.when));
332
+ right.appendChild(next);
333
+
254
334
  const appr = pendingApproval(demo, s.progress);
255
335
  if (appr) {
256
336
  const a = el("div", "k-approve");
257
337
  a.appendChild(
258
338
  el(
259
339
  "p",
260
- "k-muted",
261
- `The driver approves wave ${appr.wave}${appr.roots.length ? ` (${appr.roots.join(", ")})` : ""} ${nx.secs === null ? "next" : nx.when}, signed with its own key. It runs:`,
340
+ "k-approve-head",
341
+ `The driver approves wave ${appr.wave}${appr.roots.length ? ` (${appr.roots.join(", ")})` : ""}, signed with its own key${appr.digest ? `, for the plan ${appr.digest.replace(/^jcs1-sha256:/, "").slice(0, 12)}` : ""}:`,
262
342
  ),
263
343
  );
264
344
  a.appendChild(el("code", "k-command", appr.approve));
265
- a.appendChild(el("p", "k-muted", appr.digest ? `The plan it approves: ${appr.digest}` : "The plan it approves: the run view names no digest for this wave yet."));
266
- a.appendChild(el("p", "k-muted", `Then it comments on ${appr.on}, so the waves run again:`));
267
- a.appendChild(el("code", "k-command", appr.comment));
268
- next.appendChild(a);
269
- }
270
- root.appendChild(next);
271
-
272
- const panel = runPanel(demo, s.answer, s.progress);
273
- const runBox = el("section", "k-run");
274
- if (panel.note) {
275
- runBox.appendChild(el("div", "k-kicker", panel.stage === "pull-request" ? "the pull request's plan" : panel.stage === "apply" ? "the waves" : "the run"));
276
- runBox.appendChild(el("p", "k-muted", panel.note));
277
- } else if (panel.stage === "pull-request") {
278
- runBox.appendChild(el("div", "k-kicker", `the plan of pull request #${panel.pr.run.pull_request}, commit ${panel.pr.run.commit.slice(0, 8)}`));
279
- const rows = pullRows(s.answer);
280
- for (const e of rows.reads) runBox.appendChild(el("p", e.unknown.length ? "k-read k-unknown" : "k-read", readLine(e)));
281
- for (const u of unreviewedLines(s.answer)) runBox.appendChild(el("p", "k-unreviewed", u));
282
- runBox.appendChild(waveList(rows.waves));
283
- const totals = totalsLine(panel.pr.totals);
284
- if (totals) runBox.appendChild(el("p", "k-muted", `totals across roots: ${totals}`));
285
- const cost = costLine(panel.pr.cost);
286
- if (cost) runBox.appendChild(el("p", "k-muted", cost));
287
- const b = blastRows(s.answer);
288
- if (b && b.downstream.length) runBox.appendChild(el("p", "k-muted", `blast radius, nearest first: ${b.downstream.join(", ")}`));
289
- if (b && b.unplanned.length) runBox.appendChild(el("p", "k-muted", `not planned by this pull request: ${b.unplanned.join(", ")}`));
345
+ const then = el("p", "k-approve-then");
346
+ then.append(`then comments `, el("code", "k-inline", appr.comment), ` on ${appr.on}, and the waves run again.`);
347
+ a.appendChild(then);
348
+ right.appendChild(a);
290
349
  } else {
291
- runBox.appendChild(el("div", "k-kicker", `the waves of ${panel.run.commit.slice(0, 8)}`));
292
- runBox.appendChild(waveList(runRows(s.progress)));
350
+ const panel = runPanel(demo, s.answer, s.progress);
351
+ if (panel.note) right.appendChild(el("p", "k-note", panel.note));
352
+ else if (panel.stage === "pull-request") for (const u of unreviewedLines(s.answer)) right.appendChild(el("p", "k-note k-unknown", u));
293
353
  }
294
- root.appendChild(runBox);
295
-
296
- const foot = el("footer", "k-foot k-muted");
297
- foot.textContent = `narration read ${clockOf(s.demo.read)} from ${s.demo.source}${demo.pull !== null ? ` · pull request #${demo.pull}` : ""}${demo.commit ? ` · commit ${demo.commit.slice(0, 8)}` : ""}`;
298
- root.appendChild(foot);
299
- }
354
+ bar.appendChild(right);
300
355
 
301
- function waveList(rows) {
302
- const ol = el("ol", "k-waves");
303
- for (const w of rows) {
304
- const li = el("li", `k-wave k-tone-${w.tone}`);
305
- li.dataset.state = w.state;
306
- li.appendChild(el("div", "k-wave-title", w.text));
307
- for (const f of w.facts) li.appendChild(el("div", "k-wave-fact", f));
308
- ol.appendChild(li);
356
+ if (foot) {
357
+ foot.appendChild(el("span", "k-legend", "Each box is a Terraform root with its own state; an arrow is one root reading another's outputs, dashed while it reads planned values. A wave applies its roots together, after the waves it reads."));
358
+ for (const f of factLines(demo, s.answer)) foot.appendChild(el("span", "k-fact", f));
359
+ foot.appendChild(
360
+ el("span", "k-source", `narration read ${clockOf(s.demo.read)} from ${s.demo.source}${demo.pull !== null ? ` · pull request #${demo.pull}` : ""}${demo.commit ? ` · commit ${demo.commit.slice(0, 8)}` : ""} · nothing here writes`),
361
+ );
309
362
  }
310
- return ol;
311
363
  }
312
364
 
313
365
  const clockOf = (iso) => {
@@ -319,21 +371,57 @@ const clockOf = (iso) => {
319
371
  export const DEMO_POLL_MS = 3_000;
320
372
 
321
373
  /**
322
- * Boot the kiosk into `root`: ask demo.json every few seconds, the reports
323
- * and the run view when the step, cycle or commit moves or the event stream
324
- * says they did, and redraw every second for the countdown.
374
+ * Boot the kiosk: the bar, the frame and the foot. Ask demo.json every few
375
+ * seconds, the reports and the run view when the step, cycle or commit moves
376
+ * or the event stream says they did, and redraw every second for the
377
+ * countdown. When the roots the step is about change, move the framed view
378
+ * with a `behold:view` message; the frame takes them only from this origin.
325
379
  */
326
- export function startKiosk(root, { fetchJson = defaultFetch, now = () => new Date() } = {}) {
380
+ export function startKiosk({ bar, frame, foot, side, main }, { fetchJson = defaultFetch, now = () => new Date(), origin = location.origin } = {}) {
327
381
  const s = { demo: null, answer: null, progress: null };
328
382
  let lastKey = "";
329
- const draw = () => renderKiosk(root, s, now());
383
+ let lastFocus;
384
+ const tellFrame = (force) => {
385
+ const d = s.demo && s.demo.demo;
386
+ if (!d) return; // nothing said yet: the frame keeps its default until the narration is read
387
+ const roots = focusRoots(d, s.answer, s.progress);
388
+ const focus = roots ? roots.join(",") : null;
389
+ const run = runPin(d);
390
+ const key = `${focus}|${run}`;
391
+ if (!force && key === lastFocus) return;
392
+ lastFocus = key;
393
+ if (frame && frame.contentWindow) frame.contentWindow.postMessage({ type: "behold:view", member: null, focus, run }, origin);
394
+ };
395
+ const draw = () => {
396
+ renderKiosk(bar, s, now(), foot);
397
+ tellFrame(false);
398
+ };
399
+ // #569: the pipeline's runs beside the view, CI-page style.
400
+ if (side && main) startRuns({ side, main }, { fetchJson, now, scenarios: SCENARIOS });
401
+ if (frame) {
402
+ frame.src = frameSrc(origin);
403
+ // The frame reads its graph after load; tell it again once it has had the time.
404
+ frame.addEventListener("load", () => {
405
+ for (const ms of [1500, 4000, 8000]) setTimeout(() => tellFrame(true), ms);
406
+ });
407
+ }
330
408
 
331
409
  const reports = async (fresh) => {
332
410
  s.answer = await fetchJson(`/api/terragucci${fresh ? "?fresh=1" : ""}`);
333
411
  draw();
334
412
  };
413
+ // #569: the run view of the cycle's commit, the same one the frame draws.
414
+ const pinnedCommit = () => {
415
+ const pin = runPin(s.demo && s.demo.demo);
416
+ return pin && pin !== "none" && pin !== "base" ? pin : null;
417
+ };
335
418
  const progress = async (fresh) => {
336
- s.progress = await fetchJson(`/api/terragucci/progress${fresh ? "?fresh=1" : ""}`);
419
+ const pin = pinnedCommit();
420
+ const q = new URLSearchParams();
421
+ if (pin) q.set("commit", pin);
422
+ if (fresh) q.set("fresh", "1");
423
+ const qs = q.toString();
424
+ s.progress = await fetchJson(`/api/terragucci/progress${qs ? `?${qs}` : ""}`);
337
425
  draw();
338
426
  };
339
427
 
@@ -362,8 +450,14 @@ export function startKiosk(root, { fetchJson = defaultFetch, now = () => new Dat
362
450
  es.addEventListener("reports", () => void reports(false));
363
451
  es.addEventListener("progress", (e) => {
364
452
  try {
365
- s.progress = JSON.parse(e.data);
366
- draw();
453
+ const pushed = JSON.parse(e.data);
454
+ const pin = pinnedCommit();
455
+ const c = pushed && pushed.run && pushed.run.commit;
456
+ // The pushed view is the newest run's: take it only when it is the cycle's.
457
+ if (!pin || (c && (c.startsWith(pin) || pin.startsWith(c)))) {
458
+ s.progress = pushed;
459
+ draw();
460
+ } else void progress(true);
367
461
  } catch {
368
462
  void progress(false);
369
463
  }
@@ -0,0 +1,65 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { focusRoots, frameSrc, factLines, pendingApproval, runPin } from "./kiosk.js";
3
+
4
+ const demo = (step, extra = {}) => ({ cycle: 1, scenario: "a-output", title: "t", step, label: "", started_at: "2026-10-15T03:00:00Z", next: null, pull: 7, commit: "abc12345", ...extra });
5
+ const answer = {
6
+ roots: ["app", "edge", "network", "other"].map((name) => ({ member: "demo", name, path: name })),
7
+ pull_request: { run: { pull_request: "7", commit: "abc12345" }, roots: ["app", "edge", "network"], reads: [], waves: [], totals: { update: 3 }, totals_roots: 3, blast: { roots: ["app", "edge", "network"], downstream: [] } },
8
+ };
9
+ const progress = (states, reads = []) => ({ run: { present: true, commit: "abc12345", updated: "2026-10-15T03:05:00Z" }, states, reads });
10
+
11
+ describe("focusRoots (#569)", () => {
12
+ it("brings the pull request's roots forward while it is planned", () => {
13
+ expect(focusRoots(demo("planned"), answer, null)).toEqual(["app", "edge", "network"]);
14
+ });
15
+ it("brings the wave that waits, applies or failed forward, and nothing once applied", () => {
16
+ const p = progress([
17
+ { number: 1, state: "applied", roots: ["network", "other"], reads: [] },
18
+ { number: 2, state: "waiting", roots: ["app"], reads: [1] },
19
+ ]);
20
+ expect(focusRoots(demo("waiting"), answer, p)).toEqual(["app"]);
21
+ expect(focusRoots(demo("applied"), answer, p)).toBe(null);
22
+ expect(focusRoots(demo("reset"), answer, p)).toBe(null);
23
+ });
24
+ it("brings a failed wave forward with the waves before it that stay applied", () => {
25
+ const p = progress([
26
+ { number: 1, state: "applied", roots: ["network", "other"], reads: [] },
27
+ { number: 2, state: "failed", roots: ["app"], reads: [1] },
28
+ { number: 3, state: "not-started", roots: ["edge"], reads: [2] },
29
+ ]);
30
+ expect(focusRoots(demo("failed"), answer, p)).toEqual(["app", "network", "other"]);
31
+ });
32
+ it("leaves a root its wave plans no change in out (#569 review)", () => {
33
+ const p = progress([{ number: 1, state: "waiting", roots: ["network", "other"], reads: [], digest: "d", totals: { create: 1 }, changed: ["network"] }]);
34
+ expect(focusRoots(demo("waiting"), answer, p)).toEqual(["network"]);
35
+ expect(pendingApproval(demo("waiting", { next: { action: "approve wave-1", at: null } }), p).roots).toEqual(["network"]);
36
+ });
37
+ it("takes every root of a composed run", () => {
38
+ const p = progress([{ number: 1, state: "applying", roots: ["network"], reads: [], composed: { roots: ["network", "app", "edge"] } }]);
39
+ expect(focusRoots(demo("applying"), answer, p)).toEqual(["network", "app", "edge"]);
40
+ });
41
+ });
42
+
43
+ describe("frameSrc (#569)", () => {
44
+ it("frames behold's own view, bare, told its host", () => {
45
+ expect(frameSrc("http://localhost:4700")).toBe("/?embed=1&bare=1&zoom=resources&host=http%3A%2F%2Flocalhost%3A4700");
46
+ });
47
+ });
48
+
49
+ describe("factLines (#569)", () => {
50
+ it("says the pull request's totals and blast radius", () => {
51
+ expect(factLines(demo("planned"), answer)).toEqual(["pull request #7 changes across 3 roots: 3 update", "blast radius: app, edge, network", "not touched: other"]);
52
+ });
53
+ });
54
+
55
+ describe("runPin: the frame stays on the cycle's run (#569)", () => {
56
+ it("draws the pull request's plan, then the cycle's commit up to applied, then the base", () => {
57
+ expect(runPin(demo("pull-request"))).toBe("none");
58
+ expect(runPin(demo("planned"))).toBe("none");
59
+ for (const step of ["merged", "applying", "waiting", "approved", "failed", "resumed", "applied"]) expect(runPin(demo(step))).toBe("abc12345");
60
+ expect(runPin(demo("reset"))).toBe("base");
61
+ expect(runPin(demo("applied", { scenario: "base" }))).toBe("base");
62
+ expect(runPin(demo("stalled", { commit: null }))).toBe("none");
63
+ expect(runPin(null)).toBe(null);
64
+ });
65
+ });