@intentius/behold 0.29.5 → 0.29.7

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.js CHANGED
@@ -14,6 +14,7 @@
14
14
  // Nothing here is all-or-nothing, and nothing says so.
15
15
 
16
16
  import { PICK_HOLD_MS, startRuns } from "./kiosk-runs.js";
17
+ import { cssString, hoverCss, hoverFromMessage, hoverLinks, hoverMessage, isLightBg, rootSegments, rootText } from "./kiosk-roots.js";
17
18
  import { STATE_WORDS, changingRoots, costLine, pinsCommit, planFitsPin, pullPin, readEdges, rootsCount, totalsLine, waveRows, blastRows } from "./terragucci-change.js";
18
19
 
19
20
  /** The scenarios terragucci#1014 numbers. An id not here is shown by the title demo.json carries. */
@@ -51,12 +52,38 @@ export const STEP_WORDS = {
51
52
  stalled: "stalled",
52
53
  };
53
54
 
54
- /** "Scenario 3 of 6 · B on waves", or the driver's title alone for an id behold doesn't know. */
55
+ const counted = (n) => Number.isInteger(n) && n > 0;
56
+
57
+ /** demo.json's `name`, the scenario's short label in plain words (terragucci#1050); null without one. */
58
+ export function scenarioName(demo) {
59
+ const n = demo && demo.name;
60
+ return typeof n === "string" && n.trim() ? n.trim() : null;
61
+ }
62
+
63
+ /**
64
+ * The kicker and the title over the card. behold#571: "Scenario 2 of 5" when
65
+ * demo.json counts the loop (`index` of `scenarios`), "Scenario 2" with the
66
+ * index alone, with the title in plain words. Without an index, today's
67
+ * kicker: "scenario 3 · B on waves" from SCENARIOS, demo.json's `name` in
68
+ * place of SCENARIOS' label when it has one; the driver's title alone for an
69
+ * id behold doesn't know.
70
+ */
55
71
  export function scenarioHead(demo) {
56
72
  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 };
73
+ const name = scenarioName(demo);
74
+ if (counted(demo.index)) {
75
+ const of = counted(demo.scenarios) && demo.index <= demo.scenarios ? ` of ${demo.scenarios}` : "";
76
+ return { kicker: `Scenario ${demo.index}${of}`, title: demo.title || name || (known ? known.name : demo.scenario) };
77
+ }
78
+ if (!known) return { kicker: name ? `scenario · ${name}` : `scenario ${demo.scenario}`, title: demo.title };
79
+ if (known.n === null) return { kicker: demo.title || name || "The base", title: demo.title || name || "The base" };
80
+ return { kicker: `scenario ${known.n} · ${name || known.name}`, title: demo.title };
81
+ }
82
+
83
+ /** behold#571: demo.json's `intro`, one paragraph on the problem the demo solves; null without one. */
84
+ export function introText(demo) {
85
+ const t = demo && demo.intro;
86
+ return typeof t === "string" && t.trim() ? t.trim() : null;
60
87
  }
61
88
 
62
89
  /** The card's rows under the title, in order, with the labels the kiosk draws (behold#571). */
