@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.
- package/AGENTS.md +6 -1
- package/dist/cli.js +42 -5
- package/package.json +1 -1
- package/web/app.js +264 -0
- package/web/index.html +22 -0
- package/web/kiosk-popover.js +234 -0
- package/web/kiosk-popover.test.js +180 -0
- package/web/kiosk-roots.js +191 -0
- package/web/kiosk-roots.test.js +95 -0
- package/web/kiosk-runs.js +29 -3
- package/web/kiosk.html +3 -0
- package/web/kiosk.js +91 -16
- package/web/kiosk.test.js +3 -3
|
@@ -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
|
|
222
|
-
* the driver
|
|
223
|
-
*
|
|
224
|
-
*
|
|
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 <
|
|
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
|
|
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
|
|
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),
|
|
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),
|
|
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(
|
|
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(
|
|
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(
|
|
519
|
-
else if (panel.stage === "pull-request") for (const u of unreviewedLines(s.answer)) right.appendChild(
|
|
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(
|
|
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
|
|
287
|
-
expect(approveGloss({ wave: 3, roots: [], digest: null })).toBe("The driver
|
|
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 <
|
|
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
|
}
|