@intentius/behold 0.29.6 → 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.
@@ -0,0 +1,95 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { ROOT_HUES, cssString, hoverCss, hoverFromMessage, hoverLinks, hoverMessage, isLightBg, rootColour, rootOrder, rootSegments, rootsOfWave, waveOfJob } from "./kiosk-roots.js";
3
+
4
+ // behold#571: the demo repo's roots and the wave jobs of its terragucci.components.json.
5
+ const ROOTS = ["network", "other", "app", "edge"];
6
+ const WAVES = [
7
+ { job: "apply-wave-1", wave: 1, roots: ["network", "other"] },
8
+ { job: "apply-wave-2", wave: 2, roots: ["app"] },
9
+ { job: "apply-wave-3", wave: 3, roots: ["edge"] },
10
+ ];
11
+ const names = (text) => rootSegments(text, ROOTS).filter((s) => s.root).map((s) => s.root);
12
+
13
+ describe("a colour per root (behold#571)", () => {
14
+ it("follows the sorted root list, so it is the same on every cycle and in any order", () => {
15
+ expect(rootOrder(ROOTS)).toEqual(["app", "edge", "network", "other"]);
16
+ const a = Object.fromEntries(ROOTS.map((r) => [r, rootColour(r, ROOTS)]));
17
+ const b = Object.fromEntries(ROOTS.map((r) => [r, rootColour(r, [...ROOTS].reverse())]));
18
+ expect(a).toEqual(b);
19
+ expect(a.app).toBe(ROOT_HUES.dark[0]);
20
+ expect(new Set(Object.values(a)).size).toBe(4);
21
+ expect(rootColour("app", ROOTS, { light: true })).toBe(ROOT_HUES.light[0]);
22
+ expect(rootColour("nope", ROOTS)).toBeNull();
23
+ });
24
+
25
+ it("uses no state colour: not the green, amber, red, blue or teal of the theme", () => {
26
+ const state = ["#a6e3a1", "#f9e2af", "#f38ba8", "#89b4fa", "#94e2d5", "#6d7187"];
27
+ for (const hue of [...ROOT_HUES.dark, ...ROOT_HUES.light]) expect(state).not.toContain(hue.toLowerCase());
28
+ expect(new Set(ROOT_HUES.dark).size).toBe(ROOT_HUES.dark.length);
29
+ expect(new Set(ROOT_HUES.light).size).toBe(ROOT_HUES.light.length);
30
+ });
31
+
32
+ it("tells a light background from a dark one", () => {
33
+ expect(isLightBg("#ffffff")).toBe(true);
34
+ expect(isLightBg(" #1e1e2e")).toBe(false);
35
+ expect(isLightBg("")).toBe(false);
36
+ });
37
+ });
38
+
39
+ describe("root names in a line (behold#571)", () => {
40
+ it("finds whole-word names, possessives and names in brackets, and keeps the line's text", () => {
41
+ const line = "app reads subnet_id from network, known only after network applies: the review does not show the value app gets.";
42
+ expect(names(line)).toEqual(["app", "network", "network", "app"]);
43
+ expect(rootSegments(line, ROOTS).map((s) => s.text).join("")).toBe(line);
44
+ expect(names("approves wave 2 (app) with network's key")).toEqual(["app", "network"]);
45
+ expect(names("app: Plan: 0 to add, 1 to change, 0 to destroy.")).toEqual(["app"]);
46
+ });
47
+
48
+ it("leaves names inside addresses, hosts, URLs, paths, hashes and longer words alone", () => {
49
+ expect(names("terraform_data.app_x and data.terraform_remote_state.network")).toEqual([]);
50
+ expect(names("upstream http://app:9080 and app:8070")).toEqual([]);
51
+ expect(names("report copied to demo/forgejo:3000/terragucci-admin/demo/2026/10/ef95/tf-apply-wave-1")).toEqual([]);
52
+ expect(names("set digest jcs1-sha256:0accc105ba06 and commit a1b2c3d4e5")).toEqual([]);
53
+ expect(names("apply-wave-2 appliance networks edge-proxy network.tfstate")).toEqual([]);
54
+ expect(names("key=network")).toEqual([]);
55
+ });
56
+
57
+ it("says nothing without roots", () => {
58
+ expect(rootSegments("network", [])).toEqual([{ text: "network" }]);
59
+ expect(rootSegments("", ROOTS)).toEqual([]);
60
+ });
61
+ });
62
+
63
+ describe("hover links between the panes (behold#571)", () => {
64
+ it("ties a wave's job to its roots, from the declared jobs or the job's name", () => {
65
+ expect(waveOfJob("apply-wave-2", WAVES)).toBe(2);
66
+ expect(waveOfJob("apply-wave-7", [])).toBe(7);
67
+ expect(waveOfJob("check", WAVES)).toBeNull();
68
+ expect(rootsOfWave(1, WAVES)).toEqual(["network", "other"]);
69
+ expect(rootsOfWave(9, WAVES)).toEqual([]);
70
+ });
71
+
72
+ it("lights a root's mentions and its wave, and a wave's job and its roots", () => {
73
+ expect(hoverLinks({ root: "other" }, WAVES)).toEqual({ roots: ["other"], waves: [1] });
74
+ expect(hoverLinks({ wave: 1 }, WAVES)).toEqual({ roots: ["network", "other"], waves: [1] });
75
+ expect(hoverLinks({ root: "lonely" }, WAVES)).toEqual({ roots: ["lonely"], waves: [] });
76
+ expect(hoverLinks(null, WAVES)).toEqual({ roots: [], waves: [] });
77
+ });
78
+
79
+ it("carries a hover between the frame and the kiosk as a message, and takes only that message", () => {
80
+ expect(hoverMessage({ root: "app" })).toEqual({ type: "behold:hover", root: "app", wave: null });
81
+ expect(hoverMessage(null)).toEqual({ type: "behold:hover", root: null, wave: null });
82
+ expect(hoverFromMessage(hoverMessage({ wave: 2 }))).toEqual({ wave: 2 });
83
+ expect(hoverFromMessage(hoverMessage({ root: "app" }))).toEqual({ root: "app" });
84
+ expect(hoverFromMessage(hoverMessage(null))).toBeNull();
85
+ expect(hoverFromMessage({ type: "behold:view" })).toBeUndefined();
86
+ expect(hoverFromMessage("behold:hover")).toBeUndefined();
87
+ });
88
+
89
+ it("writes one rule for what a hover lights, quoting names", () => {
90
+ const css = hoverCss(hoverLinks({ wave: 2 }, WAVES), { root: (r) => `.k-root[data-k-root=${cssString(r)}]`, wave: (w) => `.r-job[data-k-wave="${w}"]` }, "outline: 1px solid");
91
+ expect(css).toBe('.k-root[data-k-root="app"],\n.r-job[data-k-wave="2"] { outline: 1px solid }');
92
+ expect(hoverCss({ roots: [], waves: [] }, { root: () => "x", wave: () => "y" }, "z")).toBe("");
93
+ expect(cssString('a"b')).toBe('"a\\"b"');
94
+ });
95
+ });
package/web/kiosk-runs.js CHANGED
@@ -10,6 +10,8 @@
10
10
  // A job held at a terragucci gate is `waiting`: it waits for an approval, and
