@intentius/behold 0.29.5 → 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.5",
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
@@ -227,12 +227,88 @@ export function agoWords(at, now = new Date()) {
227
227
  return `${Math.floor(s / 86400)} d ago`;
228
228
  }
229
229
 
230
- /** "a-output" as the kiosk names scenarios, with the cycle: "A, read through .output · cycle 90". */
231
- export function placeWords(run, scenarios) {
232
- 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;
233
267
  return [sc, run.cycle !== null && run.cycle !== undefined ? `cycle ${run.cycle}` : null].filter(Boolean).join(" · ");
234
268
  }
235
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
+
236
312
  // ---------------------------------------------------------------------------
237
313
  // Drawing.
238
314
 
@@ -277,7 +353,7 @@ export function statusIcon(status) {
277
353
  }
278
354
 
279
355
  /** The sidebar: the live run pinned, then the recent ones. `pick(id)` selects one; nothing here writes. */
280
- 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 } = {}) {
281
357
  host.replaceChildren();
282
358
  const head = el("div", "r-side-head");
283
359
  head.appendChild(el("div", "r-side-title", "Pipeline runs"));
@@ -290,6 +366,7 @@ export function renderSidebar(host, index, selectedId, { now = new Date(), scena
290
366
  // The selected run as its page draws it, when its own document is newer than the index.
291
367
  const runs = sidebarRuns(index.doc.runs).map((r) => (merged && merged.id === r.id ? merged : r));
292
368
  const live = runs[0];
369
+ const named = scenarioTitles(index.doc.runs, titles);
293
370
  const list = el("ol", "r-runs");
294
371
  runs.forEach((r, i) => {
295
372
  if (i === 0) list.appendChild(el("li", "r-group", LIVE.has(live.status) ? "Live" : "Latest"));
@@ -300,7 +377,7 @@ export function renderSidebar(host, index, selectedId, { now = new Date(), scena
300
377
  li.appendChild(statusIcon(r.status));
301
378
  const body = el("div", "r-run-body");
302
379
  body.appendChild(el("div", "r-run-title", triggerWords(r)));
303
- const place = placeWords(r, scenarios);
380
+ const place = placeWords(r, scenarios, named, names);
304
381
  if (place) body.appendChild(el("div", "r-run-place", place));
305
382
  const meta = el("div", "r-run-meta");
306
383
  meta.append(
@@ -328,7 +405,7 @@ export function renderSidebar(host, index, selectedId, { now = new Date(), scena
328
405
  * null for the defaults. Returns what it drew, so the caller can keep a log's
329
406
  * scroll.
330
407
  */
331
- 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 } = {}) {
332
409
  host.replaceChildren();
333
410
  if (!answer || answer.error || answer.absent || !answer.doc) {
334
411
  host.appendChild(el("p", "r-note", answer ? answer.absent || answer.error : "Reading the run…"));
@@ -398,12 +475,25 @@ export function renderRun(host, answer, ui, { now = new Date(), pickJob = () =>
398
475
  if (i === open) {
399
476
  const pre = el("pre", "r-log");
400
477
  if (s.truncated) pre.appendChild(el("div", "r-log-cut", `… ${s.truncated} earlier lines not kept`));
401
- s.log.forEach((l, n) => {
402
- const ln = el("div", "r-log-line");
403
- const shown = logLineWords(job.status, l);
404
- ln.append(el("span", "r-ln", String((s.truncated || 0) + n + 1)), el("span", shown.held ? "r-lt r-wait" : "r-lt", shown.text));
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));
405
495
  pre.appendChild(ln);
406
- });
496
+ }
407
497
  if (!s.log.length) pre.appendChild(el("div", "r-log-cut", s.status === "running" ? "waiting for output…" : "no output"));
408
498
  if (s.status === "running") pre.appendChild(el("div", "r-log-live", "▍"));
409
499
  row.appendChild(pre);
@@ -447,8 +537,8 @@ export const PICK_HOLD_MS = 90_000;
447
537
  * is live), and redraw. The live run is selected until a viewer picks
448
538
  * another, and again {@link PICK_HOLD_MS} after their last pick.
449
539
  */
450
- export function startRuns({ side, main }, { fetchJson, now = () => new Date(), scenarios = {}, onLive = () => {} } = {}) {
451
- 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 } };
452
542
  let follow = true;
453
543
  let lastRunKey = "";
454
544
  // The open log and whether the pane follows its end; a viewer who scrolls up stops it until they come back.
@@ -470,7 +560,7 @@ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), s
470
560
  const ans = s.run && (!s.run.doc || s.run.doc.id === id) ? s.run : null;
471
561
  // One merged read of the selected run for both the sidebar and its page.
472
562
  const merged = ans && ans.doc ? mergeRun(entry, ans.doc) : null;
473
- 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 });
474
564
  // The redraw replaces the scrollers: keep where a viewer left them.
475
565
  const old = main.querySelector(".r-log");
476
566
  const oldSteps = main.querySelector(".r-steps");
@@ -480,12 +570,17 @@ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), s
480
570
  now: now(),
481
571
  entry,
482
572
  pickJob: (name) => {
483
- s.ui = { job: name, open: null };
573
+ s.ui = { job: name, open: null, setup: null };
484
574
  hold();
485
575
  draw();
486
576
  },
487
577
  toggleStep: (job, i) => {
488
- 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 };
489
584
  hold();
490
585
  draw();
491
586
  },
@@ -528,7 +623,7 @@ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), s
528
623
  s.selected = id;
529
624
  follow = false;
530
625
  s.picked = now().getTime();
531
- s.ui = { job: null, open: null };
626
+ s.ui = { job: null, open: null, setup: null };
532
627
  void readRun();
533
628
  };
534
629
  const readIndex = async () => {
@@ -545,7 +640,7 @@ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), s
545
640
  // A run that ended reads once; one still going reads on every tick.
546
641
  const key = `${id}|${entry ? entry.status : ""}|${entry ? entry.ended_at : ""}`;
547
642
  if (key === lastRunKey && entry && !LIVE.has(entry.status) && s.run && s.run.doc && s.run.doc.id === id) return draw();
548
- 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 };
549
644
  lastRunKey = key;
550
645
  s.run = await fetchJson(`/api/terragucci/runs/${id}`);
551
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, logLineWords, 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"));
@@ -131,3 +131,55 @@ describe("the log pane follows its end, like a CI page's log", () => {
131
131
  expect(nearEnd({ scrollTop: 0, clientHeight: 400, scrollHeight: 1000 }, slack)).toBe(false);
132
132
  });
133
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
+ });