@intentius/behold 0.29.6 → 0.29.8

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
@@ -13,7 +13,12 @@
13
13
  // A wave that failed says which waves applied before it and stay applied.
14
14
  // Nothing here is all-or-nothing, and nothing says so.
15
15
 
16
- import { PICK_HOLD_MS, startRuns } from "./kiosk-runs.js";
16
+ import { PICK_HOLD_MS, annMark, mergeRun, startRuns } from "./kiosk-runs.js";
17
+ import { PANEL_STEPS, PANEL_TITLE, approvalModel, cycleRuns, rowFacts, shortDigest } from "./kiosk-approval.js";
18
+ import { getThemeName, mountThemePicker, onThemeChange } from "./theme.js";
19
+ import { cssString, hoverCss, hoverFromMessage, hoverLinks, hoverMessage, isLightBg, rootSegments, rootText } from "./kiosk-roots.js";
20
+ import { creditLines, filterIndex, keepRun, liveEstimate, liveFor, namesOther, notRunWords, replayAt, replayBanner, replayIndex, rootWord, runAsOf, scrubDemo, servesRepo, vendorOf } from "./kiosk-vendor.js";
21
+ import { repoParam, repoUrl } from "./kiosk-repo.js";
17
22
  import { STATE_WORDS, changingRoots, costLine, pinsCommit, planFitsPin, pullPin, readEdges, rootsCount, totalsLine, waveRows, blastRows } from "./terragucci-change.js";
18
23
 
19
24
  /** The scenarios terragucci#1014 numbers. An id not here is shown by the title demo.json carries. */
