@intentius/behold 0.29.4 → 0.29.6

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/dist/cli.js CHANGED
@@ -24215,6 +24215,7 @@ var UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
24215
24215
  var isRecord17 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
24216
24216
  var str13 = (v) => typeof v === "string";
24217
24217
  var int = (v) => typeof v === "number" && Number.isInteger(v);
24218
+ var positive = (v) => int(v) && v > 0 ? v : null;
24218
24219
  function demoProblem(doc) {
24219
24220
  if (!isRecord17(doc)) return "it is not an object";
24220
24221
  if (!int(doc.cycle)) return "cycle is not an integer";
@@ -24269,7 +24270,11 @@ async function readDemo(source2, now = /* @__PURE__ */ new Date()) {
24269
24270
  next: d.next ? { action: d.next.action, at: d.next.at } : null,
24270
24271
  pull: d.pull,
24271
24272
  commit: d.commit,
24272
- about: d.about ? { changes: d.about.changes, watch: d.about.watch, shows: d.about.shows } : null
24273
+ about: d.about ? { changes: d.about.changes, watch: d.about.watch, shows: d.about.shows } : null,
24274
+ intro: str13(d.intro) && d.intro.trim() ? d.intro.trim() : null,
24275
+ name: str13(d.name) && d.name.trim() ? d.name.trim() : null,
24276
+ scenarios: positive(d.scenarios),
24277
+ index: positive(d.index)
24273
24278
  }
24274
24279
  };
24275
24280
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/behold",
3
- "version": "0.29.4",
3
+ "version": "0.29.6",
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
@@ -10,7 +10,7 @@
10
10
  // renderPanelView never rewrites, so the select mounts once and survives re-renders).
11
11
  import { createRefreshQueue } from "./refresh-queue.js";
12
12
  import { readViewQuery, writeViewQuery, settleView, settlePlace, refusalLine } from "./view-url.js";
13
- import { readEmbed, selectMessage, viewFromMessage } from "./embed.js";
13
+ import { quietCardLines, readEmbed, selectMessage, viewFromMessage } from "./embed.js";
14
14
  import { gateCards } from "./workspace-gates.js";
15
15
  import { cornerOf, estateLine, hrefOf as tgHref, legend as tgLegend, markLine, rootRows as tgRootRows, waveCards } from "./terragucci.js";
16
16
  import { laneElement as tgLane, rootOfCard as tgRootOfCard, historyElement as tgHistoryLane } from "./terragucci-timeline.js";
@@ -3502,7 +3502,7 @@ function drawTgReads() {
3502
3502
  }
3503
3503
  for (const t of svg.querySelectorAll("[data-tg-hid]")) {
3504
3504
  t.removeAttribute("data-tg-hid");
3505
- t.removeAttribute("visibility");
3505
+ if (!t.hasAttribute("data-kiosk-quiet")) t.removeAttribute("visibility");
3506
3506
  }