11
11
  // is never drawn as a failure. Step names are shown as the driver wrote them.
12
12
 
13
+ import { rootColour, rootSegments, rootText, rootsOfWave, waveOfJob } from "./kiosk-roots.js";
14
+
13
15
  /** The run, job and step statuses, as a viewer reads them. */
14
16
  export const STATUS_WORDS = {
15
17
  queued: "queued",
@@ -405,7 +407,14 @@ export function renderSidebar(host, index, selectedId, { now = new Date(), scena
405
407
  * null for the defaults. Returns what it drew, so the caller can keep a log's
406
408
  * scroll.
407
409
  */
408
- export function renderRun(host, answer, ui, { now = new Date(), pickJob = () => {}, toggleStep = () => {}, toggleSetup = () => {}, entry = null } = {}) {
410
+ export function renderRun(host, answer, ui, { now = new Date(), pickJob = () => {}, toggleStep = () => {}, toggleSetup = () => {}, entry = null, roots = [], waves = [], light = false } = {}) {
411
+ // behold#571: a root's name with its colour's dot, where the line names one.
412
+ const textOf = (cls, text) => {
413
+ if (!roots.length || !rootSegments(text, roots).some((x) => x.root)) return el("span", cls, text);
414
+ const n = el("span", cls);
415
+ n.appendChild(rootText(document, text, roots, { light }));
416
+ return n;
417
+ };
409
418
  host.replaceChildren();
410
419
  if (!answer || answer.error || answer.absent || !answer.doc) {
411
420
  host.appendChild(el("p", "r-note", answer ? answer.absent || answer.error : "Reading the run…"));
@@ -440,6 +449,20 @@ export function renderRun(host, answer, ui, { now = new Date(), pickJob = () =>
440
449
  li.style.setProperty("--depth", String(j.depth));
441
450
  li.appendChild(statusIcon(j.status));
442
451
  li.appendChild(el("span", "r-job-name", j.name));
452
+ // behold#571: a wave's job row names the roots it applies, each with its dot, and lights its wave box on hover.
453
+ const wave = waveOfJob(j.name, waves);
454
+ const applies = wave !== null ? rootsOfWave(wave, waves).filter((r) => rootColour(r, roots, { light })) : [];
455
+ if (wave !== null && waves.length) li.dataset.kWave = String(wave);
456
+ if (applies.length) {
457
+ const box = el("span", "r-job-roots");
458
+ for (const r of applies) {
459
+ const name = el("span", "k-root", r);
460
+ name.dataset.kRoot = r;
461
+ name.style.setProperty("--k-root", rootColour(r, roots, { light }));
462
+ box.appendChild(name);
463
+ }
464
+ li.appendChild(box);
465
+ }
443
466
  if (j.needs && j.needs.length) li.appendChild(el("span", "r-job-needs", `after ${j.needs.join(", ")}`));
444
467
  li.appendChild(el("span", "r-dur", j.status === "waiting" ? "held at the gate" : durationWords(j.started_at, j.ended_at, now) || STATUS_WORDS[j.status] || ""));
445
468
  li.addEventListener("click", () => pickJob(j.name));
@@ -491,7 +514,7 @@ export function renderRun(host, answer, ui, { now = new Date(), pickJob = () =>
491
514
  }
492
515
  const ln = el("div", `r-log-line${row.quiet ? " r-log-quiet" : ""}`);
493
516
  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));
517
+ ln.append(el("span", "r-ln", String(row.n)), row.quiet ? el("span", shown.held ? "r-lt r-wait" : "r-lt", shown.text) : textOf(shown.held ? "r-lt r-wait" : "r-lt", shown.text));
495
518
  pre.appendChild(ln);
496
519
  }
497
520
  if (!s.log.length) pre.appendChild(el("div", "r-log-cut", s.status === "running" ? "waiting for output…" : "no output"));
@@ -537,7 +560,7 @@ export const PICK_HOLD_MS = 90_000;
537
560
  * is live), and redraw. The live run is selected until a viewer picks
538
561
  * another, and again {@link PICK_HOLD_MS} after their last pick.
539
562
  */
540
- export function startRuns({ side, main }, { fetchJson, now = () => new Date(), scenarios = {}, titles = () => ({}), names = () => ({}), onLive = () => {} } = {}) {
563
+ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), scenarios = {}, titles = () => ({}), names = () => ({}), onLive = () => {}, roots = () => [], waves = () => [], light = () => false } = {}) {
541
564
  const s = { index: null, run: null, selected: null, picked: 0, ui: { job: null, open: null, setup: null } };
542
565
  let follow = true;
543
566
  let lastRunKey = "";
@@ -569,6 +592,9 @@ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), s
569
592
  const drawn = renderRun(main, ans, s.ui, {
570
593
  now: now(),
571
594
  entry,
595
+ roots: roots(),
596
+ waves: waves(),
597
+ light: light(),
572
598
  pickJob: (name) => {
573
599
  s.ui = { job: name, open: null, setup: null };
574
600
  hold();
package/web/kiosk.html CHANGED
@@ -58,6 +58,9 @@
58
58
  .r-job.r-selected { background: var(--active); }
59
59
  .r-job-name { font: 600 0.92em var(--font-mono); }
60
60
  .r-job-needs { color: var(--muted); font-size: 0.78em; }
61
+ /* behold#571: each root's colour, as a dot by its name; never a text or fill colour, which mean a state. */
62
+ .k-root::before { content: ""; display: inline-block; width: 0.6em; height: 0.6em; border-radius: 50%; background: var(--k-root); margin: 0 0.28em 0.1em 0.05em; vertical-align: middle; }
63
+ .r-job-roots { display: inline-flex; gap: 0.75em; font-size: 0.82em; color: var(--fg); }
61
64
  .r-dur { margin-left: auto; color: var(--muted); font-size: 0.8em; white-space: nowrap; }
62
65
  .r-job-skipped { color: var(--muted); font-size: 0.78em; cursor: default; }
63
66
  .r-steps { display: flex; flex-direction: column; min-height: 0; flex: 1; overflow-y: auto; padding-bottom: 0.5em; }
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. */
@@ -218,14 +219,17 @@ export function unreviewedLines(answer) {
218
219
 
219
220
  /**
220
221
  * The approval the driver gives at a waiting wave, as terragucci's
221
- * stack/demo.sh gives it: `approve_wave` runs chant's approve, sealed with
222
- * the driver's ssh key, then `resume_pull` posts `/terragucci apply` on the
223
- * merged pull request. The actor and the key are placeholders: the kiosk
224
- * 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.
225
229
  */
226
230
  export function driverApproveLines(wave, pull) {
227
231
  return {
228
- 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>`,
229
233
  comment: "/terragucci apply",
230
234
  on: pull !== null && pull !== undefined ? `pull request #${pull}` : "the merged pull request",
231
235
  };
@@ -254,12 +258,13 @@ export function pendingApproval(demo, progress) {
254
258
 
255
259
  /**
256
260
  * 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
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
258
263
  * plan's digest is on the wave's box in the view.
259
264
  */
260
265
  export function approveGloss(appr) {
261
266
  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.`;
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.`;
263
268
  }
264
269
 
265
270
  /**
@@ -457,6 +462,15 @@ const el = (tag, cls, text) => {
457
462
  return n;
458
463
  };
459
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
+
460
474
  /** Draw the bar and the foot from what the kiosk holds. Pure over its arguments, but for the DOM. */
461
475
  export function renderKiosk(bar, s, now = new Date(), foot = null) {
462
476
  bar.replaceChildren();
@@ -482,11 +496,11 @@ export function renderKiosk(bar, s, now = new Date(), foot = null) {
482
496
  const rows = aboutRows(demo);
483
497
  if (rows.length) {
484
498
  const card = el("dl", "k-about");
485
- 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));
486
500
  left.appendChild(card);
487
501
  }
488
502
  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));
503
+ said.append(el("span", "k-step-word", STEP_WORDS[demo.step] || demo.step), elRoots("span", "k-label", demo.label, s));
490
504
  left.appendChild(said);
491
505
  bar.appendChild(left);
492
506
 
@@ -500,14 +514,14 @@ export function renderKiosk(bar, s, now = new Date(), foot = null) {
500
514
  const nx = nextWords(demo, now);
501
515
  const next = el("div", "k-next");
502
516
  next.appendChild(el("span", "k-next-word", "next, by the driver"));
503
- next.appendChild(el("span", "k-action", nx.action));
517
+ next.appendChild(elRoots("span", "k-action", nx.action, s));
504
518
  next.appendChild(el("span", "k-when", nx.when));
505
519
  right.appendChild(next);
506
520
 
507
521
  const appr = pendingApproval(demo, s.progress);
508
522
  if (appr) {
509
523
  const a = el("div", "k-approve");
510
- a.appendChild(el("p", "k-approve-gloss", approveGloss(appr)));
524
+ a.appendChild(elRoots("p", "k-approve-gloss", approveGloss(appr), s));
511
525
  a.appendChild(el("code", "k-command", appr.approve));
512
526
  const then = el("p", "k-approve-then");
513
527
  then.append(`then comments `, el("code", "k-inline", appr.comment), ` on ${appr.on}, and the waves run again.`);
@@ -515,18 +529,22 @@ export function renderKiosk(bar, s, now = new Date(), foot = null) {
515
529
  right.appendChild(a);
516
530
  } else {
517
531
  const panel = runPanel(demo, s.answer, s.progress);
518
- if (panel.note) right.appendChild(el("p", "k-note", panel.note));
519
- 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));
520
534
  }
521
535
  bar.appendChild(right);
522
536
 
523
537
  if (foot) {
524
538
  foot.appendChild(el("span", "k-legend", legendWords(s.view || "resources")));
525
539
  // behold#571: no line on where the narration was read from: a viewer new to the demo reads the facts.
526
- for (const f of factLines(demo, s.answer)) foot.appendChild(el("span", "k-fact", f));
540
+ for (const f of factLines(demo, s.answer)) foot.appendChild(elRoots("span", "k-fact", f, s));
527
541
  }
528
542
  }
529
543
 
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);";
547
+
530
548
  /** How often the kiosk asks for demo.json. The server shares one read between kiosks. */
531
549
  export const DEMO_POLL_MS = 3_000;
532
550
 
@@ -538,7 +556,7 @@ export const DEMO_POLL_MS = 3_000;
538
556
  * with a `behold:view` message; the frame takes them only from this origin.
539
557
  */
540
558
  export function startKiosk({ bar, frame, foot, side, main, views }, { fetchJson = defaultFetch, now = () => new Date(), origin = location.origin } = {}) {
541
- 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 };
542
560
  let lastKey = "";
543
561
  let lastFocus;
544
562
  const shownView = () => kioskView(s.demo && s.demo.demo, { components: s.components, pick: s.pick, now: now().getTime() });
@@ -558,7 +576,59 @@ export function startKiosk({ bar, frame, foot, side, main, views }, { fetchJson
558
576
  s.pick = { view, at: now().getTime() };
559
577
  draw();
560
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
+ }
561
629
  const draw = () => {
630
+ s.light = typeof document !== "undefined" && document.documentElement ? isLightBg(getComputedStyle(document.documentElement).getPropertyValue("--bg")) : false;
631
+ s.rootNames = rootList();
562
632
  const shown = shownView();
563
633
  s.view = shown.view;
564
634
  renderKiosk(bar, s, now(), foot);
@@ -569,7 +639,7 @@ export function startKiosk({ bar, frame, foot, side, main, views }, { fetchJson
569
639
  // behold#571: the titles and names demo.json has carried, by scenario, for the runs' subtitles.
570
640
  const titles = {};
571
641
  const names = {};
572
- if (side && main) startRuns({ side, main }, { fetchJson, now, scenarios: SCENARIOS, titles: () => titles, names: () => names });
642
+ if (side && main) startRuns({ side, main }, { fetchJson, now, scenarios: SCENARIOS, titles: () => titles, names: () => names, roots: rootList, waves: () => s.waves, light: () => s.light });
573
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.
574
644
  const openFrame = () => {
575
645
  if (!frame) return;
@@ -581,6 +651,11 @@ export function startKiosk({ bar, frame, foot, side, main, views }, { fetchJson
581
651
  };
582
652
  void fetchJson("/api/project").then((p) => {
583
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
+ }
584
659
  openFrame();
585
660
  draw();
586
661
  });
package/web/kiosk.test.js CHANGED
@@ -283,8 +283,8 @@ describe("the kiosk for a viewer new to the demo (behold#571)", () => {
283
283
  });
284
284
 
285
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.");
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
288
  const p = progress([{ number: 2, state: "waiting", roots: ["app"], reads: [], digest: "jcs1-sha256:0accc105ba06" }]);
289
289
  const was = globalThis.document;
290
290
  globalThis.document = fakeDocument();
@@ -293,7 +293,7 @@ describe("the kiosk for a viewer new to the demo (behold#571)", () => {
293
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
294
  const card = find(bar, "k-approve")[0];
295
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>");
296
+ expect(card.children[1].textContent).toBe("chant approve tf-apply wave-2 --actor <reviewer> --sign <reviewer key>");
297
297
  } finally {
298
298
  globalThis.document = was;
299
299
  }