@@ -192,14 +219,17 @@ export function unreviewedLines(answer) {
192
219
 
193
220
  /**
194
221
  * The approval the driver gives at a waiting wave, as terragucci's
195
- * stack/demo.sh gives it: `approve_wave` runs chant's approve, sealed with
196
- * the driver's ssh key, then `resume_pull` posts `/terragucci apply` on the
197
- * merged pull request. The actor and the key are placeholders: the kiosk
198
- * never names a path or a secret.
222
+ * stack/demo.sh gives it: `approve_wave` runs chant's approve as the
223
+ * reviewer (terragucci#1052: the driver plays two people, demo-author who
224
+ * opens, merges and comments, and demo-reviewer who approves, since an
225
+ * approval only counts from someone who had no hand in the change), sealed
226
+ * with the reviewer's own signing key; then `resume_pull` posts
227
+ * `/terragucci apply` on the merged pull request. The actor and the key are
228
+ * placeholders: the kiosk never names a path or a secret.
199
229
  */
200
230
  export function driverApproveLines(wave, pull) {
201
231
  return {
202
- approve: `chant approve tf-apply wave-${wave} --actor <driver> --sign <driver key>`,
232
+ approve: `chant approve tf-apply wave-${wave} --actor <reviewer> --sign <reviewer key>`,
203
233
  comment: "/terragucci apply",
204
234
  on: pull !== null && pull !== undefined ? `pull request #${pull}` : "the merged pull request",
205
235
  };
@@ -226,6 +256,17 @@ export function pendingApproval(demo, progress) {
226
256
  };
227
257
  }
228
258
 
259
+ /**
260
+ * behold#571: the approval in plain words, before the command, for a viewer
261
+ * who has never approved a pipeline: who the driver plays, what it signs
262
+ * with (terragucci#1052: a reviewer apart from the change's author). The
263
+ * plan's digest is on the wave's box in the view.
264
+ */
265
+ export function approveGloss(appr) {
266
+ const roots = appr.roots.length ? ` (${appr.roots.join(", ")})` : "";
267
+ return `The driver plays a reviewer who had no hand in the change, and approves wave ${appr.wave}${roots} with that reviewer's own signing key.`;
268
+ }
269
+
229
270
  /**
230
271
  * #569: the roots the step is about, for the framed view to bring forward:
231
272
  * the pull request's roots while it is planned; from the merge on, the wave
@@ -421,6 +462,15 @@ const el = (tag, cls, text) => {
421
462
  return n;
422
463
  };
423
464
 
465
+ /** behold#571: an element whose text puts a dot in its root's colour by each whole-word root name. */
466
+ const elRoots = (tag, cls, text, s) => {
467
+ const roots = (s && (s.rootNames || s.roots)) || [];
468
+ if (text === undefined || text === null || !rootSegments(String(text), roots).some((x) => x.root)) return el(tag, cls, text);
469
+ const n = el(tag, cls);
470
+ n.appendChild(rootText(document, String(text), roots, { light: !!(s && s.light) }));
471
+ return n;
472
+ };
473
+
424
474
  /** Draw the bar and the foot from what the kiosk holds. Pure over its arguments, but for the DOM. */
425
475
  export function renderKiosk(bar, s, now = new Date(), foot = null) {
426
476
  bar.replaceChildren();
@@ -435,45 +485,43 @@ export function renderKiosk(bar, s, now = new Date(), foot = null) {
435
485
  const demo = s.demo.demo;
436
486
  const head = scenarioHead(demo);
437
487
 
488
+ const intro = introText(demo);
489
+ if (intro) bar.appendChild(el("p", "k-intro", intro));
490
+
491
+ // behold#571: the kicker on the title's line, so the bar has room for the intro.
438
492
  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));
493
+ const title = el("h1", "k-title");
494
+ title.append(el("span", "k-kicker", head.kicker), el("span", "k-title-text", head.title));
495
+ left.appendChild(title);
443
496
  const rows = aboutRows(demo);
444
497
  if (rows.length) {
445
498
  const card = el("dl", "k-about");
446
- for (const r of rows) card.append(el("dt", "k-about-label", r.label), el("dd", "k-about-text", r.text));
499
+ for (const r of rows) card.append(el("dt", "k-about-label", r.label), elRoots("dd", "k-about-text", r.text, s));
447
500
  left.appendChild(card);
448
501
  }
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));
502
+ const said = el("p", `k-step k-step-${demo.step}`);
503
+ said.append(el("span", "k-step-word", STEP_WORDS[demo.step] || demo.step), elRoots("span", "k-label", demo.label, s));
452
504
  left.appendChild(said);
453
505
  bar.appendChild(left);
454
506
 
455
507
  const right = el("div", "k-right");
456
508
  const strip = el("ol", "k-strip");
457
509
  for (const stop of stripStops(demo.step)) strip.appendChild(el("li", `k-stop k-${stop.state}`, stop.words));
458
- right.appendChild(strip);
510
+ const where = el("div", "k-where");
511
+ where.append(strip, el("span", "k-cycle", cycleWords(demo, now)));
512
+ right.appendChild(where);
459
513
 
460
514
  const nx = nextWords(demo, now);
461
515
  const next = el("div", "k-next");
462
516
  next.appendChild(el("span", "k-next-word", "next, by the driver"));
463
- next.appendChild(el("span", "k-action", nx.action));
517
+ next.appendChild(elRoots("span", "k-action", nx.action, s));
464
518
  next.appendChild(el("span", "k-when", nx.when));
465
519
  right.appendChild(next);
466
520
 
467
521
  const appr = pendingApproval(demo, s.progress);
468
522
  if (appr) {
469
523
  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
- );
524
+ a.appendChild(elRoots("p", "k-approve-gloss", approveGloss(appr), s));
477
525
  a.appendChild(el("code", "k-command", appr.approve));
478
526
  const then = el("p", "k-approve-then");
