@intentius/behold 0.29.7 → 0.29.9

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,8 +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";
17
19
  import { cssString, hoverCss, hoverFromMessage, hoverLinks, hoverMessage, isLightBg, rootSegments, rootText } from "./kiosk-roots.js";
20
+ import { creditLines, filterIndex, keepRun, liveEstimate, liveFor, namesOther, notRunRepo, notRunWords, replayAt, replayBanner, replayIndex, rootWord, runAsOf, scrubDemo, servesRepo, vendorOf } from "./kiosk-vendor.js";
21
+ import { repoParam, repoUrl } from "./kiosk-repo.js";
18
22
  import { STATE_WORDS, changingRoots, costLine, pinsCommit, planFitsPin, pullPin, readEdges, rootsCount, totalsLine, waveRows, blastRows } from "./terragucci-change.js";
19
23
 
20
24
  /** The scenarios terragucci#1014 numbers. An id not here is shown by the title demo.json carries. */
@@ -68,7 +72,12 @@ export function scenarioName(demo) {
68
72
  * place of SCENARIOS' label when it has one; the driver's title alone for an
69
73
  * id behold doesn't know.
70
74
  */
71
- 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
+ }
72
81
  const known = Object.prototype.hasOwnProperty.call(SCENARIOS, demo.scenario) ? SCENARIOS[demo.scenario] : null;
73
82
  const name = scenarioName(demo);
74
83
  if (counted(demo.index)) {
@@ -336,8 +345,10 @@ export function runPin(demo) {
336
345
  }
337
346
 
338
347
  /** The framed view's address: behold's own page, bare, at a zoom (the resources zoom unless told), told who its host is. */
339
- export function frameSrc(origin, zoom = "resources") {
348
+ export function frameSrc(origin, zoom = "resources", repo = null, theme = null) {
340
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
341
352
  return `/?${q.toString()}`;
342
353
  }
343
354
 
@@ -399,11 +410,12 @@ export function kioskView(demo, { components = false, pick = null, now = Date.no
399
410
  }
400
411
 
401
412
  /** The line under the view saying how to read it, for the view shown. */
402
- export function legendWords(view) {
413
+ export function legendWords(view, tool = null) {
414
+ const root = rootWord(tool);
403
415
  if (view === "components") {
404
- 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.`;
405
417
  }
406
- 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.`;
407
419
  }
408
420
 
409
421
  /** The behold zoom for a view. */
@@ -475,40 +487,75 @@ const elRoots = (tag, cls, text, s) => {
475
487
  export function renderKiosk(bar, s, now = new Date(), foot = null) {
476
488
  bar.replaceChildren();
477
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, { graph: !!s.notRunGraph });
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
+ }
478
507
  if (!s.demo || !s.demo.demo) {
479
508
  const box = el("div", "k-empty");
480
- 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);
481
513
  box.appendChild(el("p", "k-muted", s.demo ? s.demo.error || s.demo.absent : "Reading demo.json…"));
482
514
  bar.appendChild(box);
483
515
  return;
484
516
  }
485
517
  const demo = s.demo.demo;
486
- const head = scenarioHead(demo);
518
+ const head = scenarioHead(demo, { vendor: s.vendor || null });
487
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));
488
523
  const intro = introText(demo);
489
- if (intro) bar.appendChild(el("p", "k-intro", intro));
490
524
 
491
- // 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.
492
526
  const left = el("div", "k-left");
527
+ const titleBox = box("title", "k-box-title");
493
528
  const title = el("h1", "k-title");
529
+ if (vendor) title.appendChild(brandEl(vendor));
494
530
  title.append(el("span", "k-kicker", head.kicker), el("span", "k-title-text", head.title));
495
- 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");
496
535
  const rows = aboutRows(demo);
497
- 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) {
498
542
  const card = el("dl", "k-about");
499
543
  for (const r of rows) card.append(el("dt", "k-about-label", r.label), elRoots("dd", "k-about-text", r.text, s));
500
- left.appendChild(card);
544
+ cardBox.appendChild(card);
501
545
  }
546
+ left.appendChild(cardBox);
502
547
  const said = el("p", `k-step k-step-${demo.step}`);
503
548
  said.append(el("span", "k-step-word", STEP_WORDS[demo.step] || demo.step), elRoots("span", "k-label", demo.label, s));
504
- left.appendChild(said);
549
+ const stepBox = box("step");
550
+ stepBox.appendChild(said);
551
+ left.appendChild(stepBox);
505
552
  bar.appendChild(left);
506
553
 
507
554
  const right = el("div", "k-right");
508
555
  const strip = el("ol", "k-strip");
509
556
  for (const stop of stripStops(demo.step)) strip.appendChild(el("li", `k-stop k-${stop.state}`, stop.words));
510
557
  const where = el("div", "k-where");
511
- where.append(strip, el("span", "k-cycle", cycleWords(demo, now)));
558
+ where.appendChild(strip);
512
559
  right.appendChild(where);
513
560
 
514
561
  const nx = nextWords(demo, now);
@@ -516,28 +563,120 @@ export function renderKiosk(bar, s, now = new Date(), foot = null) {
516
563
  next.appendChild(el("span", "k-next-word", "next, by the driver"));
517
564
  next.appendChild(elRoots("span", "k-action", nx.action, s));
518
565
  next.appendChild(el("span", "k-when", nx.when));
566
+ next.appendChild(el("span", "k-cycle", cycleWords(demo, now)));
519
567
  right.appendChild(next);
520
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");
521
571
  const appr = pendingApproval(demo, s.progress);
522
- if (appr) {
572
+ if (s.approval) {
573
+ holdBox.appendChild(approvalPanel(s.approval, s));
574
+ } else if (appr) {
523
575
  const a = el("div", "k-approve");
524
576
  a.appendChild(elRoots("p", "k-approve-gloss", approveGloss(appr), s));
525
577
  a.appendChild(el("code", "k-command", appr.approve));
526
578
  const then = el("p", "k-approve-then");
527
579
  then.append(`then comments `, el("code", "k-inline", appr.comment), ` on ${appr.on}, and the waves run again.`);
528
580
  a.appendChild(then);
529
- right.appendChild(a);
530
- } else {
581
+ holdBox.appendChild(a);
582
+ } else if (!s.noReports) {
531
583
  const panel = runPanel(demo, s.answer, s.progress);
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));
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));
534
586
  }