3507
3507
  for (const g of svg.querySelectorAll("[data-tg-dim]")) {
3508
3508
  g.removeAttribute("data-tg-dim");
@@ -5329,6 +5329,8 @@ function render(ir, svg, m) {
5329
5329
  svgEl.removeAttribute("width");
5330
5330
  svgEl.removeAttribute("height");
5331
5331
  svgEl.setAttribute("preserveAspectRatio", "xMidYMid meet");
5332
+ // behold#571: the kiosk's frame drops the cards' kind and meta lines.
5333
+ if (embedOpts.bare) quietCardLines(svgEl);
5332
5334
  setupGraphViewBox(svgEl);
5333
5335
  wireEdgeHighlight(svgEl, ir);
5334
5336
  // Mark each wave lane as a GitLab CI stage when the CI projection is loaded
package/web/embed.js CHANGED
@@ -79,3 +79,23 @@ export function viewFromMessage(event, host) {
79
79
  }
80
80
  return want;
81
81
  }
82
+
83
+ /**
84
+ * behold#571: a card's line the kiosk's frame leaves out, for a viewer new to
85
+ * Terraform: the kind line ("Component · chant", "terraform_data ·
86
+ * terraform"), and the `archetype:`, `wave:` and `root:` meta lines (the wave
87
+ * or the root is the box the card sits in). behold's own page keeps them.
88
+ */
89
+ export function kioskQuietLine(text) {
90
+ const t = typeof text === "string" ? text.trim() : "";
91
+ return /^Component\b/.test(t) || /^[\w.-]+ · [\w.-]+$/.test(t) || /^archetype:\s*\S/.test(t) || /^wave:\s*\d+$/.test(t) || /^root:\s*\S+$/.test(t);
92
+ }
93
+
94
+ /** Hide the kioskQuietLine lines of every card in a drawn graph; marks them so a redraw of the marks leaves them hidden. */
95
+ export function quietCardLines(svg) {
96
+ for (const t of svg.querySelectorAll("[data-node-id] text")) {
97
+ if (!kioskQuietLine(t.textContent)) continue;
98
+ t.setAttribute("visibility", "hidden");
99
+ t.setAttribute("data-kiosk-quiet", "1");
100
+ }
101
+ }
package/web/embed.test.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { readEmbed, selectMessage, viewFromMessage, THEME_ALIASES } from "./embed.js";
2
+ import { kioskQuietLine, readEmbed, selectMessage, viewFromMessage, THEME_ALIASES } from "./embed.js";
3
3
 
4
4
  describe("readEmbed (#476)", () => {
5
5
  it("reads embed, an exact host origin and a theme", () => {
@@ -63,3 +63,12 @@ describe("viewFromMessage focus (#569)", () => {
63
63
  expect(viewFromMessage({ origin: host, data: { type: "behold:view", focus: "" } }, host)).toEqual({ focus: null });
64
64
  });
65
65
  });
66
+
67
+ describe("kioskQuietLine (behold#571)", () => {
68
+ it("leaves out a card's kind and meta lines in the kiosk's frame", () => {
69
+ for (const t of ["Component · chant", "terraform_data · terraform", "archetype: infra", "wave: 2", " root: app "]) expect(kioskQuietLine(t)).toBe(true);
70
+ });
71
+ it("keeps the names, the states and the reads", () => {
72
+ for (const t of ["network", "terraform_data.http_proxy_port", "waiting", "reads network · planned · 1 unknown", "wave 2 · planned", "", null]) expect(kioskQuietLine(t)).toBe(false);
73
+ });
74
+ });
package/web/kiosk-runs.js CHANGED
@@ -159,6 +159,22 @@ export function defaultJob(jobs) {
159
159
  return started[0] || jobs.find((j) => j.status !== "skipped") || jobs[0] || null;
160
160
  }
161
161
 
162
+ /** Forgejo's runner line ending a step that exited 3, the code terragucci holds a wave with. */
163
+ const RUNNER_EXIT_3 = /^\s*⚙️\s*\[runner\]: exitcode '3': failure\s*$/u;
164
+
165
+ /**
166
+ * A log line as the kiosk shows it under a job: in a job held at the gate,
167
+ * the runner's `exitcode '3': failure` reads as what it is, a hold waiting for
168
+ * an approval, never a failure. `{ text, held }`; any other line, and any line
169
+ * of a job that is not held, is kept as it came.
170
+ */
171
+ export function logLineWords(jobStatus, line) {
172
+ if (jobStatus === "waiting" && typeof line === "string" && RUNNER_EXIT_3.test(line)) {
173
+ return { text: "[runner]: exit code 3: held at the gate, waiting for an approval", held: true };
174
+ }
175
+ return { text: line, held: false };
176
+ }
177
+
162
178
  /** The workflow's own steps of a job: not the runner's set-up or trailer, a post step, or an action's (named by its address). */
163
179
  function ownSteps(steps) {
164
180
  return steps.map((s, i) => ({ s, i })).filter(({ s, i }) => i > 0 && i < steps.length - 1 && !isPostStep(s.name) && !looksLikeAction(s.name));
@@ -211,12 +227,88 @@ export function agoWords(at, now = new Date()) {
211
227
  return `${Math.floor(s / 86400)} d ago`;
212
228
  }
213
229
 
214
- /** "a-output" as the kiosk names scenarios, with the cycle: "A, read through .output · cycle 90". */
215
- export function placeWords(run, scenarios) {
216
- const sc = run.scenario && scenarios && Object.prototype.hasOwnProperty.call(scenarios, run.scenario) ? scenarios[run.scenario].name : run.scenario;
230
+ /**
231
+ * behold#571: whether a scenario's short label reads as code to someone new
232
+ * to the demo: a lettered label ("A, read through .output", "B on waves"), a
233
+ * code token in it, or the bare id ("a-output").
234
+ */
235
+ export function codeish(label) {
236
+ return typeof label === "string" && (/^[A-Z]\b/.test(label) || /\.[A-Za-z]|[_`]/.test(label) || /^[a-z0-9]+(?:-[a-z0-9]+)+$/.test(label));
237
+ }
238
+
239
+ const MERGE_TITLE = /^Merge pull request '(.+)' \(#\d+\)/;
240
+
241
+ /**
242
+ * behold#571: each scenario's title in plain words, by id: from the merge
243
+ * runs' titles (the pull request is titled after its scenario), and from
244
+ * `known` (the titles demo.json has carried, which win).
245
+ */
246
+ export function scenarioTitles(runs, known = {}) {
247
+ const out = {};
248
+ for (const r of [...(runs || [])].reverse()) {
249
+ const m = r && r.scenario && typeof r.title === "string" ? MERGE_TITLE.exec(r.title) : null;
250
+ if (m) out[r.scenario] = m[1];
251
+ }
252
+ for (const [id, title] of Object.entries(known || {})) if (typeof title === "string" && title) out[id] = title;
253
+ return out;
254
+ }
255
+
256
+ /**
257
+ * The scenario and the cycle under a run: "a failure mid-apply · cycle 90".
258
+ * The scenario's name as demo.json last carried it (`names`, terragucci#1050)
259
+ * comes first, then the kiosk's SCENARIOS label; a label that reads as code
260
+ * gives way to the scenario's title when one is known (behold#571).
261
+ */
262
+ export function placeWords(run, scenarios, titles = {}, names = {}) {
263
+ const own = (m) => !!(run.scenario && m && Object.prototype.hasOwnProperty.call(m, run.scenario));
264
+ const short = own(names) && names[run.scenario] ? names[run.scenario] : own(scenarios) ? scenarios[run.scenario].name : run.scenario;
265
+ const title = own(titles) ? titles[run.scenario] : null;
266
+ const sc = title && (!short || codeish(short)) ? title : short;
217
267
  return [sc, run.cycle !== null && run.cycle !== undefined ? `cycle ${run.cycle}` : null].filter(Boolean).join(" · ");
218
268
  }
219
269
 
270
+ /**
271
+ * behold#571: a runner's or an action's set-up line, not terragucci's own:
272
+ * git's hints, the checkout's `[command]` lines and groups, the runner's
273
+ * docker and fetch lines.
274
+ */
275
+ export const SETUP_LINES = [
276
+ /^hint:/,
277
+ /^\[command\]/,
278
+ /^##\[(?:group|endgroup)\]/,
279
+ /^Initialized empty Git repository\b/,
280
+ /^::(?:add|remove)-matcher\b/,
281
+ /^\s*❓\s*add-matcher\b/u,
282
+ /^\s*🐳\s+docker\b/u,
283
+ /^\s*☁️\s+git fetch\b/u,
284
+ /^git version \d/,
285
+ /^Temporarily overriding HOME=/,
286
+ /^Adding repository directory to the temporary git global config\b/,
287
+ /^Deleting the contents of '/,
288
+ /^Syncing repository: /,
289
+ /^Working directory is '/,
290
+ ];
291
+
292
+ /** Whether a log line is set-up noise (SETUP_LINES). */
293
+ export function setupLine(line) {
294
+ return typeof line === "string" && SETUP_LINES.some((re) => re.test(line));
295
+ }
296
+
297
+ /**
298
+ * behold#571: a step's log as the kiosk draws it. The set-up lines fold into
299
+ * one row at the top, `{ setup: n }`, so terragucci's own lines come first;
300
+ * opened (`open`), the row heads the whole log in order. Every line keeps
301
+ * its number in the log. A log with no set-up lines is drawn as it came.
302
+ */
303
+ export function logRows(lines, { open = false, from = 0 } = {}) {
304
+ const all = (lines || []).map((text, i) => ({ n: from + i + 1, text, setup: setupLine(text) }));
305
+ const n = all.filter((l) => l.setup).length;
306
+ if (!n) return all.map(({ n: num, text }) => ({ n: num, text }));
307
+ const head = { setup: n, open };
308
+ const body = open ? all : all.filter((l) => !l.setup);
309
+ return [head, ...body.map(({ n: num, text, setup }) => (open && setup ? { n: num, text, quiet: true } : { n: num, text }))];
310
+ }
311
+
220
312
  // ---------------------------------------------------------------------------
221
313
  // Drawing.
222
314
 
@@ -261,7 +353,7 @@ export function statusIcon(status) {
261
353
  }
262
354
 
263
355
  /** The sidebar: the live run pinned, then the recent ones. `pick(id)` selects one; nothing here writes. */
264
- export function renderSidebar(host, index, selectedId, { now = new Date(), scenarios = {}, pick = () => {}, merged = null } = {}) {
356
+ export function renderSidebar(host, index, selectedId, { now = new Date(), scenarios = {}, titles = {}, names = {}, pick = () => {}, merged = null } = {}) {
265
357
  host.replaceChildren();
266
358
  const head = el("div", "r-side-head");
267
359
  head.appendChild(el("div", "r-side-title", "Pipeline runs"));
@@ -274,6 +366,7 @@ export function renderSidebar(host, index, selectedId, { now = new Date(), scena
274
366
  // The selected run as its page draws it, when its own document is newer than the index.
275
367
  const runs = sidebarRuns(index.doc.runs).map((r) => (merged && merged.id === r.id ? merged : r));
276
368
  const live = runs[0];
369
+ const named = scenarioTitles(index.doc.runs, titles);
277
370
  const list = el("ol", "r-runs");
278
371
  runs.forEach((r, i) => {
279
372
  if (i === 0) list.appendChild(el("li", "r-group", LIVE.has(live.status) ? "Live" : "Latest"));
@@ -284,7 +377,7 @@ export function renderSidebar(host, index, selectedId, { now = new Date(), scena
284
377
  li.appendChild(statusIcon(r.status));
285
378
  const body = el("div", "r-run-body");
286
379
  body.appendChild(el("div", "r-run-title", triggerWords(r)));
287
- const place = placeWords(r, scenarios);
380
+ const place = placeWords(r, scenarios, named, names);
288
381
  if (place) body.appendChild(el("div", "r-run-place", place));
289
382
  const meta = el("div", "r-run-meta");
290
383
  meta.append(
@@ -312,7 +405,7 @@ export function renderSidebar(host, index, selectedId, { now = new Date(), scena
312
405
  * null for the defaults. Returns what it drew, so the caller can keep a log's
313
406
  * scroll.
314
407
  */
315
- export function renderRun(host, answer, ui, { now = new Date(), pickJob = () => {}, toggleStep = () => {}, entry = null } = {}) {
408
+ export function renderRun(host, answer, ui, { now = new Date(), pickJob = () => {}, toggleStep = () => {}, toggleSetup = () => {}, entry = null } = {}) {
316
409
  host.replaceChildren();
317
410
  if (!answer || answer.error || answer.absent || !answer.doc) {
318
411
  host.appendChild(el("p", "r-note", answer ? answer.absent || answer.error : "Reading the run…"));
@@ -382,11 +475,25 @@ export function renderRun(host, answer, ui, { now = new Date(), pickJob = () =>
382
475
  if (i === open) {
383
476
  const pre = el("pre", "r-log");
384
477
  if (s.truncated) pre.appendChild(el("div", "r-log-cut", `… ${s.truncated} earlier lines not kept`));
385
- s.log.forEach((l, n) => {
386
- const ln = el("div", "r-log-line");
387
- ln.append(el("span", "r-ln", String((s.truncated || 0) + n + 1)), el("span", "r-lt", l));
478
+ const key = logKey(run.id, job.name, i);
479
+ for (const row of logRows(s.log, { open: ui.setup === key, from: s.truncated || 0 })) {
480
+ if (row.setup) {
481
+ const fold = el("div", `r-log-setup${row.open ? " r-log-setup-open" : ""}`, `${row.open ? "▾" : "▸"} setup (${row.setup} line${row.setup === 1 ? "" : "s"})`);
482
+ fold.setAttribute("role", "button");
483
+ fold.tabIndex = 0;
484
+ fold.setAttribute("aria-expanded", String(row.open));
485
+ fold.addEventListener("click", () => toggleSetup(key));
486
+ fold.addEventListener("keydown", (e) => {
487
+ if (e.key === "Enter" || e.key === " ") toggleSetup(key);
488
+ });
489
+ pre.appendChild(fold);
490
+ continue;
491
+ }
492
+ const ln = el("div", `r-log-line${row.quiet ? " r-log-quiet" : ""}`);
493
+ const shown = logLineWords(job.status, row.text);
494
+ ln.append(el("span", "r-ln", String(row.n)), el("span", shown.held ? "r-lt r-wait" : "r-lt", shown.text));
388
495
  pre.appendChild(ln);
389
- });
496
+ }
390
497
  if (!s.log.length) pre.appendChild(el("div", "r-log-cut", s.status === "running" ? "waiting for output…" : "no output"));
391
498
  if (s.status === "running") pre.appendChild(el("div", "r-log-live", "▍"));
392
499
  row.appendChild(pre);
@@ -430,8 +537,8 @@ export const PICK_HOLD_MS = 90_000;
430
537
  * is live), and redraw. The live run is selected until a viewer picks
431
538
  * another, and again {@link PICK_HOLD_MS} after their last pick.
432
539
  */
433
- export function startRuns({ side, main }, { fetchJson, now = () => new Date(), scenarios = {}, onLive = () => {} } = {}) {
434
- const s = { index: null, run: null, selected: null, picked: 0, ui: { job: null, open: null } };
540
+ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), scenarios = {}, titles = () => ({}), names = () => ({}), onLive = () => {} } = {}) {
541
+ const s = { index: null, run: null, selected: null, picked: 0, ui: { job: null, open: null, setup: null } };
435
542
  let follow = true;
436
543
  let lastRunKey = "";
437
544
  // The open log and whether the pane follows its end; a viewer who scrolls up stops it until they come back.
@@ -453,7 +560,7 @@ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), s
453
560
  const ans = s.run && (!s.run.doc || s.run.doc.id === id) ? s.run : null;
454
561
  // One merged read of the selected run for both the sidebar and its page.
455
562
  const merged = ans && ans.doc ? mergeRun(entry, ans.doc) : null;
456
- renderSidebar(side, s.index, id, { now: now(), scenarios, pick, merged });
563
+ renderSidebar(side, s.index, id, { now: now(), scenarios, titles: titles(), names: names(), pick, merged });
457
564
  // The redraw replaces the scrollers: keep where a viewer left them.
458
565
  const old = main.querySelector(".r-log");
459
566
  const oldSteps = main.querySelector(".r-steps");
@@ -463,12 +570,17 @@ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), s
463
570
  now: now(),
464
571
  entry,
465
572
  pickJob: (name) => {
466
- s.ui = { job: name, open: null };
573
+ s.ui = { job: name, open: null, setup: null };
467
574
  hold();
468
575
  draw();
469
576
  },
470
577
  toggleStep: (job, i) => {
471
- s.ui = { job, open: i };
578
+ s.ui = { job, open: i, setup: null };
579
+ hold();
580
+ draw();
581
+ },
582
+ toggleSetup: (key) => {
583
+ s.ui = { ...s.ui, setup: s.ui.setup === key ? null : key };
472
584
  hold();
473
585
  draw();
474
586
  },
@@ -511,7 +623,7 @@ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), s
511
623
  s.selected = id;
512
624
  follow = false;
513
625
  s.picked = now().getTime();
514
- s.ui = { job: null, open: null };
626
+ s.ui = { job: null, open: null, setup: null };
515
627
  void readRun();
516
628
  };
517
629
  const readIndex = async () => {
@@ -528,7 +640,7 @@ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), s
528
640
  // A run that ended reads once; one still going reads on every tick.
529
641
  const key = `${id}|${entry ? entry.status : ""}|${entry ? entry.ended_at : ""}`;
530
642
  if (key === lastRunKey && entry && !LIVE.has(entry.status) && s.run && s.run.doc && s.run.doc.id === id) return draw();
531
- if (s.run && s.run.doc && s.run.doc.id !== id) s.ui = { job: null, open: null };
643
+ if (s.run && s.run.doc && s.run.doc.id !== id) s.ui = { job: null, open: null, setup: null };
532
644
  lastRunKey = key;
533
645
  s.run = await fetchJson(`/api/terragucci/runs/${id}`);
534
646
  draw();
@@ -1,7 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { describe, expect, it } from "vitest";
4
- import { FOLLOW_LINES, followsLog, logKey, nearEnd, mergeRun, newerOf, agoWords, defaultJob, defaultStep, durationWords, jobOrder, liveRun, placeWords, sidebarRuns, splitSkipped, STATUS_TONE, triggerWords } from "./kiosk-runs.js";
4
+ import { FOLLOW_LINES, followsLog, logKey, nearEnd, mergeRun, newerOf, agoWords, defaultJob, defaultStep, durationWords, jobOrder, liveRun, codeish, logLineWords, logRows, placeWords, scenarioTitles, setupLine, sidebarRuns, splitSkipped, STATUS_TONE, triggerWords } from "./kiosk-runs.js";
5
5
 
6
6
  const FIX = join(import.meta.dirname, "..", "src", "__fixtures__", "terragucci-demo", "runs");
7
7
  const run = (id) => JSON.parse(readFileSync(join(FIX, `${id}.json`), "utf8"));
@@ -21,6 +21,20 @@ describe("the runs, as a CI page lists them (#569)", () => {
21
21
  expect(STATUS_TONE.waiting).toBe("wait");
22
22
  expect(STATUS_TONE.waiting).not.toBe(STATUS_TONE.failure);
23
23
  });
24
+ it("shows the runner's exit 3 under a job held at the gate as the hold, not a failure", () => {
25
+ const line = "⚙️ [runner]: exitcode '3': failure";
26
+ expect(logLineWords("waiting", line)).toEqual({ text: "[runner]: exit code 3: held at the gate, waiting for an approval", held: true });
27
+ // The fixture's held job ends its log with that line.
28
+ const held = run(444).jobs.find((j) => j.status === "waiting");
29
+ const last = held.steps.find((s) => s.status === "waiting").log.at(-1);
30
+ expect(logLineWords(held.status, last).held).toBe(true);
31
+ // A real failure keeps its line, whatever the code.
32
+ expect(logLineWords("failure", line)).toEqual({ text: line, held: false });
33
+ expect(logLineWords("failure", "⚙️ [runner]: exitcode '1': failure")).toEqual({ text: "⚙️ [runner]: exitcode '1': failure", held: false });
34
+ // A held job's other lines, and another code, are kept as they came.
35
+ expect(logLineWords("waiting", "⚙️ [runner]: exitcode '1': failure").held).toBe(false);
36
+ expect(logLineWords("waiting", "wave 2 of 3: approval ledger (the default)").held).toBe(false);
37
+ });
24
38
  it("follows the newest run still going, pinned first", () => {
25
39
  const runs = index().runs;
26
40
  expect(liveRun(runs).id).toBe(448);
@@ -117,3 +131,55 @@ describe("the log pane follows its end, like a CI page's log", () => {
117
131
  expect(nearEnd({ scrollTop: 0, clientHeight: 400, scrollHeight: 1000 }, slack)).toBe(false);
118
132
  });
119
133
  });
134
+
135
+ describe("the runs for a viewer new to the demo (behold#571)", () => {
136
+ const SC = { "a-output": { name: "A, read through .output" }, "b-waves": { name: "B on waves" }, failure: { name: "a failure mid-apply" } };
137
+ it("prefers the scenario's title when its short label reads as code", () => {
138
+ expect(codeish("A, read through .output")).toBe(true);
139
+ expect(codeish("B on waves")).toBe(true);
140
+ expect(codeish("a-output")).toBe(true);
141
+ expect(codeish("a failure mid-apply")).toBe(false);
142
+ expect(codeish("the base")).toBe(false);
143
+ const titles = { "a-output": "The same change, read once it is built", failure: "One root fails partway" };
144
+ expect(placeWords({ scenario: "a-output", cycle: 90 }, SC, titles)).toBe("The same change, read once it is built · cycle 90");
145
+ expect(placeWords({ scenario: "failure", cycle: 92 }, SC, titles)).toBe("a failure mid-apply · cycle 92");
146
+ expect(placeWords({ scenario: "b-waves", cycle: 91 }, SC, titles)).toBe("B on waves · cycle 91");
147
+ expect(placeWords({ scenario: "c-new", cycle: 93 }, SC, { "c-new": "A new one" })).toBe("A new one · cycle 93");
148
+ expect(placeWords({ scenario: "a-output", cycle: 90 }, SC)).toBe("A, read through .output · cycle 90");
149
+ });
150
+ it("takes demo.json's name for a scenario before the table's label (terragucci#1050)", () => {
151
+ const names = { "b-waves": "a new subnet", failure: "a failure halfway" };
152
+ expect(placeWords({ scenario: "b-waves", cycle: 12 }, SC, {}, names)).toBe("a new subnet · cycle 12");
153
+ expect(placeWords({ scenario: "failure", cycle: 12 }, SC, { failure: "One root fails" }, names)).toBe("a failure halfway · cycle 12");
154
+ expect(placeWords({ scenario: "a-output", cycle: 12 }, SC, {}, names)).toBe("A, read through .output · cycle 12");
155
+ });
156
+ it("learns titles from the merge runs, and demo.json's win", () => {
157
+ const runs = index().runs;
158
+ const t = scenarioTitles(runs);
159
+ expect(t["a-known"]).toBe("A port change, known when it plans");
160
+ expect(scenarioTitles(runs, { "a-known": "A plain title" })["a-known"]).toBe("A plain title");
161
+ expect(scenarioTitles([], {})).toEqual({});
162
+ });
163
+ it("knows the runner's set-up lines from terragucci's own", () => {
164
+ for (const l of ["hint: Using 'master' as the name", "[command]/usr/bin/git init /workspace/demo", "##[group]Initializing the repository", "##[endgroup]", "Initialized empty Git repository in /workspace/demo/.git/"]) expect(setupLine(l)).toBe(true);
165
+ for (const l of ["wave 2 of 3: planning app", "app: Plan: 0 to add, 1 to change, 0 to destroy.", " terragucci approve wave-2 --plan jcs1-sha256:0a", "⚙️ [runner]: exitcode '3': failure"]) expect(setupLine(l)).toBe(false);
166
+ });
167
+ it("folds the set-up lines into one row at the top, and opens it in order", () => {
168
+ const log = ["##[group]Init", "[command]/usr/bin/git init", "wave 1 of 3: planning network", "hint: x", "##[endgroup]", "wave 1 of 3: applied"];
169
+ expect(logRows(log)).toEqual([{ setup: 4, open: false }, { n: 3, text: "wave 1 of 3: planning network" }, { n: 6, text: "wave 1 of 3: applied" }]);
170
+ const open = logRows(log, { open: true });
171
+ expect(open[0]).toEqual({ setup: 4, open: true });
172
+ expect(open.slice(1).map((r) => r.n)).toEqual([1, 2, 3, 4, 5, 6]);
173
+ expect(open.filter((r) => r.quiet).map((r) => r.n)).toEqual([1, 2, 4, 5]);
174
+ expect(logRows(["a", "b"], { from: 10 })).toEqual([{ n: 11, text: "a" }, { n: 12, text: "b" }]);
175
+ });
176
+ it("folds the checkout's set-up in a real held job's log, and leaves terragucci's step as it came", () => {
177
+ const job = run(444).jobs.find((j) => j.status === "waiting");
178
+ const checkout = job.steps.find((s) => s.name.includes("checkout") && !s.name.startsWith("Post"));
179
+ const rows = logRows(checkout.log);
180
+ expect(rows[0].setup).toBeGreaterThan(40);
181
+ expect(rows.slice(1).some((r) => /^hint:|^\[command\]|^##\[/.test(r.text))).toBe(false);
182
+ const own = job.steps.find((s) => /^Apply wave/.test(s.name));
183
+ expect(logRows(own.log)).toEqual(own.log.map((text, i) => ({ n: i + 1, text })));
184
+ });
185
+ });
package/web/kiosk.html CHANGED
@@ -74,30 +74,36 @@
74
74
  white-space: pre-wrap; overflow-wrap: anywhere; overflow-y: auto; max-height: 52vh; min-height: 6em; }
75
75
  .r-log-line { display: grid; grid-template-columns: 3.2em 1fr; gap: 0.8em; padding: 0 0.8em 0 0; }
76
76
  .r-ln { color: var(--muted); text-align: right; user-select: none; opacity: 0.7; }
77
+ /* behold#571: the runner's set-up lines, folded into one row so terragucci's own lines come first. */
78
+ .r-log-setup { color: var(--muted); padding: 0 0.8em 0.2em 4em; cursor: pointer; user-select: none; }
79
+ .r-log-setup:hover { color: var(--fg); }
80
+ .r-log-quiet .r-lt { color: var(--muted); }
77
81
  .r-log-cut { color: var(--muted); padding: 0 0.8em 0.2em 4em; font-style: italic; }
78
82
  .r-log-live { color: var(--foreign); padding-left: 4em; }
79
83
  @media (prefers-reduced-motion: no-preference) { .r-log-live { animation: r-blink 1s steps(2) infinite; } }
80
84
  @keyframes r-blink { 50% { opacity: 0; } }
81
85
  .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); }
86
+ #bar { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); gap: 0.45em 1.6em; align-items: start;
87
+ padding: 0.55em 1.4em 0.6em; background: var(--panel); border-bottom: 1px solid var(--rule); }
84
88
  #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; }
89
+ /* behold#571: the problem the demo solves, always on, across the bar's top. */
90
+ .k-intro { grid-column: 1 / -1; margin: 0; font-size: 1em; line-height: 1.35; color: var(--fg); }
91
+ .k-kicker { font: 600 0.58em var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin-right: 0.9em; vertical-align: 0.2em; white-space: nowrap; }
92
+ .k-cycle { color: var(--muted); font: 0.78em var(--font-mono); white-space: nowrap; }
93
+ .k-title { margin: 0 0 0.2em; font-size: 1.35em; line-height: 1.2; }
88
94
  /* behold#571: the scenario's card, three labelled rows under the title. */
89
- .k-about { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 0.12em 0.9em; align-items: baseline; margin: 0 0 0.45em; font-size: 0.9em; line-height: 1.3; }
95
+ .k-about { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 0.08em 0.9em; align-items: baseline; margin: 0 0 0.35em; font-size: 0.88em; line-height: 1.25; }
90
96
  .k-about-label { font: 600 0.74em var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
91
97
  .k-about-text { margin: 0; color: var(--fg); }
92
98
  .k-muted { color: var(--muted); margin: 0.25em 0; }
93
- .k-step { border-left: 4px solid var(--pending); padding: 0.1em 0 0.1em 0.8em; }
94
- .k-step-word { font: 600 0.75em var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
95
- .k-label { font-size: 1.02em; margin: 0.1em 0 0; }
99
+ .k-step { border-left: 4px solid var(--pending); padding: 0.1em 0 0.1em 0.8em; margin: 0; font-size: 0.95em; line-height: 1.3; }
100
+ .k-step-word { font: 600 0.72em var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin-right: 0.7em; }
96
101
  .k-step-waiting { border-left-color: var(--foreign); }
97
102
  .k-step-failed, .k-step-stalled { border-left-color: var(--degraded); }
98
103
  .k-step-applied { border-left-color: var(--managed); }
99
104
  .k-step-reset { border-left-color: var(--neutral); }
100
- .k-right { display: grid; gap: 0.6em; align-content: start; }
105
+ .k-right { display: grid; gap: 0.45em; align-content: start; }
106
+ .k-where { display: flex; flex-wrap: wrap; gap: 0.35em 1em; align-items: center; }
101
107
  .k-strip { list-style: none; display: flex; flex-wrap: wrap; gap: 0.35em; padding: 0; margin: 0; }
102
108
  .k-stop { font: 0.82em var(--font-mono); padding: 0.25em 0.7em; border-radius: 999px; border: 1px solid var(--rule); color: var(--muted); }
103
109
  .k-done { color: var(--fg); border-color: var(--managed); }
@@ -109,7 +115,8 @@
109
115
  .k-when { color: var(--foreign); font-family: var(--font-mono); }
110
116
  .k-approve { border-left: 4px solid var(--foreign); padding-left: 0.8em; }
111
117
  .k-approve p { margin: 0.15em 0; }
112
- .k-approve-head, .k-approve-then { color: var(--muted); font-size: 0.92em; }
118
+ .k-approve-gloss { color: var(--fg); font-size: 0.92em; }
119
+ .k-approve-then { color: var(--muted); font-size: 0.92em; }
113
120
  .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; }
114
121
  .k-inline { font-family: var(--font-mono); color: var(--fg); background: var(--well); padding: 0.05em 0.35em; border-radius: 4px; user-select: none; }
115
122
  .k-note { color: var(--muted); margin: 0; font-size: 0.92em; }
@@ -129,7 +136,6 @@
129
136
  #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; }
130
137
  .k-legend { color: var(--muted); flex-basis: 100%; }
131
138
  .k-fact { color: var(--fg); }
132
- .k-source { color: var(--muted); margin-left: auto; }
133
139
  @media (max-width: 900px) { #bar { grid-template-columns: 1fr; } #stage { grid-template-columns: 1fr; } }
134
140
  </style>
135
141
  </head>
package/web/kiosk.js CHANGED
@@ -51,12 +51,38 @@ export const STEP_WORDS = {
51
51
  stalled: "stalled",
52
52
  };
53
53
 
54
- /** "Scenario 3 of 6 · B on waves", or the driver's title alone for an id behold doesn't know. */
54
+ const counted = (n) => Number.isInteger(n) && n > 0;
55
+
56
+ /** demo.json's `name`, the scenario's short label in plain words (terragucci#1050); null without one. */
57
+ export function scenarioName(demo) {
58
+ const n = demo && demo.name;
59
+ return typeof n === "string" && n.trim() ? n.trim() : null;
60
+ }
61
+
62
+ /**
63
+ * The kicker and the title over the card. behold#571: "Scenario 2 of 5" when
64
+ * demo.json counts the loop (`index` of `scenarios`), "Scenario 2" with the
65
+ * index alone, with the title in plain words. Without an index, today's
66
+ * kicker: "scenario 3 · B on waves" from SCENARIOS, demo.json's `name` in
67
+ * place of SCENARIOS' label when it has one; the driver's title alone for an
68
+ * id behold doesn't know.
69
+ */
55
70
  export function scenarioHead(demo) {
56
71
  const known = Object.prototype.hasOwnProperty.call(SCENARIOS, demo.scenario) ? SCENARIOS[demo.scenario] : null;
57
- if (!known) return { kicker: `scenario ${demo.scenario}`, title: demo.title };
58
- if (known.n === null) return { kicker: demo.title || "The base", title: demo.title || "The base" };
59
- return { kicker: `scenario ${known.n} · ${known.name}`, title: demo.title };
72
+ const name = scenarioName(demo);
73
+ if (counted(demo.index)) {
74
+ const of = counted(demo.scenarios) && demo.index <= demo.scenarios ? ` of ${demo.scenarios}` : "";
75
+ return { kicker: `Scenario ${demo.index}${of}`, title: demo.title || name || (known ? known.name : demo.scenario) };
76
+ }
77
+ if (!known) return { kicker: name ? `scenario · ${name}` : `scenario ${demo.scenario}`, title: demo.title };
78
+ if (known.n === null) return { kicker: demo.title || name || "The base", title: demo.title || name || "The base" };
79
+ return { kicker: `scenario ${known.n} · ${name || known.name}`, title: demo.title };
80
+ }
81
+
82
+ /** behold#571: demo.json's `intro`, one paragraph on the problem the demo solves; null without one. */
83
+ export function introText(demo) {
84
+ const t = demo && demo.intro;
85
+ return typeof t === "string" && t.trim() ? t.trim() : null;
60
86
  }
61
87
 
62
88
  /** The card's rows under the title, in order, with the labels the kiosk draws (behold#571). */
@@ -226,6 +252,16 @@ export function pendingApproval(demo, progress) {
226
252
  };
227
253
  }
228
254
 
255
+ /**
256
+ * behold#571: the approval in plain words, before the command, for a viewer
257
+ * who has never approved a pipeline: who the driver is, what it signs. The
258
+ * plan's digest is on the wave's box in the view.
259
+ */
260
+ export function approveGloss(appr) {
261
+ const roots = appr.roots.length ? ` (${appr.roots.join(", ")})` : "";
262
+ return `The driver stands in for a person: it approves wave ${appr.wave}${roots} with its own signing key.`;
263
+ }
264
+
229
265
  /**
230
266
  * #569: the roots the step is about, for the framed view to bring forward:
231
267
  * the pull request's roots while it is planned; from the merge on, the wave
@@ -435,27 +471,31 @@ export function renderKiosk(bar, s, now = new Date(), foot = null) {
435
471
  const demo = s.demo.demo;
436
472
  const head = scenarioHead(demo);
437
473
 
474
+ const intro = introText(demo);
475
+ if (intro) bar.appendChild(el("p", "k-intro", intro));
476
+
477
+ // behold#571: the kicker on the title's line, so the bar has room for the intro.
438
478
  const left = el("div", "k-left");
439
- const kick = el("div", "k-kicker");
440
- kick.append(el("span", null, head.kicker), el("span", "k-cycle", cycleWords(demo, now)));
441
- left.appendChild(kick);
442
- left.appendChild(el("h1", "k-title", head.title));
479
+ const title = el("h1", "k-title");
480
+ title.append(el("span", "k-kicker", head.kicker), el("span", "k-title-text", head.title));
481
+ left.appendChild(title);
443
482
  const rows = aboutRows(demo);
444
483
  if (rows.length) {
445
484
  const card = el("dl", "k-about");
446
485
  for (const r of rows) card.append(el("dt", "k-about-label", r.label), el("dd", "k-about-text", r.text));
447
486
  left.appendChild(card);
448
487
  }
449
- const said = el("div", `k-step k-step-${demo.step}`);
450
- said.appendChild(el("span", "k-step-word", STEP_WORDS[demo.step] || demo.step));
451
- said.appendChild(el("p", "k-label", demo.label));
488
+ const said = el("p", `k-step k-step-${demo.step}`);
489
+ said.append(el("span", "k-step-word", STEP_WORDS[demo.step] || demo.step), el("span", "k-label", demo.label));
452
490
  left.appendChild(said);
453
491
  bar.appendChild(left);
454
492
 
455
493
  const right = el("div", "k-right");
456
494
  const strip = el("ol", "k-strip");
457
495
  for (const stop of stripStops(demo.step)) strip.appendChild(el("li", `k-stop k-${stop.state}`, stop.words));
458
- right.appendChild(strip);
496
+ const where = el("div", "k-where");
497
+ where.append(strip, el("span", "k-cycle", cycleWords(demo, now)));
498
+ right.appendChild(where);
459
499
 
460
500
  const nx = nextWords(demo, now);
461
501
  const next = el("div", "k-next");
@@ -467,13 +507,7 @@ export function renderKiosk(bar, s, now = new Date(), foot = null) {
467
507
  const appr = pendingApproval(demo, s.progress);
468
508
  if (appr) {
469
509
  const a = el("div", "k-approve");
470
- a.appendChild(
471
- el(
472
- "p",
473
- "k-approve-head",
474
- `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)}` : ""}:`,
475
- ),
476
- );
510
+ a.appendChild(el("p", "k-approve-gloss", approveGloss(appr)));
477
511
  a.appendChild(el("code", "k-command", appr.approve));
478
512
  const then = el("p", "k-approve-then");
479
513
  then.append(`then comments `, el("code", "k-inline", appr.comment), ` on ${appr.on}, and the waves run again.`);
@@ -488,18 +522,11 @@ export function renderKiosk(bar, s, now = new Date(), foot = null) {
488
522
 
489
523
  if (foot) {
490
524
  foot.appendChild(el("span", "k-legend", legendWords(s.view || "resources")));
525
+ // behold#571: no line on where the narration was read from: a viewer new to the demo reads the facts.
491
526
  for (const f of factLines(demo, s.answer)) foot.appendChild(el("span", "k-fact", f));
492
- foot.appendChild(
493
- 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`),
494
- );
495
527
  }
496
528
  }
497
529
 
498
- const clockOf = (iso) => {
499
- const d = new Date(iso);
500
- return Number.isNaN(d.getTime()) ? iso : `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}:${String(d.getUTCSeconds()).padStart(2, "0")} UTC`;
501
- };
502
-
503
530
  /** How often the kiosk asks for demo.json. The server shares one read between kiosks. */
504
531
  export const DEMO_POLL_MS = 3_000;
505
532
 
@@ -539,7 +566,10 @@ export function startKiosk({ bar, frame, foot, side, main, views }, { fetchJson
539
566
  tellFrame(false);
540
567
  };
541
568
  // #569: the pipeline's runs beside the view, CI-page style.
542
- if (side && main) startRuns({ side, main }, { fetchJson, now, scenarios: SCENARIOS });
569
+ // behold#571: the titles and names demo.json has carried, by scenario, for the runs' subtitles.
570
+ const titles = {};
571
+ const names = {};
572
+ if (side && main) startRuns({ side, main }, { fetchJson, now, scenarios: SCENARIOS, titles: () => titles, names: () => names });
543
573
  // #571: whether the repo offers the components zoom (a terragucci.components.json), read before the frame opens so it opens on the step's view.
544
574
  const openFrame = () => {
545
575
  if (!frame) return;
@@ -577,6 +607,8 @@ export function startKiosk({ bar, frame, foot, side, main, views }, { fetchJson
577
607
  const demo = async () => {
578
608
  s.demo = await fetchJson("/api/terragucci/demo");
579
609
  const d = s.demo && s.demo.demo;
610
+ if (d && d.scenario && typeof d.title === "string" && d.title) titles[d.scenario] = d.title;
611
+ if (d && d.scenario && scenarioName(d)) names[d.scenario] = scenarioName(d);
580
612
  const key = d ? `${d.cycle}|${d.step}|${d.commit}|${d.pull}` : "";
581
613
  if (key !== lastKey) {
582
614
  lastKey = key;
package/web/kiosk.test.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { STEP_VIEW, STEP_WORDS, aboutRows, focusRoots, frameSrc, factLines, kioskView, legendWords, matchingPull, pendingApproval, renderKiosk, renderViewToggle, runPanel, runPin, stepView, viewZoom } from "./kiosk.js";
2
+ import { STEP_VIEW, STEP_WORDS, aboutRows, approveGloss, focusRoots, introText, scenarioHead, frameSrc, factLines, kioskView, legendWords, matchingPull, pendingApproval, renderKiosk, renderViewToggle, runPanel, runPin, stepView, viewZoom } from "./kiosk.js";
3
3
  import { PICK_HOLD_MS } from "./kiosk-runs.js";
4
4
 
5
5
  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 });
@@ -220,3 +220,82 @@ describe("which view the frame shows (#571)", () => {
220
220
  }
221
221
  });
222
222
  });
223
+
224
+ describe("the kiosk for a viewer new to the demo (behold#571)", () => {
225
+ const intro = "One change can touch many Terraform states; this demo shows how terragucci applies them in order.";
226
+ const fakeDocument = () => {
227
+ const make = (tag) => {
228
+ const n = { tagName: tag, className: "", textContent: "", children: [] };
229
+ n.appendChild = (c) => (n.children.push(c), c);
230
+ n.append = (...cs) => cs.forEach((c) => n.children.push(typeof c === "string" ? { tagName: "#text", textContent: c, children: [] } : c));
231
+ n.replaceChildren = () => (n.children = []);
232
+ return n;
233
+ };
234
+ return { createElement: make };
235
+ };
236
+ const find = (n, cls) => (n.className.split(" ").includes(cls) ? [n] : []).concat(...(n.children || []).map((c) => (c.className !== undefined ? find(c, cls) : [])));
237
+ const draw = (d) => {
238
+ const was = globalThis.document;
239
+ globalThis.document = fakeDocument();
240
+ try {
241
+ const bar = document.createElement("header");
242
+ const foot = document.createElement("footer");
243
+ renderKiosk(bar, { demo: { source: "s3://demo-reports/demo", read: "2026-10-15T03:00:00Z", demo: d }, answer: null, progress: null }, new Date("2026-10-15T03:01:00Z"), foot);
244
+ return { bar, foot };
245
+ } finally {
246
+ globalThis.document = was;
247
+ }
248
+ };
249
+
250
+ it("shows the intro first in the bar, and none without one", () => {
251
+ const { bar } = draw(demo("planned", { intro }));
252
+ expect(bar.children[0].className).toBe("k-intro");
253
+ expect(bar.children[0].textContent).toBe(intro);
254
+ expect(find(draw(demo("planned")).bar, "k-intro")).toEqual([]);
255
+ expect(find(draw(demo("planned", { intro: null })).bar, "k-intro")).toEqual([]);
256
+ expect(introText(demo("planned", { intro: " " }))).toBe(null);
257
+ });
258
+
259
+ it("says which scenario of how many, with the title and no short label", () => {
260
+ expect(scenarioHead(demo("planned", { index: 2, scenarios: 5, title: "The same change, read once it is built" }))).toEqual({ kicker: "Scenario 2 of 5", title: "The same change, read once it is built" });
261
+ const { bar } = draw(demo("planned", { index: 2, scenarios: 5, title: "The same change, read once it is built" }));
262
+ expect(find(bar, "k-kicker")[0].textContent).toBe("Scenario 2 of 5");
263
+ expect(find(bar, "k-title-text")[0].textContent).toBe("The same change, read once it is built");
264
+ });
265
+
266
+ it("leaves the count out when scenarios is missing, 0 or less than the index", () => {
267
+ expect(scenarioHead(demo("planned", { index: 2, title: "t" })).kicker).toBe("Scenario 2");
268
+ expect(scenarioHead(demo("planned", { index: 2, scenarios: 0, title: "t" })).kicker).toBe("Scenario 2");
269
+ expect(scenarioHead(demo("planned", { index: 6, scenarios: 5, title: "t" })).kicker).toBe("Scenario 6");
270
+ });
271
+
272
+ it("falls back to the short label without an index, demo.json's name before the table's", () => {
273
+ expect(scenarioHead(demo("planned", { title: "t" }))).toEqual({ kicker: "scenario 2 · A, read through .output", title: "t" });
274
+ expect(scenarioHead(demo("planned", { index: 0, scenarios: 5 })).kicker).toBe("scenario 2 · A, read through .output");
275
+ expect(scenarioHead(demo("planned", { index: null, scenarios: 0, name: "the same change, read late" })).kicker).toBe("scenario 2 · the same change, read late");
276
+ expect(scenarioHead(demo("reset", { scenario: "base", title: "The base", index: null, scenarios: 0, name: null }))).toEqual({ kicker: "The base", title: "The base" });
277
+ expect(scenarioHead(demo("planned", { scenario: "new-one", title: "A new one" }))).toEqual({ kicker: "scenario new-one", title: "A new one" });
278
+ });
279
+
280
+ it("has no line on where the narration was read from", () => {
281
+ const { foot } = draw(demo("planned", { intro }));
282
+ expect(JSON.stringify(foot)).not.toMatch(/narration read|s3:\/\/|nothing here writes/);
283
+ });
284
+
285
+ it("says who the driver is before the command it runs", () => {
286
+ expect(approveGloss({ wave: 2, roots: ["app"], digest: "jcs1-sha256:0accc105ba06" })).toBe("The driver stands in for a person: it approves wave 2 (app) with its own signing key.");
287
+ expect(approveGloss({ wave: 3, roots: [], digest: null })).toBe("The driver stands in for a person: it approves wave 3 with its own signing key.");
288
+ const p = progress([{ number: 2, state: "waiting", roots: ["app"], reads: [], digest: "jcs1-sha256:0accc105ba06" }]);
289
+ const was = globalThis.document;
290
+ globalThis.document = fakeDocument();
291
+ try {
292
+ const bar = document.createElement("header");
293
+ renderKiosk(bar, { demo: { source: "fx", read: "2026-10-15T03:00:00Z", demo: demo("waiting", { next: { action: "approve wave-2", at: null } }) }, answer: null, progress: p }, new Date("2026-10-15T03:01:00Z"));
294
+ const card = find(bar, "k-approve")[0];
295
+ expect(card.children.map((c) => c.className)).toEqual(["k-approve-gloss", "k-command", "k-approve-then"]);
296
+ expect(card.children[1].textContent).toBe("chant approve tf-apply wave-2 --actor <driver> --sign <driver key>");
297
+ } finally {
298
+ globalThis.document = was;
299
+ }
300
+ });
301
+ });