479
527
  then.append(`then comments `, el("code", "k-inline", appr.comment), ` on ${appr.on}, and the waves run again.`);
@@ -481,24 +529,21 @@ export function renderKiosk(bar, s, now = new Date(), foot = null) {
481
529
  right.appendChild(a);
482
530
  } else {
483
531
  const panel = runPanel(demo, s.answer, s.progress);
484
- if (panel.note) right.appendChild(el("p", "k-note", panel.note));
485
- else if (panel.stage === "pull-request") for (const u of unreviewedLines(s.answer)) right.appendChild(el("p", "k-note k-unknown", u));
532
+ if (panel.note) right.appendChild(elRoots("p", "k-note", panel.note, s));
533
+ else if (panel.stage === "pull-request") for (const u of unreviewedLines(s.answer)) right.appendChild(elRoots("p", "k-note k-unknown", u, s));
486
534
  }
487
535
  bar.appendChild(right);
488
536
 
489
537
  if (foot) {
490
538
  foot.appendChild(el("span", "k-legend", legendWords(s.view || "resources")));
491
- 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
- );
539
+ // behold#571: no line on where the narration was read from: a viewer new to the demo reads the facts.
540
+ for (const f of factLines(demo, s.answer)) foot.appendChild(elRoots("span", "k-fact", f, s));
495
541
  }
496
542
  }
497
543
 
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
- };
544
+ /** behold#571: how a hovered root's name and wave's job row light. A background and a shadow: nothing moves. */
545
+ export const HOVER_ROOT_RULE = "background: color-mix(in srgb, var(--focus) 32%, transparent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--focus) 32%, transparent); border-radius: 3px;";
546
+ export const HOVER_JOB_RULE = "background: color-mix(in srgb, var(--focus) 24%, transparent); box-shadow: inset 4px 0 0 var(--focus);";
502
547
 
503
548
  /** How often the kiosk asks for demo.json. The server shares one read between kiosks. */
504
549
  export const DEMO_POLL_MS = 3_000;
@@ -511,7 +556,7 @@ export const DEMO_POLL_MS = 3_000;
511
556
  * with a `behold:view` message; the frame takes them only from this origin.
512
557
  */