@@ -67,7 +72,12 @@ export function scenarioName(demo) {
67
72
  * place of SCENARIOS' label when it has one; the driver's title alone for an
68
73
  * id behold doesn't know.
69
74
  */
70
- export function scenarioHead(demo) {
75
+ export function scenarioHead(demo, { vendor = null } = {}) {
76
+ // behold#571: a tool's page numbers nothing, so nothing hints at the loop's other scenarios.
77
+ if (vendor) {
78
+ const name = scenarioName(demo);
79
+ return { kicker: name ? `Scenario · ${name}` : "Scenario", title: demo.title || name || demo.scenario };
80
+ }
71
81
  const known = Object.prototype.hasOwnProperty.call(SCENARIOS, demo.scenario) ? SCENARIOS[demo.scenario] : null;
72
82
  const name = scenarioName(demo);
73
83
  if (counted(demo.index)) {
@@ -218,14 +228,17 @@ export function unreviewedLines(answer) {
218
228
 
219
229
  /**
220
230
  * 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.
231
+ * stack/demo.sh gives it: `approve_wave` runs chant's approve as the
232
+ * reviewer (terragucci#1052: the driver plays two people, demo-author who
233
+ * opens, merges and comments, and demo-reviewer who approves, since an
234
+ * approval only counts from someone who had no hand in the change), sealed
235
+ * with the reviewer's own signing key; then `resume_pull` posts
236
+ * `/terragucci apply` on the merged pull request. The actor and the key are
237
+ * placeholders: the kiosk never names a path or a secret.
225
238
  */
226
239
  export function driverApproveLines(wave, pull) {
227
240
  return {
228
- approve: `chant approve tf-apply wave-${wave} --actor <driver> --sign <driver key>`,
241
+ approve: `chant approve tf-apply wave-${wave} --actor <reviewer> --sign <reviewer key>`,
229
242
  comment: "/terragucci apply",
230
243
  on: pull !== null && pull !== undefined ? `pull request #${pull}` : "the merged pull request",
231
244
  };
@@ -254,12 +267,13 @@ export function pendingApproval(demo, progress) {
254
267
 
255
268
  /**
256
269
  * 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
270
+ * who has never approved a pipeline: who the driver plays, what it signs
271
+ * with (terragucci#1052: a reviewer apart from the change's author). The
258
272
  * plan's digest is on the wave's box in the view.
259
273
  */
260
274
  export function approveGloss(appr) {
261
275
  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.`;
276
+ 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
277
  }
264
278
 
265
279
  /**
@@ -331,8 +345,10 @@ export function runPin(demo) {
331
345
  }
332
346
 
333
347
  /** The framed view's address: behold's own page, bare, at a zoom (the resources zoom unless told), told who its host is. */
334
- export function frameSrc(origin, zoom = "resources") {
348
+ export function frameSrc(origin, zoom = "resources", repo = null, theme = null) {
335
349
  const q = new URLSearchParams({ embed: "1", bare: "1", zoom, host: origin });
350
+ if (repo) q.set("repo", repo); // behold#571: the checkout the frame draws, on a behold that serves several
351
+ if (theme) q.set("theme", theme); // behold#571: the kiosk's theme, which the frame follows
336
352
  return `/?${q.toString()}`;
337
353
  }
338
354
 
@@ -394,11 +410,12 @@ export function kioskView(demo, { components = false, pick = null, now = Date.no
394
410
  }
395
411
 
396
412
  /** The line under the view saying how to read it, for the view shown. */
397
- export function legendWords(view) {
413
+ export function legendWords(view, tool = null) {
414
+ const root = rootWord(tool);
398
415
  if (view === "components") {
399
- return "Each card is a Terraform root with its own state, in a box per wave titled with the pipeline job that applies it; an arrow is one root reading another's outputs, dashed while it reads planned values. A wave applies its roots together, after the waves it reads.";
416
+ return `Each card is ${root} with its own state, in a box per wave titled with the pipeline job that applies it; an arrow is one root reading another's outputs, dashed while it reads planned values. A wave applies its roots together, after the waves it reads.`;
400
417
  }
401
- return "Each box is a Terraform root with its own state; an arrow is one root reading another's outputs, dashed while it reads planned values. A wave applies its roots together, after the waves it reads.";
418
+ return `Each box is ${root} with its own state; an arrow is one root reading another's outputs, dashed while it reads planned values. A wave applies its roots together, after the waves it reads.`;
402
419
  }
403
420
 
404
421
  /** The behold zoom for a view. */
@@ -457,76 +474,232 @@ const el = (tag, cls, text) => {
457
474
  return n;
458
475
  };
459
476
 
477
+ /** behold#571: an element whose text puts a dot in its root's colour by each whole-word root name. */
478
+ const elRoots = (tag, cls, text, s) => {
479
+ const roots = (s && (s.rootNames || s.roots)) || [];
480
+ if (text === undefined || text === null || !rootSegments(String(text), roots).some((x) => x.root)) return el(tag, cls, text);
481
+ const n = el(tag, cls);
482
+ n.appendChild(rootText(document, String(text), roots, { light: !!(s && s.light) }));
483
+ return n;
484
+ };
485
+
460
486
  /** Draw the bar and the foot from what the kiosk holds. Pure over its arguments, but for the DOM. */
461
487
  export function renderKiosk(bar, s, now = new Date(), foot = null) {
462
488
  bar.replaceChildren();
463
489
  if (foot) foot.replaceChildren();
490
+ drawBar(bar, s, now, foot);
491
+ // behold#571: a tool's page ends with who makes the tool, and that they don't endorse the demo.
492
+ if (foot && vendorOf(s.vendor)) drawCredit(foot, s.vendor);
493
+ }
494
+
495
+ function drawBar(bar, s, now, foot) {
496
+ const vendor = vendorOf(s.vendor);
497
+ if (vendor && s.mode === "none") {
498
+ // behold#571: the tool's scenario has no cycle yet: its name, its link, and a calm line.
499
+ const words = notRunWords(vendor);
500
+ const box = el("div", "k-empty");
501
+ const title = el("h1", "k-title");
502
+ title.append(brandEl(vendor), el("span", "k-title-text", words.title));
503
+ box.append(title, el("p", "k-muted", words.text));
504
+ bar.appendChild(box);
505
+ return;
506
+ }
464
507
  if (!s.demo || !s.demo.demo) {
465
508
  const box = el("div", "k-empty");
466
- box.appendChild(el("h1", "k-title", "Waiting for the demo"));
509
+ const title = el("h1", "k-title");
510
+ if (vendor) title.appendChild(brandEl(vendor));
511
+ title.appendChild(el("span", "k-title-text", "Waiting for the demo"));
512
+ box.appendChild(title);
467
513
  box.appendChild(el("p", "k-muted", s.demo ? s.demo.error || s.demo.absent : "Reading demo.json…"));
468
514
  bar.appendChild(box);
469
515
  return;
470
516
  }
471
517
  const demo = s.demo.demo;
472
- const head = scenarioHead(demo);
518
+ const head = scenarioHead(demo, { vendor: s.vendor || null });
473
519
 
520
+ // behold#571: a tool's page says plainly when it replays an earlier run, and when it can't draw the repo's graph.
521
+ if (s.replay && s.replay.banner) bar.appendChild(el("p", "k-replay", s.replay.banner));
522
+ if (vendor && s.noReports && (s.mode === "live" || s.mode === "replay")) bar.appendChild(el("p", "k-nograph-note", NO_GRAPH_WORDS));
474
523
  const intro = introText(demo);
475
- if (intro) bar.appendChild(el("p", "k-intro", intro));
476
524
 
477
- // behold#571: the kicker on the title's line, so the bar has room for the intro.
525
+ // behold#571: the left column in fixed boxes: the title, the card (the intro between scenarios), the step.
478
526
  const left = el("div", "k-left");
527
+ const titleBox = box("title", "k-box-title");
479
528
  const title = el("h1", "k-title");
529
+ if (vendor) title.appendChild(brandEl(vendor));
480
530
  title.append(el("span", "k-kicker", head.kicker), el("span", "k-title-text", head.title));
481
- left.appendChild(title);
531
+ if (title.setAttribute) title.setAttribute("title", `${head.kicker} · ${head.title || ""}`);
532
+ titleBox.appendChild(title);
533
+ left.appendChild(titleBox);
534
+ const cardBox = box("card");
482
535
  const rows = aboutRows(demo);
483
- if (rows.length) {
536
+ if (demo.step === "reset" && intro) {
537
+ // Between scenarios nothing else happens: the card's box says what the demo is about.
538
+ const p = el("p", "k-intro");
539
+ p.append(el("span", "k-intro-head", INTRO_HEAD), intro);
540
+ cardBox.appendChild(p);
541
+ } else if (rows.length) {
484
542
  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));
486
- left.appendChild(card);
543
+ for (const r of rows) card.append(el("dt", "k-about-label", r.label), elRoots("dd", "k-about-text", r.text, s));
544
+ cardBox.appendChild(card);
487
545
  }
546
+ left.appendChild(cardBox);
488
547
  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));
490
- left.appendChild(said);
548
+ said.append(el("span", "k-step-word", STEP_WORDS[demo.step] || demo.step), elRoots("span", "k-label", demo.label, s));
549
+ const stepBox = box("step");
550
+ stepBox.appendChild(said);
551
+ left.appendChild(stepBox);
491
552
  bar.appendChild(left);
492
553
 
493
554
  const right = el("div", "k-right");
494
555
  const strip = el("ol", "k-strip");
495
556
  for (const stop of stripStops(demo.step)) strip.appendChild(el("li", `k-stop k-${stop.state}`, stop.words));
496
557
  const where = el("div", "k-where");
497
- where.append(strip, el("span", "k-cycle", cycleWords(demo, now)));
558
+ where.appendChild(strip);
498
559
  right.appendChild(where);
499
560
 
500
561
  const nx = nextWords(demo, now);
501
562
  const next = el("div", "k-next");
502
563
  next.appendChild(el("span", "k-next-word", "next, by the driver"));
503
- next.appendChild(el("span", "k-action", nx.action));
564
+ next.appendChild(elRoots("span", "k-action", nx.action, s));
504
565
  next.appendChild(el("span", "k-when", nx.when));
566
+ next.appendChild(el("span", "k-cycle", cycleWords(demo, now)));
505
567
  right.appendChild(next);
506
568
 
569
+ // behold#571: the hold's box: how the hold ends, from the records; else the driver's approval, or the run's note.
570
+ const holdBox = box("hold");
507
571
  const appr = pendingApproval(demo, s.progress);
508
- if (appr) {
572
+ if (s.approval) {
573
+ holdBox.appendChild(approvalPanel(s.approval, s));
574
+ } else if (appr) {
509
575
  const a = el("div", "k-approve");
510
- a.appendChild(el("p", "k-approve-gloss", approveGloss(appr)));
576
+ a.appendChild(elRoots("p", "k-approve-gloss", approveGloss(appr), s));
511
577
  a.appendChild(el("code", "k-command", appr.approve));
512
578
  const then = el("p", "k-approve-then");
513
579
  then.append(`then comments `, el("code", "k-inline", appr.comment), ` on ${appr.on}, and the waves run again.`);
514
580
  a.appendChild(then);
515
- right.appendChild(a);
516
- } else {
581
+ holdBox.appendChild(a);
582
+ } else if (!s.noReports) {
517
583
  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));
584
+ if (panel.note) holdBox.appendChild(elRoots("p", "k-note", panel.note, s));
585
+ else if (panel.stage === "pull-request") for (const u of unreviewedLines(s.answer)) holdBox.appendChild(elRoots("p", "k-note k-unknown", u, s));
520
586
  }
587
+ right.appendChild(holdBox);
521
588
  bar.appendChild(right);
522
589
 
523
590
  if (foot) {
524
- foot.appendChild(el("span", "k-legend", legendWords(s.view || "resources")));
591
+ // The legend reads the graph; with no graph shown there is nothing for it to explain.
592
+ if (!s.noReports) foot.appendChild(el("span", "k-legend", legendWords(s.view || "resources", s.vendor || null)));
525
593
  // 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));
594
+ if (!s.noReports) for (const f of factLines(demo, s.answer)) foot.appendChild(elRoots("span", "k-fact", f, s));
527
595
  }
528
596
  }
529
597
 
598
+ /** behold#571: the head over the intro, in the card's box between scenarios, and the title of "What is this?". */
599
+ export const INTRO_HEAD = "What this demo is about";
600
+
601
+ /** A fixed box of the header: its text scrolls inside it, and the page keeps its scroll across redraws by `data-box`. */
602
+ function box(name, cls = "") {
603
+ const b = el("div", `k-box${cls ? ` ${cls}` : ""}`);
604
+ if (b.dataset) b.dataset.box = name;
605
+ return b;
606
+ }
607
+
608
+ /**
609
+ * behold#571: "How this hold ends", drawn from approvalModel's rows. A row's
610
+ * chip names the log line it rests on and shows it in the runs pane
611
+ * (`s.showLine`).
612
+ */
613
+ export function approvalPanel(model, s = {}) {
614
+ const wrap = el("div", "k-ap");
615
+ const head = el("p", "k-ap-head", `${PANEL_TITLE} · wave ${model.wave} `);
616
+ if (model.digest) head.appendChild(el("span", "k-ap-plan", `plan ${shortDigest(model.digest)}`));
617
+ head.append(" · times in UTC");
618
+ wrap.appendChild(head);
619
+ const list = el("ol", "k-ap-rows");
620
+ for (const r of model.rows) {
621
+ const li = el("li", `k-ap-row k-ap-${r.state}`);
622
+ const mark = el("span", "k-ap-mark");
623
+ if (mark.setAttribute) mark.setAttribute("aria-label", r.state === "lit" ? "done" : r.state === "going" ? "under way" : "not yet");
624
+ li.append(mark, el("span", "k-ap-label", r.label));
625
+ const text = elRoots("span", "k-ap-text", r.text, s);
626
+ const facts = rowFacts(r);
627
+ if (facts.length) text.appendChild(el("span", `k-ap-facts${r.seal && r.seal.ok === false ? " k-ap-bad" : ""}`, facts.join(" · ")));
628
+ if (r.line && r.line.a) {
629
+ const chip = annMark(r.line.a, { first: true });
630
+ chip.dataset.annJump = "1";
631
+ chip.title = `${r.line.a.note} Shows run #${r.line.run}, ${r.line.job}, line ${r.line.n}.`;
632
+ const line = r.line;
633
+ chip.addEventListener("click", () => s.showLine && s.showLine(line));
634
+ text.append(" ", chip);
635
+ }
636
+ li.appendChild(text);
637
+ if (r.command) li.appendChild(el("code", "k-command", r.command));
638
+ list.appendChild(li);
639
+ }
640
+ wrap.appendChild(list);
641
+ return wrap;
642
+ }
643
+
644
+ /** behold#571: the header's line when this behold serves another repo than the page's. */
645
+ export const NO_GRAPH_WORDS = "This page shows the runs and their logs; the graph of this repo isn't served here.";
646
+
647
+ /** behold#571: a tool's name, and its logo where its owner allows one, linking to its official site. */
648
+ function brandEl(vendor) {
649
+ const a = el("a", "k-brand");
650
+ a.href = vendor.url;
651
+ a.target = "_blank";
652
+ a.rel = "noopener";
653
+ if (vendor.logo) {
654
+ const img = el("img", "k-brand-logo");
655
+ img.src = vendor.logo.src;
656
+ if (vendor.logo.src2x) img.srcset = `${vendor.logo.src2x} 2x`;
657
+ img.alt = "";
658
+ a.appendChild(img);
659
+ }
660
+ a.appendChild(el("span", "k-brand-name", vendor.name));
661
+ return a;
662
+ }
663
+
664
+ /** behold#571: the footer's credit on a tool's page, its names linked to their official sites. */
665
+ function drawCredit(foot, tool) {
666
+ for (const line of creditLines(tool)) {
667
+ const p = el("span", "k-credit");
668
+ for (const seg of line) {
669
+ if (!seg.url) {
670
+ p.append(seg.text);
671
+ continue;
672
+ }
673
+ const a = el("a", "k-credit-link", seg.text);
674
+ a.href = seg.url;
675
+ a.target = "_blank";
676
+ a.rel = "noopener";
677
+ p.appendChild(a);
678
+ }
679
+ foot.appendChild(p);
680
+ }
681
+ }
682
+
683
+ /** behold#571: how a hovered root's name and wave's job row light. A background and a shadow: nothing moves. */
684
+ 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;";
685
+ export const HOVER_JOB_RULE = "background: color-mix(in srgb, var(--focus) 24%, transparent); box-shadow: inset 4px 0 0 var(--focus);";
686
+
687
+ /** behold#571: how often the approval panel reads the cycle's runs, and the gate ledger (which the server caches 30 s). */
688
+ export const APPROVAL_POLL_MS = 4_000;
689
+ export const LEDGER_POLL_MS = 10_000;
690
+
691
+ /** behold#571: the approval panel's words for the approval the driver gives next, over its command. */
692
+ export const DRIVER_SIGNS = "Next the driver plays a reviewer with no hand in the change and signs this plan with that reviewer's key:";
693
+
694
+ /** behold#571: "What is this?" before demo.json says. */
695
+ export const WHAT_FALLBACK = "A demo that runs a real infrastructure change through its pipeline in a loop, and shows each step as it happens.";
696
+
697
+ /** behold#571: a fade at a box's edge where its text goes on past it. */
698
+ function fade(b) {
699
+ b.classList.toggle("k-more-below", b.scrollTop + b.clientHeight < b.scrollHeight - 1);
700
+ b.classList.toggle("k-more-above", b.scrollTop > 1);
701
+ }
702
+
530
703
  /** How often the kiosk asks for demo.json. The server shares one read between kiosks. */
531
704
  export const DEMO_POLL_MS = 3_000;
532
705
 
@@ -537,8 +710,37 @@ export const DEMO_POLL_MS = 3_000;
537
710
  * countdown. When the roots the step is about change, move the framed view
538
711
  * with a `behold:view` message; the frame takes them only from this origin.
539
712
  */
540
- 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" };
713
+ export function startKiosk({ bar, frame, foot, side, main, views, tools = null }, { fetchJson = defaultFetch, now = () => new Date(), origin = location.origin, tool = null } = {}) {
714
+ const s = { demo: null, answer: null, progress: null, components: false, pick: null, view: "resources", roots: [], waves: [], light: false, hover: null, frameHover: null };
715
+ // behold#571: on a tool's page, `live` while its scenario runs, `replay` of its latest cycle otherwise, `none` before it has one.
716
+ const vendor = vendorOf(tool);
717
+ s.vendor = vendor ? tool : null;
718
+ s.mode = vendor ? "loading" : "live";
719
+ s.replay = null;
720
+ s.repo = null;
721
+ s.live = null;
722
+ if (vendor && typeof document !== "undefined") document.title = `${vendor.name} · terragucci demo`;
723
+ /** The time the page shows: the replay's, while it replays. */
724
+ const shownNow = () => (s.mode === "replay" && s.replay && s.replay.at ? s.replay.at : now());
725
+ /** behold#571: the frame and the plan's facts are the served repo's, so a tool's page shows them only for its own repo. */
726
+ const frameOk = () => !vendor || servesRepo(s.answer && !s.answer.error ? s.answer.project : null, s.repo);
727
+ // behold#571: a behold serving several repos (`serve --repos`) answers the
728
+ // graph's reads from the checkout of the repo the page is about, asked by
729
+ // name. `viewRepos` is what it serves, `aimed` the repo the reads are for.
730
+ s.viewRepos = null;
731
+ s.aimed = null;
732
+ const wantRepo = () => repoParam(s.repo, s.viewRepos);
733
+ const fetchServed = fetchJson;
734
+ fetchJson = (url) => fetchServed(repoUrl(url, wantRepo()));
735
+ /** When the page moves to another served repo, read its project, reports and run view again, and point the frame at it. */
736
+ const followRepo = () => {
737
+ const want = wantRepo();
738
+ if (!want || want === s.aimed) return;
739
+ s.aimed = want;
740
+ void readProject();
741
+ void reports(true);
742
+ void progress(true);
743
+ };
542
744
  let lastKey = "";
543
745
  let lastFocus;
544
746
  const shownView = () => kioskView(s.demo && s.demo.demo, { components: s.components, pick: s.pick, now: now().getTime() });
@@ -558,32 +760,280 @@ export function startKiosk({ bar, frame, foot, side, main, views }, { fetchJson
558
760
  s.pick = { view, at: now().getTime() };
559
761
  draw();
560
762
  };
763
+ // behold#571: the roots' colours and the hover links between the panes and the frame.
764
+ const rootList = () => (s.roots.length ? s.roots : s.answer && Array.isArray(s.answer.roots) ? s.answer.roots.map((r) => r.path) : []);
765
+ const glow = () => {
766
+ let style = document.getElementById("k-hover-style");
767
+ if (!style) {
768
+ style = document.createElement("style");
769
+ style.id = "k-hover-style";
770
+ document.head.appendChild(style);
771
+ }
772
+ const a = hoverLinks(s.hover, s.waves);
773
+ const b = hoverLinks(s.frameHover, s.waves);
774
+ const links = { roots: [...new Set([...a.roots, ...b.roots])], waves: [...new Set([...a.waves, ...b.waves])] };
775
+ style.textContent = [
776
+ hoverCss({ roots: links.roots, waves: [] }, { root: (r) => `.k-root[data-k-root=${cssString(r)}]` }, HOVER_ROOT_RULE),
777
+ hoverCss({ roots: [], waves: links.waves }, { wave: (w) => `.r-job[data-k-wave="${w}"]` }, HOVER_JOB_RULE),
778
+ ].join("\n");
779
+ };
780
+ const tellHover = (h) => {
781
+ if (frame && frame.contentWindow) frame.contentWindow.postMessage(hoverMessage(h), origin);
782
+ };
783
+ const hoverAt = (target) => {
784
+ const t = target && target.closest ? target.closest("[data-k-root],[data-k-wave]") : null;
785
+ if (!t) return null;
786
+ if (t.dataset.kRoot) return { root: t.dataset.kRoot };
787
+ const w = Number(t.dataset.kWave);
788
+ return Number.isInteger(w) ? { wave: w } : null;
789
+ };
790
+ const setHover = (h) => {
791
+ if ((h && h.root) === (s.hover && s.hover.root) && (h && h.wave) === (s.hover && s.hover.wave)) return;
792
+ s.hover = h;
793
+ glow();
794
+ tellHover(h);
795
+ };
796
+ if (typeof document !== "undefined" && document.addEventListener) {
797
+ document.addEventListener("pointerover", (e) => setHover(hoverAt(e.target)));
798
+ document.documentElement.addEventListener("pointerleave", () => setHover(null));
799
+ // A click or Escape anywhere here is a click elsewhere for the frame's pinned popover.
800
+ const unpin = () => frame && frame.contentWindow && frame.contentWindow.postMessage({ type: "behold:unpin" }, origin);
801
+ document.addEventListener("pointerdown", unpin);
802
+ document.addEventListener("keydown", (e) => {
803
+ if (e.key === "Escape") unpin();
804
+ });
805
+ window.addEventListener("message", (e) => {
806
+ if (e.origin !== origin || !frame || e.source !== frame.contentWindow) return;
807
+ const h = hoverFromMessage(e.data);
808
+ if (h === undefined) return;
809
+ s.frameHover = h;
810
+ glow();
811
+ });
812
+ }
813
+
814
+ // behold#571: the titles and names demo.json has carried, by scenario, for the runs' subtitles.
815
+ const titles = {};
816
+ const names = {};
817
+ /** Take one narration (demo.json, or a replayed step), and ask for the run it is about when the step moved. */
818
+ const setDemo = (answer) => {
819
+ s.demo = answer;
820
+ const d = answer && answer.demo;
821
+ if (d && d.scenario && typeof d.title === "string" && d.title) titles[d.scenario] = d.title;
822
+ if (d && d.scenario && scenarioName(d)) names[d.scenario] = scenarioName(d);
823
+ const key = d ? `${s.mode}|${d.cycle}|${d.step}|${d.commit}|${d.pull}` : "";
824
+ if (key !== lastKey) {
825
+ lastKey = key;
826
+ // The step moved: the run it is about may be newer than what behold read.
827
+ const stage = d ? demoStage(d) : "none";
828
+ if (stage === "pull-request" || stage === "either") void reports(true);
829
+ if (stage === "apply" || stage === "either") void progress(true);
830
+ }
831
+ };
832
+ // behold#571: the replay's clock, read on every draw.
833
+ const replayTick = () => {
834
+ if (s.mode !== "replay" || !s.replay) return;
835
+ const steps = s.replay.cycle.steps;
836
+ const r = replayAt(steps, now().getTime() - s.replay.start);
837
+ s.replay.at = r.at;
838
+ s.replay.banner = replayBanner(s.replay.cycle, liveEstimate(s.live && s.live.demo, s.replay.cycle, now()));
839
+ setDemo({ source: "replay", read: steps[r.index].at, demo: scrubDemo(steps[r.index], tool) });
840
+ };
841
+
561
842
  const draw = () => {
843
+ followRepo();
844
+ replayTick();
845
+ s.light = typeof document !== "undefined" && document.documentElement ? isLightBg(getComputedStyle(document.documentElement).getPropertyValue("--bg")) : false;
846
+ s.rootNames = rootList();
562
847
  const shown = shownView();
563
848
  s.view = shown.view;
564
- renderKiosk(bar, s, now(), foot);
565
- renderViewToggle(views, shown, { components: s.components, onPick: pickView });
849
+ const ok = frameOk();
850
+ s.noReports = !ok;
851
+ s.approval = approvalNow();
852
+ // behold#571: the header's boxes redraw every second; each keeps where a viewer scrolled it.
853
+ const scrolled = Object.fromEntries([...bar.querySelectorAll(".k-box")].map((b) => [b.dataset.box, b.scrollTop]));
854
+ renderKiosk(bar, s, shownNow(), foot);
855
+ for (const b of bar.querySelectorAll(".k-box")) {
856
+ if (scrolled[b.dataset.box]) b.scrollTop = scrolled[b.dataset.box];
857
+ fade(b);
858
+ b.addEventListener("scroll", () => fade(b), { passive: true });
859
+ }
860
+ whatText();
861
+ renderViewToggle(views, shown, { components: s.components && ok, onPick: pickView });
862
+ // behold#571: with no graph to show, the graph pane folds away and the runs and the log take its width.
863
+ if (typeof document !== "undefined" && document.body && document.body.classList) document.body.classList.toggle("k-nograph", !!vendor && !ok);
566
864
  tellFrame(false);
567
865
  };
568
- // #569: the pipeline's runs beside the view, CI-page style.
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 });
866
+
867
+ // behold#571: on a tool's page the runs pane shows the tool's repo alone; in the replay, the cycle's runs as they stood.
868
+ const runDocs = new Map();
869
+ const runDoc = (id) => {
870
+ if (!runDocs.has(id)) runDocs.set(id, fetchJson(`/api/terragucci/runs/${id}`).then((a) => (a && a.doc ? a.doc : null)));
871
+ return runDocs.get(id);
872
+ };
873
+ const replayRuns = async () => {
874
+ const r = s.replay;
875
+ if (r.runs) return r.runs;
876
+ const idx = await fetchJson("/api/terragucci/runs");
877
+ const listed = new Map((idx && idx.doc && Array.isArray(idx.doc.runs) ? idx.doc.runs : []).map((x) => [x.id, x]));
878
+ const runs = [];
879
+ for (const id of r.cycle.runs) {
880
+ const e = listed.get(id) || (await runDoc(id));
881
+ if (e) runs.push(e);
882
+ }
883
+ r.runs = runs.filter((x) => keepRun(x, { tool, repo: r.cycle.repo }));
884
+ return r.runs;
885
+ };
886
+ const runsSource = () => {
887
+ if (!vendor || s.mode === "live") return null;
888
+ if (s.mode !== "replay" || !s.replay) return { index: async () => ({ doc: { schema: "demo.runs/v1", runs: [] } }), run: async () => ({ doc: null, absent: "No run to show yet." }) };
889
+ return {
890
+ index: async () => replayIndex(await replayRuns(), shownNow().getTime()),
891
+ run: async (id) => {
892
+ const doc = await runDoc(id);
893
+ return doc ? { doc: runAsOf(doc, shownNow().getTime()) } : { doc: null, absent: `Run #${id} is no longer kept in the reports.` };
894
+ },
895
+ };
896
+ };
897
+ // behold#571: the approval panel's records: the cycle's runs, the gate ledger's history, and the lane.
898
+ const ap = { runs: new Map(), list: [], lifecycle: null, history: null, at: 0, ledgerAt: 0, busy: false };
899
+ const readApproval = async () => {
900
+ const d = s.demo && s.demo.demo;
901
+ if (!d || !PANEL_STEPS.includes(d.step) || ap.busy) return;
902
+ const t = now().getTime();
903
+ if (t - ap.at < APPROVAL_POLL_MS) return;
904
+ ap.busy = true;
905
+ ap.at = t;
906
+ try {
907
+ const src = runsSource();
908
+ const raw = src ? await src.index() : await fetchJson("/api/terragucci/runs");
909
+ const idx = vendor ? filterIndex(raw, { tool, repo: s.repo }) : raw;
910
+ const mine = cycleRuns(idx && idx.doc && Array.isArray(idx.doc.runs) ? idx.doc.runs : [], d);
911
+ for (const e of mine) {
912
+ const key = `${s.mode}|${e.status}|${e.ended_at}|${src ? Math.floor(shownNow().getTime() / 5000) : ""}`;
913
+ const had = ap.runs.get(e.id);
914
+ if (had && had.key === key) continue;
915
+ const r = src ? await src.run(e.id) : await fetchJson(`/api/terragucci/runs/${e.id}`);
916
+ if (r && r.doc) ap.runs.set(e.id, { key, doc: mergeRun(e, r.doc) });
917
+ }
918
+ ap.list = mine.map((e) => ap.runs.get(e.id)).filter(Boolean).map((x) => x.doc);
919
+ if (t - ap.ledgerAt >= LEDGER_POLL_MS) {
920
+ ap.ledgerAt = t;
921
+ const [lifecycle, history] = await Promise.all([fetchJson("/api/terragucci/lifecycle"), fetchJson("/api/lifecycle/history?limit=200")]);
922
+ ap.lifecycle = lifecycle && !lifecycle.error ? lifecycle : null;
923
+ ap.history = history && !history.error ? history : null;
924
+ }
925
+ } finally {
926
+ ap.busy = false;
927
+ }
928
+ draw();
929
+ };
930
+ const approvalNow = () => {
931
+ const d = s.demo && s.demo.demo;
932
+ if (!d || !PANEL_STEPS.includes(d.step)) return null;
933
+ const appr = pendingApproval(d, s.progress);
934
+ const driver = appr && s.mode !== "replay" ? { gloss: DRIVER_SIGNS, approve: appr.approve, comment: appr.comment, on: appr.on } : null;
935
+ return approvalModel({ demo: d, runs: ap.list, history: ap.history, lifecycle: ap.lifecycle, fallback: appr, now: shownNow().getTime(), driver });
936
+ };
937
+ // behold#571: a chip in the panel shows its log line in the runs pane (opened, on a phone).
938
+ let runsApi = null;
939
+ s.showLine = (line) => {
940
+ if (!runsApi) return;
941
+ if (tools && tools.runsToggle) setRunsOpen(true);
942
+ runsApi.show(line.run, line.job, line.step, line.n);
943
+ };
944
+ const setRunsOpen = (open) => {
945
+ document.body.classList.toggle("k-runs-open", open);
946
+ if (tools && tools.runsToggle) tools.runsToggle.setAttribute("aria-expanded", String(open));
947
+ };
948
+ if (tools && tools.runsToggle) tools.runsToggle.addEventListener("click", () => setRunsOpen(!document.body.classList.contains("k-runs-open")));
949
+
950
+ // behold#571: "What is this?" opens the intro over the page (no layout moves); a click elsewhere or Escape closes it.
951
+ const whatText = () => {
952
+ if (!tools || !tools.pop) return;
953
+ const d = s.demo && s.demo.demo;
954
+ const text = (d && introText(d)) || WHAT_FALLBACK;
955
+ const p = tools.pop.firstElementChild;
956
+ if (p && p.textContent === text) return;
957
+ tools.pop.replaceChildren(el("p", null, text));
958
+ };
959
+ if (tools && tools.what && tools.pop) {
960
+ const setWhat = (open) => {
961
+ tools.pop.hidden = !open;
962
+ tools.what.setAttribute("aria-expanded", String(open));
963
+ };
964
+ tools.what.addEventListener("click", (e) => {
965
+ e.stopPropagation();
966
+ whatText();
967
+ setWhat(tools.pop.hidden);
968
+ });
969
+ document.addEventListener("click", (e) => {
970
+ if (!tools.pop.hidden && !tools.pop.contains(e.target)) setWhat(false);
971
+ });
972
+ document.addEventListener("keydown", (e) => {
973
+ if (e.key === "Escape") setWhat(false);
974
+ });
975
+ }
976
+ // behold#571: the theme picker, persisted per viewer as the app does; the frame follows without a reload.
977
+ if (tools && tools.theme) mountThemePicker(tools.theme);
978
+ onThemeChange((th) => {
979
+ if (frame && frame.contentWindow) frame.contentWindow.postMessage({ type: "behold:theme", name: th.name }, origin);
980
+ draw();
981
+ });
982
+
983
+ if (side && main) {
984
+ runsApi = startRuns(
985
+ { side, main },
986
+ {
987
+ fetchJson,
988
+ now,
989
+ shown: shownNow,
990
+ scenarios: SCENARIOS,
991
+ titles: () => titles,
992
+ names: () => names,
993
+ roots: rootList,
994
+ waves: () => s.waves,
995
+ light: () => s.light,
996
+ source: runsSource,
997
+ filter: vendor ? (index) => filterIndex(index, { tool, repo: s.repo }) : (index) => index,
998
+ replay: () => s.mode === "replay",
999
+ hideLine: vendor ? (line) => namesOther(line, tool) : null,
1000
+ },
1001
+ );
1002
+ }
573
1003
  // #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.
1004
+ let frameSrcNow = null;
574
1005
  const openFrame = () => {
575
1006
  if (!frame) return;
576
- frame.src = frameSrc(origin, viewZoom(shownView().view));
577
- // The frame reads its graph after load; tell it again once it has had the time.
578
- frame.addEventListener("load", () => {
579
- for (const ms of [1500, 4000, 8000]) setTimeout(() => tellFrame(true), ms);
580
- });
1007
+ const src = frameSrc(origin, viewZoom(shownView().view), wantRepo(), getThemeName());
1008
+ if (src === frameSrcNow) return;
1009
+ if (frameSrcNow === null) {
1010
+ // The frame reads its graph after load; tell it again once it has had the time.
1011
+ frame.addEventListener("load", () => {
1012
+ for (const ms of [1500, 4000, 8000]) setTimeout(() => tellFrame(true), ms);
1013
+ });
1014
+ }
1015
+ frameSrcNow = src;
1016
+ frame.src = src;
581
1017
  };
582
- void fetchJson("/api/project").then((p) => {
1018
+ const readProject = () => fetchJson("/api/project").then((p) => {
1019
+ // behold#571: the repos this behold serves, and which one this answer is.
1020
+ if (p && !p.error && Array.isArray(p.viewRepos)) {
1021
+ s.viewRepos = p.viewRepos;
1022
+ if (s.aimed === null && p.viewRepo) s.aimed = p.viewRepo;
1023
+ }
583
1024
  s.components = !!(p && !p.error && p.terragucciComponents);
1025
+ // behold#571: the declared roots (their colours' order) and the wave jobs that apply them.
1026
+ if (p && p.terragucciRoots) {
1027
+ s.roots = Array.isArray(p.terragucciRoots.roots) ? p.terragucciRoots.roots : [];
1028
+ s.waves = Array.isArray(p.terragucciRoots.waves) ? p.terragucciRoots.waves : [];
1029
+ } else if (s.aimed) {
1030
+ s.roots = [];
1031
+ s.waves = [];
1032
+ }
584
1033
  openFrame();
585
1034
  draw();
586
1035
  });
1036
+ void readProject();
587
1037
 
588
1038
  const reports = async (fresh) => {
589
1039
  s.answer = await fetchJson(`/api/terragucci${fresh ? "?fresh=1" : ""}`);
@@ -604,19 +1054,45 @@ export function startKiosk({ bar, frame, foot, side, main, views }, { fetchJson
604
1054
  draw();
605
1055
  };
606
1056
 
1057
+ // behold#571: a tool's latest cycle, read when its scenario stops being live, and while it has none yet.
1058
+ let cycleRead = 0;
1059
+ const readCycle = async () => {
1060
+ cycleRead = now().getTime();
1061
+ const a = await fetchJson(`/api/terragucci/cycles/${tool}`);
1062
+ if (s.mode === "live") return; // its scenario started while the read was out
1063
+ if (a && a.cycle) {
1064
+ if (!s.replay || s.replay.cycle.cycle !== a.cycle.cycle) s.replay = { cycle: a.cycle, start: now().getTime(), at: null, banner: null, runs: null };
1065
+ s.mode = "replay";
1066
+ s.repo = a.cycle.repo;
1067
+ } else if (a && a.absent) {
1068
+ s.mode = "none";
1069
+ s.replay = null;
1070
+ } else {
1071
+ s.mode = "error";
1072
+ s.replay = null;
1073
+ s.demo = { demo: null, error: a && a.error ? a.error : "The tool's latest cycle could not be read." };
1074
+ }
1075
+ };
1076
+ const CYCLE_REREAD_MS = 60_000;
1077
+
607
1078
  const demo = async () => {
608
- s.demo = await fetchJson("/api/terragucci/demo");
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);
612
- const key = d ? `${d.cycle}|${d.step}|${d.commit}|${d.pull}` : "";
613
- if (key !== lastKey) {
614
- lastKey = key;
615
- // The step moved: the run it is about may be newer than what behold read.
616
- const stage = d ? demoStage(d) : "none";
617
- if (stage === "pull-request" || stage === "either") void reports(true);
618
- if (stage === "apply" || stage === "either") void progress(true);
1079
+ const live = await fetchJson("/api/terragucci/demo");
1080
+ s.live = live;
1081
+ if (!vendor) {
1082
+ s.repo = live && live.demo ? live.demo.repo : null;
1083
+ setDemo(live);
1084
+ return draw();
1085
+ }
1086
+ const d = live && live.demo;
1087
+ if (liveFor(d, tool, SCENARIOS)) {
1088
+ s.mode = "live";
1089
+ s.replay = null;
1090
+ s.repo = d.repo || s.repo;
1091
+ setDemo({ ...live, demo: scrubDemo(d, tool) });
1092
+ return draw();
619
1093
  }
1094
+ // Not this tool's scenario: replay its latest cycle, read again when it went live and ended, or now and then.
1095
+ if (s.mode === "live" || s.mode === "loading" || now().getTime() - cycleRead > CYCLE_REREAD_MS) await readCycle();
620
1096
  draw();
621
1097
  };
622
1098
 
@@ -624,6 +1100,7 @@ export function startKiosk({ bar, frame, foot, side, main, views }, { fetchJson
624
1100
  void reports(false);
625
1101
  void progress(false);
626
1102
  setInterval(() => void demo(), DEMO_POLL_MS);
1103
+ setInterval(() => void readApproval(), 1000);
627
1104
  setInterval(draw, 1000);
628
1105
 
629
1106
  if (typeof EventSource !== "undefined") {