587
+ right.appendChild(holdBox);
535
588
  bar.appendChild(right);
536
589
 
537
590
  if (foot) {
538
- 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)));
539
593
  // behold#571: no line on where the narration was read from: a viewer new to the demo reads the facts.
540
- for (const f of factLines(demo, s.answer)) foot.appendChild(elRoots("span", "k-fact", f, s));
594
+ if (!s.noReports) for (const f of factLines(demo, s.answer)) foot.appendChild(elRoots("span", "k-fact", f, s));
595
+ }
596
+ }
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);
541
680
  }
542
681
  }
543
682
 
@@ -545,6 +684,22 @@ export function renderKiosk(bar, s, now = new Date(), foot = null) {
545
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;";
546
685
  export const HOVER_JOB_RULE = "background: color-mix(in srgb, var(--focus) 24%, transparent); box-shadow: inset 4px 0 0 var(--focus);";
547
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
+
548
703
  /** How often the kiosk asks for demo.json. The server shares one read between kiosks. */
549
704
  export const DEMO_POLL_MS = 3_000;
550
705
 
@@ -555,17 +710,53 @@ export const DEMO_POLL_MS = 3_000;
555
710
  * countdown. When the roots the step is about change, move the framed view
556
711
  * with a `behold:view` message; the frame takes them only from this origin.
557
712
  */
558
- export function startKiosk({ bar, frame, foot, side, main, views }, { fetchJson = defaultFetch, now = () => new Date(), origin = location.origin } = {}) {
713
+ export function startKiosk({ bar, frame, foot, side, main, views, tools = null }, { fetchJson = defaultFetch, now = () => new Date(), origin = location.origin, tool = null } = {}) {
559
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 = () => {
727
+ if (!vendor) return true;
728
+ // Before the tool's first cycle: its repo's checkout, when this behold serves it.
729
+ if (s.mode === "none") return !!notRunRepo(tool, s.viewRepos) && s.aimed === notRunRepo(tool, s.viewRepos);
730
+ return servesRepo(s.answer && !s.answer.error ? s.answer.project : null, s.repo);
731
+ };
732
+ // behold#571: a behold serving several repos (`serve --repos`) answers the
733
+ // graph's reads from the checkout of the repo the page is about, asked by
734
+ // name. `viewRepos` is what it serves, `aimed` the repo the reads are for.
735
+ s.viewRepos = null;
736
+ s.aimed = null;
737
+ const wantRepo = () => repoParam(s.repo, s.viewRepos) || (vendor && s.mode === "none" ? notRunRepo(tool, s.viewRepos) : null);
738
+ const fetchServed = fetchJson;
739
+ fetchJson = (url) => fetchServed(repoUrl(url, wantRepo()));
740
+ /** When the page moves to another served repo, read its project, reports and run view again, and point the frame at it. */
741
+ const followRepo = () => {
742
+ const want = wantRepo();
743
+ if (!want || want === s.aimed) return;
744
+ s.aimed = want;
745
+ void readProject();
746
+ void reports(true);
747
+ void progress(true);
748
+ };
560
749
  let lastKey = "";
561
750
  let lastFocus;
562
751
  const shownView = () => kioskView(s.demo && s.demo.demo, { components: s.components, pick: s.pick, now: now().getTime() });
563
752
  const tellFrame = (force) => {
564
- const d = s.demo && s.demo.demo;
565
- if (!d) return; // nothing said yet: the frame keeps its default until the narration is read
566
- const roots = focusRoots(d, s.answer, s.progress);
753
+ // behold#571: before the tool's first cycle the frame draws its repo on the base, with nothing in flight.
754
+ const notRun = !!vendor && s.mode === "none";
755
+ const d = notRun ? null : s.demo && s.demo.demo;
756
+ if (!d && !notRun) return; // nothing said yet: the frame keeps its default until the narration is read
757
+ const roots = d ? focusRoots(d, s.answer, s.progress) : null;
567
758
  const focus = roots ? roots.join(",") : null;
568
- const run = runPin(d);
759
+ const run = d ? runPin(d) : "base";
569
760
  const zoom = viewZoom(shownView().view);
570
761
  const key = `${focus}|${run}|${zoom}`;
571
762
  if (!force && key === lastFocus) return;
@@ -626,39 +817,235 @@ export function startKiosk({ bar, frame, foot, side, main, views }, { fetchJson
626
817
  glow();
627
818
  });
628
819
  }
820
+
821
+ // behold#571: the titles and names demo.json has carried, by scenario, for the runs' subtitles.
822
+ const titles = {};
823
+ const names = {};
824
+ /** Take one narration (demo.json, or a replayed step), and ask for the run it is about when the step moved. */
825
+ const setDemo = (answer) => {
826
+ s.demo = answer;
827
+ const d = answer && answer.demo;
828
+ if (d && d.scenario && typeof d.title === "string" && d.title) titles[d.scenario] = d.title;
829
+ if (d && d.scenario && scenarioName(d)) names[d.scenario] = scenarioName(d);
830
+ const key = d ? `${s.mode}|${d.cycle}|${d.step}|${d.commit}|${d.pull}` : "";
831
+ if (key !== lastKey) {
832
+ lastKey = key;
833
+ // The step moved: the run it is about may be newer than what behold read.
834
+ const stage = d ? demoStage(d) : "none";
835
+ if (stage === "pull-request" || stage === "either") void reports(true);
836
+ if (stage === "apply" || stage === "either") void progress(true);
837
+ }
838
+ };
839
+ // behold#571: the replay's clock, read on every draw.
840
+ const replayTick = () => {
841
+ if (s.mode !== "replay" || !s.replay) return;
842
+ const steps = s.replay.cycle.steps;
843
+ const r = replayAt(steps, now().getTime() - s.replay.start);
844
+ s.replay.at = r.at;
845
+ s.replay.banner = replayBanner(s.replay.cycle, liveEstimate(s.live && s.live.demo, s.replay.cycle, now()));
846
+ setDemo({ source: "replay", read: steps[r.index].at, demo: scrubDemo(steps[r.index], tool) });
847
+ };
848
+
629
849
  const draw = () => {
850
+ followRepo();
851
+ replayTick();
630
852
  s.light = typeof document !== "undefined" && document.documentElement ? isLightBg(getComputedStyle(document.documentElement).getPropertyValue("--bg")) : false;
631
853
  s.rootNames = rootList();
632
854
  const shown = shownView();
633
855
  s.view = shown.view;
634
- renderKiosk(bar, s, now(), foot);
635
- renderViewToggle(views, shown, { components: s.components, onPick: pickView });
856
+ const ok = frameOk();
857
+ s.noReports = !ok;
858
+ s.notRunGraph = !!vendor && s.mode === "none" && ok;
859
+ s.approval = approvalNow();
860
+ // behold#571: the header's boxes redraw every second; each keeps where a viewer scrolled it.
861
+ const scrolled = Object.fromEntries([...bar.querySelectorAll(".k-box")].map((b) => [b.dataset.box, b.scrollTop]));
862
+ renderKiosk(bar, s, shownNow(), foot);
863
+ for (const b of bar.querySelectorAll(".k-box")) {
864
+ if (scrolled[b.dataset.box]) b.scrollTop = scrolled[b.dataset.box];
865
+ fade(b);
866
+ b.addEventListener("scroll", () => fade(b), { passive: true });
867
+ }
868
+ whatText();
869
+ renderViewToggle(views, shown, { components: s.components && ok, onPick: pickView });
870
+ // behold#571: with no graph to show, the graph pane folds away and the runs and the log take its width.
871
+ if (typeof document !== "undefined" && document.body && document.body.classList) {
872
+ document.body.classList.toggle("k-nograph", !!vendor && !ok);
873
+ // Before the tool's first cycle there are no runs and no log: those panes fold away, and the graph (when there is one) takes the width.
874
+ document.body.classList.toggle("k-notyet", !!vendor && s.mode === "none");
875
+ }
636
876
  tellFrame(false);
637
877
  };
638
- // #569: the pipeline's runs beside the view, CI-page style.
639
- // behold#571: the titles and names demo.json has carried, by scenario, for the runs' subtitles.
640
- const titles = {};
641
- const names = {};
642
- if (side && main) startRuns({ side, main }, { fetchJson, now, scenarios: SCENARIOS, titles: () => titles, names: () => names, roots: rootList, waves: () => s.waves, light: () => s.light });
878
+
879
+ // 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.
880
+ const runDocs = new Map();
881
+ const runDoc = (id) => {
882
+ if (!runDocs.has(id)) runDocs.set(id, fetchJson(`/api/terragucci/runs/${id}`).then((a) => (a && a.doc ? a.doc : null)));
883
+ return runDocs.get(id);
884
+ };
885
+ const replayRuns = async () => {
886
+ const r = s.replay;
887
+ if (r.runs) return r.runs;
888
+ const idx = await fetchJson("/api/terragucci/runs");
889
+ const listed = new Map((idx && idx.doc && Array.isArray(idx.doc.runs) ? idx.doc.runs : []).map((x) => [x.id, x]));
890
+ const runs = [];
891
+ for (const id of r.cycle.runs) {
892
+ const e = listed.get(id) || (await runDoc(id));
893
+ if (e) runs.push(e);
894
+ }
895
+ r.runs = runs.filter((x) => keepRun(x, { tool, repo: r.cycle.repo }));
896
+ return r.runs;
897
+ };
898
+ const runsSource = () => {
899
+ if (!vendor || s.mode === "live") return null;
900
+ 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." }) };
901
+ return {
902
+ index: async () => replayIndex(await replayRuns(), shownNow().getTime()),
903
+ run: async (id) => {
904
+ const doc = await runDoc(id);
905
+ return doc ? { doc: runAsOf(doc, shownNow().getTime()) } : { doc: null, absent: `Run #${id} is no longer kept in the reports.` };
906
+ },
907
+ };
908
+ };
909
+ // behold#571: the approval panel's records: the cycle's runs, the gate ledger's history, and the lane.
910
+ const ap = { runs: new Map(), list: [], lifecycle: null, history: null, at: 0, ledgerAt: 0, busy: false };
911
+ const readApproval = async () => {
912
+ const d = s.demo && s.demo.demo;
913
+ if (!d || !PANEL_STEPS.includes(d.step) || ap.busy) return;
914
+ const t = now().getTime();
915
+ if (t - ap.at < APPROVAL_POLL_MS) return;
916
+ ap.busy = true;
917
+ ap.at = t;
918
+ try {
919
+ const src = runsSource();
920
+ const raw = src ? await src.index() : await fetchJson("/api/terragucci/runs");
921
+ const idx = vendor ? filterIndex(raw, { tool, repo: s.repo }) : raw;
922
+ const mine = cycleRuns(idx && idx.doc && Array.isArray(idx.doc.runs) ? idx.doc.runs : [], d);
923
+ for (const e of mine) {
924
+ const key = `${s.mode}|${e.status}|${e.ended_at}|${src ? Math.floor(shownNow().getTime() / 5000) : ""}`;
925
+ const had = ap.runs.get(e.id);
926
+ if (had && had.key === key) continue;
927
+ const r = src ? await src.run(e.id) : await fetchJson(`/api/terragucci/runs/${e.id}`);
928
+ if (r && r.doc) ap.runs.set(e.id, { key, doc: mergeRun(e, r.doc) });
929
+ }
930
+ ap.list = mine.map((e) => ap.runs.get(e.id)).filter(Boolean).map((x) => x.doc);
931
+ if (t - ap.ledgerAt >= LEDGER_POLL_MS) {
932
+ ap.ledgerAt = t;
933
+ const [lifecycle, history] = await Promise.all([fetchJson("/api/terragucci/lifecycle"), fetchJson("/api/lifecycle/history?limit=200")]);
934
+ ap.lifecycle = lifecycle && !lifecycle.error ? lifecycle : null;
935
+ ap.history = history && !history.error ? history : null;
936
+ }
937
+ } finally {
938
+ ap.busy = false;
939
+ }
940
+ draw();
941
+ };
942
+ const approvalNow = () => {
943
+ const d = s.demo && s.demo.demo;
944
+ if (!d || !PANEL_STEPS.includes(d.step)) return null;
945
+ const appr = pendingApproval(d, s.progress);
946
+ const driver = appr && s.mode !== "replay" ? { gloss: DRIVER_SIGNS, approve: appr.approve, comment: appr.comment, on: appr.on } : null;
947
+ return approvalModel({ demo: d, runs: ap.list, history: ap.history, lifecycle: ap.lifecycle, fallback: appr, now: shownNow().getTime(), driver });
948
+ };
949
+ // behold#571: a chip in the panel shows its log line in the runs pane (opened, on a phone).
950
+ let runsApi = null;
951
+ s.showLine = (line) => {
952
+ if (!runsApi) return;
953
+ if (tools && tools.runsToggle) setRunsOpen(true);
954
+ runsApi.show(line.run, line.job, line.step, line.n);
955
+ };
956
+ const setRunsOpen = (open) => {
957
+ document.body.classList.toggle("k-runs-open", open);
958
+ if (tools && tools.runsToggle) tools.runsToggle.setAttribute("aria-expanded", String(open));
959
+ };
960
+ if (tools && tools.runsToggle) tools.runsToggle.addEventListener("click", () => setRunsOpen(!document.body.classList.contains("k-runs-open")));
961
+
962
+ // behold#571: "What is this?" opens the intro over the page (no layout moves); a click elsewhere or Escape closes it.
963
+ const whatText = () => {
964
+ if (!tools || !tools.pop) return;
965
+ const d = s.demo && s.demo.demo;
966
+ const text = (d && introText(d)) || WHAT_FALLBACK;
967
+ const p = tools.pop.firstElementChild;
968
+ if (p && p.textContent === text) return;
969
+ tools.pop.replaceChildren(el("p", null, text));
970
+ };
971
+ if (tools && tools.what && tools.pop) {
972
+ const setWhat = (open) => {
973
+ tools.pop.hidden = !open;
974
+ tools.what.setAttribute("aria-expanded", String(open));
975
+ };
976
+ tools.what.addEventListener("click", (e) => {
977
+ e.stopPropagation();
978
+ whatText();
979
+ setWhat(tools.pop.hidden);
980
+ });
981
+ document.addEventListener("click", (e) => {
982
+ if (!tools.pop.hidden && !tools.pop.contains(e.target)) setWhat(false);
983
+ });
984
+ document.addEventListener("keydown", (e) => {
985
+ if (e.key === "Escape") setWhat(false);
986
+ });
987
+ }
988
+ // behold#571: the theme picker, persisted per viewer as the app does; the frame follows without a reload.
989
+ if (tools && tools.theme) mountThemePicker(tools.theme);
990
+ onThemeChange((th) => {
991
+ if (frame && frame.contentWindow) frame.contentWindow.postMessage({ type: "behold:theme", name: th.name }, origin);
992
+ draw();
993
+ });
994
+
995
+ if (side && main) {
996
+ runsApi = startRuns(
997
+ { side, main },
998
+ {
999
+ fetchJson,
1000
+ now,
1001
+ shown: shownNow,
1002
+ scenarios: SCENARIOS,
1003
+ titles: () => titles,
1004
+ names: () => names,
1005
+ roots: rootList,
1006
+ waves: () => s.waves,
1007
+ light: () => s.light,
1008
+ source: runsSource,
1009
+ filter: vendor ? (index) => filterIndex(index, { tool, repo: s.repo }) : (index) => index,
1010
+ replay: () => s.mode === "replay",
1011
+ hideLine: vendor ? (line) => namesOther(line, tool) : null,
1012
+ },
1013
+ );
1014
+ }
643
1015
  // #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.
1016
+ let frameSrcNow = null;
644
1017
  const openFrame = () => {
645
1018
  if (!frame) return;
646
- frame.src = frameSrc(origin, viewZoom(shownView().view));
647
- // The frame reads its graph after load; tell it again once it has had the time.
648
- frame.addEventListener("load", () => {
649
- for (const ms of [1500, 4000, 8000]) setTimeout(() => tellFrame(true), ms);
650
- });
1019
+ const src = frameSrc(origin, viewZoom(shownView().view), wantRepo(), getThemeName());
1020
+ if (src === frameSrcNow) return;
1021
+ if (frameSrcNow === null) {
1022
+ // The frame reads its graph after load; tell it again once it has had the time.
1023
+ frame.addEventListener("load", () => {
1024
+ for (const ms of [1500, 4000, 8000]) setTimeout(() => tellFrame(true), ms);
1025
+ });
1026
+ }
1027
+ frameSrcNow = src;
1028
+ frame.src = src;
651
1029
  };
652
- void fetchJson("/api/project").then((p) => {
1030
+ const readProject = () => fetchJson("/api/project").then((p) => {
1031
+ // behold#571: the repos this behold serves, and which one this answer is.
1032
+ if (p && !p.error && Array.isArray(p.viewRepos)) {
1033
+ s.viewRepos = p.viewRepos;
1034
+ if (s.aimed === null && p.viewRepo) s.aimed = p.viewRepo;
1035
+ }
653
1036
  s.components = !!(p && !p.error && p.terragucciComponents);
654
1037
  // behold#571: the declared roots (their colours' order) and the wave jobs that apply them.
655
1038
  if (p && p.terragucciRoots) {
656
1039
  s.roots = Array.isArray(p.terragucciRoots.roots) ? p.terragucciRoots.roots : [];
657
1040
  s.waves = Array.isArray(p.terragucciRoots.waves) ? p.terragucciRoots.waves : [];
1041
+ } else if (s.aimed) {
1042
+ s.roots = [];
1043
+ s.waves = [];
658
1044
  }
659
1045
  openFrame();
660
1046
  draw();
661
1047
  });
1048
+ void readProject();
662
1049
 
663
1050
  const reports = async (fresh) => {
664
1051
  s.answer = await fetchJson(`/api/terragucci${fresh ? "?fresh=1" : ""}`);
@@ -679,19 +1066,45 @@ export function startKiosk({ bar, frame, foot, side, main, views }, { fetchJson
679
1066
  draw();
680
1067
  };
681
1068
 
1069
+ // behold#571: a tool's latest cycle, read when its scenario stops being live, and while it has none yet.
1070
+ let cycleRead = 0;
1071
+ const readCycle = async () => {
1072
+ cycleRead = now().getTime();
1073
+ const a = await fetchJson(`/api/terragucci/cycles/${tool}`);
1074
+ if (s.mode === "live") return; // its scenario started while the read was out
1075
+ if (a && a.cycle) {
1076
+ if (!s.replay || s.replay.cycle.cycle !== a.cycle.cycle) s.replay = { cycle: a.cycle, start: now().getTime(), at: null, banner: null, runs: null };
1077
+ s.mode = "replay";
1078
+ s.repo = a.cycle.repo;
1079
+ } else if (a && a.absent) {
1080
+ s.mode = "none";
1081
+ s.replay = null;
1082
+ } else {
1083
+ s.mode = "error";
1084
+ s.replay = null;
1085
+ s.demo = { demo: null, error: a && a.error ? a.error : "The tool's latest cycle could not be read." };
1086
+ }
1087
+ };
1088
+ const CYCLE_REREAD_MS = 60_000;
1089
+
682
1090
  const demo = async () => {
683
- s.demo = await fetchJson("/api/terragucci/demo");
684
- const d = s.demo && s.demo.demo;
685
- if (d && d.scenario && typeof d.title === "string" && d.title) titles[d.scenario] = d.title;
686
- if (d && d.scenario && scenarioName(d)) names[d.scenario] = scenarioName(d);
687
- const key = d ? `${d.cycle}|${d.step}|${d.commit}|${d.pull}` : "";
688
- if (key !== lastKey) {
689
- lastKey = key;
690
- // The step moved: the run it is about may be newer than what behold read.
691
- const stage = d ? demoStage(d) : "none";
692
- if (stage === "pull-request" || stage === "either") void reports(true);
693
- if (stage === "apply" || stage === "either") void progress(true);
1091
+ const live = await fetchJson("/api/terragucci/demo");
1092
+ s.live = live;
1093
+ if (!vendor) {
1094
+ s.repo = live && live.demo ? live.demo.repo : null;
1095
+ setDemo(live);
1096
+ return draw();
1097
+ }
1098
+ const d = live && live.demo;
1099
+ if (liveFor(d, tool, SCENARIOS)) {
1100
+ s.mode = "live";
1101
+ s.replay = null;
1102
+ s.repo = d.repo || s.repo;
1103
+ setDemo({ ...live, demo: scrubDemo(d, tool) });
1104
+ return draw();
694
1105
  }
1106
+ // Not this tool's scenario: replay its latest cycle, read again when it went live and ended, or now and then.
1107
+ if (s.mode === "live" || s.mode === "loading" || now().getTime() - cycleRead > CYCLE_REREAD_MS) await readCycle();
695
1108
  draw();
696
1109
  };
697
1110
 
@@ -699,6 +1112,7 @@ export function startKiosk({ bar, frame, foot, side, main, views }, { fetchJson
699
1112
  void reports(false);
700
1113
  void progress(false);
701
1114
  setInterval(() => void demo(), DEMO_POLL_MS);
1115
+ setInterval(() => void readApproval(), 1000);
702
1116
  setInterval(draw, 1000);
703
1117
 
704
1118
  if (typeof EventSource !== "undefined") {