513
558
  export function startKiosk({ bar, frame, foot, side, main, views }, { fetchJson = defaultFetch, now = () => new Date(), origin = location.origin } = {}) {
514
- const s = { demo: null, answer: null, progress: null, components: false, pick: null, view: "resources" };
559
+ const s = { demo: null, answer: null, progress: null, components: false, pick: null, view: "resources", roots: [], waves: [], light: false, hover: null, frameHover: null };
515
560
  let lastKey = "";
516
561
  let lastFocus;
517
562
  const shownView = () => kioskView(s.demo && s.demo.demo, { components: s.components, pick: s.pick, now: now().getTime() });
@@ -531,7 +576,59 @@ export function startKiosk({ bar, frame, foot, side, main, views }, { fetchJson
531
576
  s.pick = { view, at: now().getTime() };
532
577
  draw();
533
578
  };
579
+ // behold#571: the roots' colours and the hover links between the panes and the frame.
580
+ const rootList = () => (s.roots.length ? s.roots : s.answer && Array.isArray(s.answer.roots) ? s.answer.roots.map((r) => r.path) : []);
581
+ const glow = () => {
582
+ let style = document.getElementById("k-hover-style");
583
+ if (!style) {
584
+ style = document.createElement("style");
585
+ style.id = "k-hover-style";
586
+ document.head.appendChild(style);
587
+ }
588
+ const a = hoverLinks(s.hover, s.waves);
589
+ const b = hoverLinks(s.frameHover, s.waves);
590
+ const links = { roots: [...new Set([...a.roots, ...b.roots])], waves: [...new Set([...a.waves, ...b.waves])] };
591
+ style.textContent = [
592
+ hoverCss({ roots: links.roots, waves: [] }, { root: (r) => `.k-root[data-k-root=${cssString(r)}]` }, HOVER_ROOT_RULE),
593
+ hoverCss({ roots: [], waves: links.waves }, { wave: (w) => `.r-job[data-k-wave="${w}"]` }, HOVER_JOB_RULE),
594
+ ].join("\n");
595
+ };
596
+ const tellHover = (h) => {
597
+ if (frame && frame.contentWindow) frame.contentWindow.postMessage(hoverMessage(h), origin);
598
+ };
599
+ const hoverAt = (target) => {
600
+ const t = target && target.closest ? target.closest("[data-k-root],[data-k-wave]") : null;
601
+ if (!t) return null;
602
+ if (t.dataset.kRoot) return { root: t.dataset.kRoot };
603
+ const w = Number(t.dataset.kWave);
604
+ return Number.isInteger(w) ? { wave: w } : null;
605
+ };
606
+ const setHover = (h) => {
607
+ if ((h && h.root) === (s.hover && s.hover.root) && (h && h.wave) === (s.hover && s.hover.wave)) return;
608
+ s.hover = h;
609
+ glow();
610
+ tellHover(h);
611
+ };
612
+ if (typeof document !== "undefined" && document.addEventListener) {
613
+ document.addEventListener("pointerover", (e) => setHover(hoverAt(e.target)));
614
+ document.documentElement.addEventListener("pointerleave", () => setHover(null));
615
+ // A click or Escape anywhere here is a click elsewhere for the frame's pinned popover.
616
+ const unpin = () => frame && frame.contentWindow && frame.contentWindow.postMessage({ type: "behold:unpin" }, origin);
617
+ document.addEventListener("pointerdown", unpin);
618
+ document.addEventListener("keydown", (e) => {
619
+ if (e.key === "Escape") unpin();
620
+ });
621
+ window.addEventListener("message", (e) => {
622
+ if (e.origin !== origin || !frame || e.source !== frame.contentWindow) return;
623
+ const h = hoverFromMessage(e.data);
624
+ if (h === undefined) return;
625
+ s.frameHover = h;
626
+ glow();
627
+ });
628
+ }
534
629
  const draw = () => {
630
+ s.light = typeof document !== "undefined" && document.documentElement ? isLightBg(getComputedStyle(document.documentElement).getPropertyValue("--bg")) : false;
631
+ s.rootNames = rootList();
535
632
  const shown = shownView();
536
633
  s.view = shown.view;
537
634
  renderKiosk(bar, s, now(), foot);
@@ -539,7 +636,10 @@ export function startKiosk({ bar, frame, foot, side, main, views }, { fetchJson
539
636
  tellFrame(false);
540
637
  };
541
638
  // #569: the pipeline's runs beside the view, CI-page style.
542
- if (side && main) startRuns({ side, main }, { fetchJson, now, scenarios: SCENARIOS });
639
+ // behold#571: the titles and names demo.json has carried, by scenario, for the runs' subtitles.
640
+ const titles = {};
641
+ const names = {};
642
+ if (side && main) startRuns({ side, main }, { fetchJson, now, scenarios: SCENARIOS, titles: () => titles, names: () => names, roots: rootList, waves: () => s.waves, light: () => s.light });
543
643
  // #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
644
  const openFrame = () => {
545
645
  if (!frame) return;
@@ -551,6 +651,11 @@ export function startKiosk({ bar, frame, foot, side, main, views }, { fetchJson
551
651
  };
552
652
  void fetchJson("/api/project").then((p) => {
553
653
  s.components = !!(p && !p.error && p.terragucciComponents);
654
+ // behold#571: the declared roots (their colours' order) and the wave jobs that apply them.
655
+ if (p && p.terragucciRoots) {
656
+ s.roots = Array.isArray(p.terragucciRoots.roots) ? p.terragucciRoots.roots : [];
657
+ s.waves = Array.isArray(p.terragucciRoots.waves) ? p.terragucciRoots.waves : [];
658
+ }
554
659
  openFrame();
555
660
  draw();
556
661
  });
@@ -577,6 +682,8 @@ export function startKiosk({ bar, frame, foot, side, main, views }, { fetchJson
577
682
  const demo = async () => {
578
683
  s.demo = await fetchJson("/api/terragucci/demo");
579
684
  const d = s.demo && s.demo.demo;
685
+ if (d && d.scenario && typeof d.title === "string" && d.title) titles[d.scenario] = d.title;
686
+ if (d && d.scenario && scenarioName(d)) names[d.scenario] = scenarioName(d);
580
687
  const key = d ? `${d.cycle}|${d.step}|${d.commit}|${d.pull}` : "";
581
688
  if (key !== lastKey) {
582
689
  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 plays a reviewer who had no hand in the change, and approves wave 2 (app) with that reviewer's own signing key.");
287
+ expect(approveGloss({ wave: 3, roots: [], digest: null })).toBe("The driver plays a reviewer who had no hand in the change, and approves wave 3 with that reviewer's 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 <reviewer> --sign <reviewer key>");
297
+ } finally {
298
+ globalThis.document = was;
299
+ }
300
+ });
301
+ });