@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-runs.js CHANGED
@@ -10,7 +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";
13
+ import { rootColour, rootPaint, rootSegments, rootText, rootsOfWave, waveOfJob } from "./kiosk-roots.js";
14
+ import { TOUR, chipWords, docsUrl, jobMarks, runFeatures } from "./kiosk-annotations.js";
14
15
 
15
16
  /** The run, job and step statuses, as a viewer reads them. */
16
17
  export const STATUS_WORDS = {
@@ -184,7 +185,12 @@ function ownSteps(steps) {
184
185
 
185
186
  /** How many log lines a job's own steps wrote. */
186
187
  function ownLines(steps) {
187
- return ownSteps(steps).reduce((n, { s }) => n + (s.log ? s.log.length : 0), 0);
188
+ return ownSteps(steps).reduce((n, { s }) => n + saidLines(s.log), 0);
189
+ }
190
+
191
+ /** behold#571: a log's lines that are not set-up or an artifact's copy, the ones a viewer reads. */
192
+ function saidLines(log) {
193
+ return (log || []).filter((l) => !setupLine(l) && typeof l === "string" && l.trim()).length;
188
194
  }
189
195
 
190
196
  /**
@@ -200,8 +206,8 @@ export function defaultStep(steps) {
200
206
  const i = steps.findIndex((x) => x.status === s);
201
207
  if (i >= 0) return i;
202
208
  }
203
- const own = ownSteps(steps).filter(({ s }) => s.log && s.log.length);
204
- if (own.length) return own.reduce((a, b) => (b.s.log.length > a.s.log.length ? b : a)).i;
209
+ const own = ownSteps(steps).filter(({ s }) => saidLines(s.log) > 0);
210
+ if (own.length) return own.reduce((a, b) => (saidLines(b.s.log) > saidLines(a.s.log) ? b : a)).i;
205
211
  const any = steps.map((s, i) => ({ s, i })).filter(({ s }) => s.log && s.log.length);
206
212
  return any.length ? any[any.length - 1].i : 0;
207
213
  }
@@ -289,6 +295,39 @@ export const SETUP_LINES = [
289
295
  /^Deleting the contents of '/,
290
296
  /^Syncing repository: /,
291
297
  /^Working directory is '/,
298
+ // behold#571: an artifact's upload or download, as actions/upload-artifact and download-artifact print it.
299
+ /^With the provided path, there will be \d+ files? uploaded$/,
300
+ /^Starting artifact upload$/,
301
+ /^For more detailed logs during the artifact (?:upload|download) process, enable step-debugging: /,
302
+ /^Artifact name is valid!$/,
303
+ /^Container for artifact "[^"]*" successfully created\. Starting upload of file\(s\)$/,
304
+ /^Total size of all the files uploaded is \d+ bytes$/,
305
+ /^File upload process has finished\. Finalizing the artifact upload$/,
306
+ /^Artifact has been finalized\. All files have been successfully uploaded!$/,
307
+ /^The raw size of all the files that were specified for upload is \d+ bytes$/,
308
+ /^The size of all the files that were uploaded is \d+ bytes\. /,
309
+ /^Note: The size of downloaded zips can differ significantly from the reported size\./,
310
+ /^Artifact \S+ has been successfully uploaded!/,
311
+ /^Uploaded bytes \d+$/,
312
+ /^Beginning upload of artifact content to blob storage$/,
313
+ /^Finished uploading artifact content to blob storage!$/,
314
+ /^SHA256 (?:hash|digest) of (?:uploaded )?artifact (?:zip )?is /,
315
+ /^Finalizing artifact upload$/,
316
+ /^Artifact \S+ successfully finalized\. Artifact ID \d+$/,
317
+ /^Artifact download URL: /,
318
+ /^Starting download for \S+$/,
319
+ /^Directory structure has been setup for the artifact$/,
320
+ /^Total number of files that will be downloaded: \d+$/,
321
+ /^Artifact \S+ was downloaded to /,
322
+ /^Artifact download has finished successfully$/,
323
+ /^Downloading single artifact$/,
324
+ /^Preparing to download the following artifacts:$/,
325
+ /^- \S+ \(ID: \d+, Size: \d+/,
326
+ /^Redirecting to blob download url: /,
327
+ /^Starting download of artifact to: /,
328
+ /^Artifact download completed successfully\.$/,
329
+ /^Total of \d+ artifact\(s\) downloaded$/,
330
+ /^Download artifact has finished successfully$/,
292
331
  ];
293
332
 
294
333
  /** Whether a log line is set-up noise (SETUP_LINES). */
@@ -302,8 +341,22 @@ export function setupLine(line) {
302
341
  * opened (`open`), the row heads the whole log in order. Every line keeps
303
342
  * its number in the log. A log with no set-up lines is drawn as it came.
304
343
  */
305
- export function logRows(lines, { open = false, from = 0 } = {}) {
306
- const all = (lines || []).map((text, i) => ({ n: from + i + 1, text, setup: setupLine(text) }));
344
+ export function logRows(lines, { open = false, from = 0, hide = null } = {}) {
345
+ // behold#571: a tool's page leaves out a line naming another tool, and says how many it left out.
346
+ const every = (lines || []).map((text, i) => ({ n: from + i + 1, text, setup: setupLine(text) }));
347
+ // behold#571: a blank line after a set-up line (the upload's spacing) folds with it.
348
+ let prev = false;
349
+ for (const l of every) {
350
+ if (typeof l.text === "string" && !l.text.trim()) l.setup = prev;
351
+ else prev = l.setup;
352
+ }
353
+ const all = hide ? every.filter((l) => !hide(l.text)) : every;
354
+ const gone = every.length - all.length;
355
+ const rows = logRowsOf(all, open);
356
+ return gone ? [...rows, { hidden: gone }] : rows;
357
+ }
358
+
359
+ function logRowsOf(all, open) {
307
360
  const n = all.filter((l) => l.setup).length;
308
361
  if (!n) return all.map(({ n: num, text }) => ({ n: num, text }));
309
362
  const head = { setup: n, open };
@@ -355,11 +408,12 @@ export function statusIcon(status) {
355
408
  }
356
409
 
357
410
  /** The sidebar: the live run pinned, then the recent ones. `pick(id)` selects one; nothing here writes. */
358
- export function renderSidebar(host, index, selectedId, { now = new Date(), scenarios = {}, titles = {}, names = {}, pick = () => {}, merged = null } = {}) {
411
+ export function renderSidebar(host, index, selectedId, { now = new Date(), scenarios = {}, titles = {}, names = {}, pick = () => {}, merged = null, replay = false } = {}) {
359
412
  host.replaceChildren();
360
413
  const head = el("div", "r-side-head");
361
414
  head.appendChild(el("div", "r-side-title", "Pipeline runs"));
362
- head.appendChild(el("div", "r-side-note", "Real CI runs of the demo repo, copied from its forge as they go. Newest first."));
415
+ // behold#571: a tool's page replays an earlier cycle's runs, and says so.
416
+ head.appendChild(el("div", "r-side-note", replay ? "Real CI runs of the replayed cycle, as they stood at this point in it. Newest first." : "Real CI runs of the demo repo, copied from its forge as they go. Newest first."));
363
417
  host.appendChild(head);
364
418
  if (!index || index.error || index.absent || !index.doc) {
365
419
  host.appendChild(el("p", "r-note", index ? index.absent || index.error : "Reading the runs…"));
@@ -367,11 +421,15 @@ export function renderSidebar(host, index, selectedId, { now = new Date(), scena
367
421
  }
368
422
  // The selected run as its page draws it, when its own document is newer than the index.
369
423
  const runs = sidebarRuns(index.doc.runs).map((r) => (merged && merged.id === r.id ? merged : r));
424
+ if (!runs.length) {
425
+ host.appendChild(el("p", "r-note", "No runs to show yet."));
426
+ return;
427
+ }
370
428
  const live = runs[0];
371
429
  const named = scenarioTitles(index.doc.runs, titles);
372
430
  const list = el("ol", "r-runs");
373
431
  runs.forEach((r, i) => {
374
- if (i === 0) list.appendChild(el("li", "r-group", LIVE.has(live.status) ? "Live" : "Latest"));
432
+ if (i === 0) list.appendChild(el("li", "r-group", replay ? (LIVE.has(live.status) ? "Running in the replay" : "Latest in the replay") : LIVE.has(live.status) ? "Live" : "Latest"));
375
433
  if (i === 1) list.appendChild(el("li", "r-group", "Recent"));
376
434
  const li = el("li", `r-run${r.id === selectedId ? " r-selected" : ""}`);
377
435
  li.tabIndex = 0;
@@ -407,7 +465,7 @@ export function renderSidebar(host, index, selectedId, { now = new Date(), scena
407
465
  * null for the defaults. Returns what it drew, so the caller can keep a log's
408
466
  * scroll.
409
467
  */
410
- export function renderRun(host, answer, ui, { now = new Date(), pickJob = () => {}, toggleStep = () => {}, toggleSetup = () => {}, entry = null, roots = [], waves = [], light = false } = {}) {
468
+ export function renderRun(host, answer, ui, { now = new Date(), pickJob = () => {}, toggleStep = () => {}, toggleSetup = () => {}, jump = () => {}, toggleFeats = () => {}, featsOpen = false, note = null, flash = null, entry = null, roots = [], waves = [], light = false, hideLine = null } = {}) {
411
469
  // behold#571: a root's name with its colour's dot, where the line names one.
412
470
  const textOf = (cls, text) => {
413
471
  if (!roots.length || !rootSegments(text, roots).some((x) => x.root)) return el("span", cls, text);
@@ -458,7 +516,7 @@ export function renderRun(host, answer, ui, { now = new Date(), pickJob = () =>
458
516
  for (const r of applies) {
459
517
  const name = el("span", "k-root", r);
460
518
  name.dataset.kRoot = r;
461
- name.style.setProperty("--k-root", rootColour(r, roots, { light }));
519
+ name.style.setProperty("--k-root", rootPaint(r, roots, { light }));
462
520
  box.appendChild(name);
463
521
  }
464
522
  li.appendChild(box);
@@ -471,6 +529,31 @@ export function renderRun(host, answer, ui, { now = new Date(), pickJob = () =>
471
529
  if (skipped.length) jobsBox.appendChild(el("li", "r-job r-job-skipped", `${skipped.length} skipped, not for this event: ${skipped.map((j) => j.name).join(", ")}`));
472
530
  host.appendChild(jobsBox);
473
531
 
532
+ // behold#571: the features this run's logs show, by tool, each jumping to its first line.
533
+ // One wrapping list (a tool's name, then its features); folded, it shows whole lines only (fitFeatures).
534
+ const feats = runFeatures(shown, { hide: hideLine });
535
+ if (feats.length) {
536
+ const box = el("div", `r-feats${featsOpen ? " r-feats-open" : ""}`);
537
+ box.appendChild(el("div", "r-label r-feats-label", "Features in this run"));
538
+ const list = el("div", "r-feats-list");
539
+ for (const g of feats) {
540
+ list.appendChild(el("span", `r-feats-product r-ann-${g.product}`, g.product));
541
+ for (const f of g.features) {
542
+ const b = el("button", "r-feat", f.feature);
543
+ b.type = "button";
544
+ b.title = `${f.a.note} (${f.job}, line ${f.n})`;
545
+ b.addEventListener("click", () => jump(f.job, f.step, f.n));
546
+ list.appendChild(b);
547
+ }
548
+ }
549
+ const more = el("button", "r-feats-more", featsOpen ? "Show fewer" : "");
550
+ more.type = "button";
551
+ more.addEventListener("click", () => toggleFeats());
552
+ list.appendChild(more);
553
+ box.appendChild(list);
554
+ host.appendChild(box);
555
+ }
556
+
474
557
  if (!job) return { log: null, steps: null, key: null };
475
558
  host.appendChild(el("div", "r-label", "Steps of the job, with its log"));
476
559
  const stepsBox = el("div", "r-steps");
@@ -489,6 +572,7 @@ export function renderRun(host, answer, ui, { now = new Date(), pickJob = () =>
489
572
  }
490
573
  const open = ui.open !== null && ui.open !== undefined && ui.job === job.name ? ui.open : defaultStep(steps);
491
574
  let logEl = null;
575
+ const marks = jobMarks(steps, { hide: hideLine });
492
576
  steps.forEach((s, i) => {
493
577
  const row = el("div", `r-step${i === open ? " r-open" : ""}`);
494
578
  const line = el("div", "r-step-line");
@@ -499,9 +583,13 @@ export function renderRun(host, answer, ui, { now = new Date(), pickJob = () =>
499
583
  const pre = el("pre", "r-log");
500
584
  if (s.truncated) pre.appendChild(el("div", "r-log-cut", `… ${s.truncated} earlier lines not kept`));
501
585
  const key = logKey(run.id, job.name, i);
502
- for (const row of logRows(s.log, { open: ui.setup === key, from: s.truncated || 0 })) {
586
+ for (const row of logRows(s.log, { open: ui.setup === key, from: s.truncated || 0, hide: hideLine })) {
587
+ if (row.hidden) {
588
+ pre.appendChild(el("div", "r-log-cut", `${row.hidden} line${row.hidden === 1 ? "" : "s"} of this log left out on this page`));
589
+ continue;
590
+ }
503
591
  if (row.setup) {
504
- const fold = el("div", `r-log-setup${row.open ? " r-log-setup-open" : ""}`, `${row.open ? "▾" : "▸"} setup (${row.setup} line${row.setup === 1 ? "" : "s"})`);
592
+ const fold = el("div", `r-log-setup${row.open ? " r-log-setup-open" : ""}`, `${row.open ? "▾" : "▸"} runner set-up and file copies (${row.setup} line${row.setup === 1 ? "" : "s"})`);
505
593
  fold.setAttribute("role", "button");
506
594
  fold.tabIndex = 0;
507
595
  fold.setAttribute("aria-expanded", String(row.open));
@@ -512,9 +600,20 @@ export function renderRun(host, answer, ui, { now = new Date(), pickJob = () =>
512
600
  pre.appendChild(fold);
513
601
  continue;
514
602
  }
515
- const ln = el("div", `r-log-line${row.quiet ? " r-log-quiet" : ""}`);
603
+ const lineKey = `${key}|${row.n}`;
604
+ const ln = el("div", `r-log-line${row.quiet ? " r-log-quiet" : ""}${flash === lineKey ? " r-flash" : ""}`);
605
+ ln.dataset.n = String(row.n);
516
606
  const shown = logLineWords(job.status, row.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));
607
+ const text = 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);
608
+ ln.append(el("span", "r-ln", String(row.n)), text);
609
+ // behold#571: the line's feature, as a chip on its first line in the job and a dot after.
610
+ const mark = marks.get(`${i}:${row.n}`);
611
+ if (mark) {
612
+ ln.classList.add("r-ann-line");
613
+ ln.dataset.annKey = lineKey;
614
+ text.append(" ", annMark(mark.a, { first: mark.first, tour: mark.first && TOUR.includes(mark.a.id) }));
615
+ if (note && note.key === lineKey) ln.appendChild(annNote(mark.a, note));
616
+ }
518
617
  pre.appendChild(ln);
519
618
  }
520
619
  if (!s.log.length) pre.appendChild(el("div", "r-log-cut", s.status === "running" ? "waiting for output…" : "no output"));
@@ -528,6 +627,73 @@ export function renderRun(host, answer, ui, { now = new Date(), pickJob = () =>
528
627
  return { log: logEl, steps: stepsBox, key: logEl ? logKey(run.id, job.name, open) : null, live: LIVE.has(job.status) };
529
628
  }
530
629
 
630
+ /** behold#571: a line's feature mark: an outlined chip (the tool and the feature), or a faint dot on a later line of it. */
631
+ export function annMark(a, { first = true, tour = false } = {}) {
632
+ const b = el("button", first ? `r-ann r-ann-${a.product}${tour ? " r-ann-tour" : ""}` : `r-ann-dot r-ann-${a.product}`, first ? chipWords(a) : "");
633
+ b.type = "button";
634
+ b.dataset.ann = a.id;
635
+ b.setAttribute("aria-label", `${chipWords(a)}: ${a.note}`);
636
+ if (!first) b.title = chipWords(a);
637
+ return b;
638
+ }
639
+
640
+ /** behold#571: a feature's note, opened under its line: the sentence, and the docs in a new tab. */
641
+ export function annNote(a, { auto = false, pinned = false } = {}) {
642
+ const box = el("div", `r-ann-note${auto ? " r-ann-auto" : ""}${pinned ? " r-ann-pinned" : ""}`);
643
+ box.setAttribute("role", "note");
644
+ box.appendChild(el("div", `r-ann-note-head r-ann-${a.product}`, chipWords(a)));
645
+ box.appendChild(el("p", "r-ann-note-text", a.note));
646
+ const link = el("a", "r-ann-more", "Read more");
647
+ link.href = docsUrl(a);
648
+ link.target = "_blank";
649
+ link.rel = "noopener";
650
+ box.appendChild(link);
651
+ return box;
652
+ }
653
+
654
+ /** behold#571: how many whole lines the folded features strip shows. */
655
+ export const FEATURE_LINES = 3;
656
+
657
+ /**
658
+ * behold#571: fold the features strip to FEATURE_LINES whole lines: what
659
+ * would wrap past them is hidden, never cut through, and a "+N more" button
660
+ * on the last line opens the rest. A tool's name left last on a line with
661
+ * none of its features after it goes too. Measures the drawn list, so it runs
662
+ * after layout and again whenever the list's width or its features change.
663
+ * The button shows only when it hides at least one feature (never "+0 more"),
664
+ * and opened, only when there is something to fold back.
665
+ */
666
+ export function fitFeatures(list, open, lines = FEATURE_LINES) {
667
+ if (!list) return;
668
+ const more = list.querySelector(".r-feats-more");
669
+ const items = [...list.children].filter((c) => c !== more);
670
+ for (const i of items) i.hidden = false;
671
+ more.hidden = true;
672
+ const tops = [...new Set(items.map((i) => i.offsetTop))].sort((a, b) => a - b);
673
+ if (tops.length <= lines) return;
674
+ const last = tops[lines - 1];
675
+ if (open) {
676
+ // Folded, it would hide a feature: offer to fold it back.
677
+ more.hidden = !items.some((i) => i.offsetTop > last && i.classList.contains("r-feat"));
678
+ return;
679
+ }
680
+ const shown = items.filter((i) => i.offsetTop <= last);
681
+ for (const i of items) if (i.offsetTop > last) i.hidden = true;
682
+ const count = () => items.filter((i) => i.hidden && i.classList.contains("r-feat")).length;
683
+ if (count() > 0) {
684
+ more.hidden = false;
685
+ more.textContent = `+${count()} more`;
686
+ // The button sits on the last line: hide features from its end until it fits there.
687
+ while (more.offsetTop > last && shown.length) {
688
+ shown.pop().hidden = true;
689
+ more.textContent = `+${count()} more`;
690
+ }
691
+ }
692
+ // A tool's name with none of its features shown after it.
693
+ const vis = items.filter((i) => !i.hidden);
694
+ if (vis.length && vis[vis.length - 1].classList.contains("r-feats-product")) vis[vis.length - 1].hidden = true;
695
+ }
696
+
531
697
  /** Which log is open: the run, the job and the step. A new key means a different log, which the pane follows from its end. */
532
698
  export function logKey(runId, job, step) {
533
699
  return `${runId}|${job}|${step}`;
@@ -560,8 +726,30 @@ export const PICK_HOLD_MS = 90_000;
560
726
  * is live), and redraw. The live run is selected until a viewer picks
561
727
  * another, and again {@link PICK_HOLD_MS} after their last pick.
562
728
  */
563
- export function startRuns({ side, main }, { fetchJson, now = () => new Date(), scenarios = {}, titles = () => ({}), names = () => ({}), onLive = () => {}, roots = () => [], waves = () => [], light = () => false } = {}) {
564
- const s = { index: null, run: null, selected: null, picked: 0, ui: { job: null, open: null, setup: null } };
729
+ export function startRuns(
730
+ { side, main },
731
+ {
732
+ fetchJson,
733
+ now = () => new Date(),
734
+ shown = now,
735
+ scenarios = {},
736
+ titles = () => ({}),
737
+ names = () => ({}),
738
+ onLive = () => {},
739
+ roots = () => [],
740
+ waves = () => [],
741
+ light = () => false,
742
+ source = () => null,
743
+ filter = (index) => index,
744
+ replay = () => false,
745
+ hideLine = null,
746
+ } = {},
747
+ ) {
748
+ const s = { index: null, run: null, selected: null, picked: 0, ui: { job: null, open: null, setup: null }, note: null, flash: null, scrollTo: null, pending: null, featsOpen: false };
749
+ // behold#571: the notes a kiosk with nobody at it has opened by itself, and when it last had a pointer.
750
+ const toured = new Set();
751
+ let lastPointer = -Infinity;
752
+ let tourRest = 0;
565
753
  let follow = true;
566
754
  let lastRunKey = "";
567
755
  // The open log and whether the pane follows its end; a viewer who scrolls up stops it until they come back.
@@ -581,20 +769,52 @@ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), s
581
769
  const id = selectedId();
582
770
  const entry = s.index && s.index.doc ? s.index.doc.runs.find((r) => r.id === id) || null : null;
583
771
  const ans = s.run && (!s.run.doc || s.run.doc.id === id) ? s.run : null;
772
+ // behold#571: a line asked for from elsewhere (the approval panel), once its run is read.
773
+ if (s.pending && ans && ans.doc && ans.doc.id === s.pending.run) {
774
+ const p = s.pending;
775
+ s.pending = null;
776
+ s.ui = { job: p.job, open: p.step, setup: null };
777
+ const k = `${logKey(p.run, p.job, p.step)}|${p.n}`;
778
+ s.scrollTo = k;
779
+ s.flash = { key: k, until: now().getTime() + FLASH_MS };
780
+ s.note = p.note === false ? s.note : { key: k, pinned: true, auto: false };
781
+ }
782
+ const t = now().getTime();
783
+ if (s.flash && t > s.flash.until) s.flash = null;
784
+ if (s.note && s.note.auto && t > s.note.until) {
785
+ s.note = null;
786
+ tourRest = t + TOUR_REST_MS;
787
+ }
584
788
  // One merged read of the selected run for both the sidebar and its page.
585
789
  const merged = ans && ans.doc ? mergeRun(entry, ans.doc) : null;
586
- renderSidebar(side, s.index, id, { now: now(), scenarios, titles: titles(), names: names(), pick, merged });
790
+ renderSidebar(side, s.index, id, { now: shown(), scenarios, titles: titles(), names: names(), pick, merged, replay: replay() });
587
791
  // The redraw replaces the scrollers: keep where a viewer left them.
588
792
  const old = main.querySelector(".r-log");
589
793
  const oldSteps = main.querySelector(".r-steps");
590
794
  const prevTop = old ? old.scrollTop : 0;
591
795
  const prevStepsTop = oldSteps ? oldSteps.scrollTop : 0;
592
796
  const drawn = renderRun(main, ans, s.ui, {
593
- now: now(),
797
+ now: shown(),
594
798
  entry,
595
799
  roots: roots(),
596
800
  waves: waves(),
597
801
  light: light(),
802
+ hideLine,
803
+ note: s.note,
804
+ flash: s.flash ? s.flash.key : null,
805
+ featsOpen: s.featsOpen,
806
+ toggleFeats: () => {
807
+ s.featsOpen = !s.featsOpen;
808
+ hold();
809
+ draw();
810
+ },
811
+ jump: (job, step, n) => {
812
+ const runId = ans && ans.doc ? ans.doc.id : null;
813
+ if (runId === null) return;
814
+ s.pending = { run: runId, job, step, n };
815
+ hold();
816
+ draw();
817
+ },
598
818
  pickJob: (name) => {
599
819
  s.ui = { job: name, open: null, setup: null };
600
820
  hold();
@@ -611,6 +831,7 @@ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), s
611
831
  draw();
612
832
  },
613
833
  });
834
+ fitFeatures(main.querySelector(".r-feats-list"), s.featsOpen);
614
835
  logFollow = followsLog(logShown, drawn.key, logFollow);
615
836
  const sameLog = drawn.key !== null && drawn.key === logShown;
616
837
  logShown = drawn.key;
@@ -620,7 +841,17 @@ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), s
620
841
  }
621
842
  const log = drawn.log;
622
843
  const steps = drawn.steps;
623
- if (logFollow) {
844
+ const target = s.scrollTo && s.scrollTo.startsWith(`${drawn.key}|`) ? log.querySelector(`.r-log-line[data-n="${s.scrollTo.slice(drawn.key.length + 1)}"]`) : null;
845
+ if (target) {
846
+ // A line asked for: in the middle of the log's view, the log in the steps' view; the pane stops following.
847
+ s.scrollTo = null;
848
+ logFollow = false;
849
+ log.scrollTop = Math.max(0, target.offsetTop - log.clientHeight / 3);
850
+ steps.scrollTop = 0;
851
+ const below = log.getBoundingClientRect().bottom - steps.getBoundingClientRect().bottom;
852
+ if (below > 0) steps.scrollTop += below + 8;
853
+ if (typeof target.scrollIntoView === "function" && main.closest && main.closest(".k-runs-open")) target.scrollIntoView({ block: "center" });
854
+ } else if (logFollow) {
624
855
  log.scrollTop = log.scrollHeight;
625
856
  // The steps list scrolls too: bring the log's end into its view.
626
857
  steps.scrollTop = sameLog ? prevStepsTop : 0;
@@ -639,7 +870,83 @@ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), s
639
870
  };
640
871
  log.addEventListener("scroll", onScroll, { passive: true });
641
872
  steps.addEventListener("scroll", onScroll, { passive: true });
873
+ tour(log);
642
874
  };
875
+ /**
876
+ * behold#571: with nobody at the screen (no pointer or touch for a while,
877
+ * and not a touch screen), the tour's chips open their notes one at a
878
+ * time, each for a few seconds, as their line scrolls into the log's view.
879
+ */
880
+ const tour = (log) => {
881
+ const t = now().getTime();
882
+ if (s.note || t < tourRest || t - lastPointer < IDLE_MS || !tourable()) return;
883
+ const view = log.getBoundingClientRect();
884
+ if (!view.height) return;
885
+ for (const chip of log.querySelectorAll(".r-ann-tour")) {
886
+ const line = chip.closest(".r-ann-line");
887
+ const key = line && line.dataset.annKey;
888
+ if (!key || toured.has(key)) continue;
889
+ const r = line.getBoundingClientRect();
890
+ if (r.top < view.top || r.bottom > view.bottom - view.height / 4) continue;
891
+ toured.add(key);
892
+ s.note = { key, pinned: false, auto: true, until: t + TOUR_NOTE_MS };
893
+ draw();
894
+ return;
895
+ }
896
+ };
897
+ // behold#571: pointing at a chip or a dot opens its note, a click or a tap keeps it open; a click elsewhere closes it.
898
+ const annKeyAt = (target) => {
899
+ const mark = target && target.closest ? target.closest(".r-ann, .r-ann-dot, .r-ann-note") : null;
900
+ const line = mark && mark.closest(".r-ann-line");
901
+ return line ? line.dataset.annKey : null;
902
+ };
903
+ // behold#571: the features strip folds by measuring its drawn lines, so it measures again
904
+ // when the panel's width changes (a resize, the panel opening) and once the fonts load.
905
+ const refitFeatures = () => fitFeatures(main.querySelector && main.querySelector(".r-feats-list"), s.featsOpen);
906
+ if (typeof ResizeObserver !== "undefined" && typeof Element !== "undefined" && main instanceof Element) {
907
+ let width = -1;
908
+ new ResizeObserver((entries) => {
909
+ const w = Math.round(entries[entries.length - 1].contentRect.width);
910
+ if (w === width) return;
911
+ width = w;
912
+ refitFeatures();
913
+ }).observe(main);
914
+ }
915
+ if (typeof document !== "undefined" && document.fonts && document.fonts.ready) document.fonts.ready.then(refitFeatures, () => {});
916
+ if (main.addEventListener) {
917
+ const seen =() => (lastPointer = now().getTime());
918
+ for (const ev of ["pointermove", "pointerdown", "touchstart", "wheel", "keydown"]) document.addEventListener(ev, seen, { passive: true });
919
+ main.addEventListener("pointerover", (e) => {
920
+ if (e.pointerType === "touch") return;
921
+ const key = annKeyAt(e.target);
922
+ if (s.note && (s.note.pinned || s.note.key === key)) return;
923
+ if (!key && !s.note) return;
924
+ s.note = key ? { key, pinned: false, auto: false } : null;
925
+ draw();
926
+ });
927
+ document.addEventListener("click", (e) => {
928
+ // The approval panel's chips and the features strip show their line, which opens its note.
929
+ if (e.target && e.target.closest && e.target.closest("[data-ann-jump], .r-feat")) return;
930
+ const key = annKeyAt(e.target);
931
+ const onMark = e.target && e.target.closest && e.target.closest(".r-ann, .r-ann-dot");
932
+ if (onMark && key) {
933
+ s.note = s.note && s.note.key === key && s.note.pinned ? null : { key, pinned: true, auto: false };
934
+ hold();
935
+ draw();
936
+ return;
937
+ }
938
+ if (s.note && !key) {
939
+ s.note = null;
940
+ draw();
941
+ }
942
+ });
943
+ document.addEventListener("keydown", (e) => {
944
+ if (e.key === "Escape" && s.note) {
945
+ s.note = null;
946
+ draw();
947
+ }
948
+ });
949
+ }
643
950
  const hold = () => {
644
951
  follow = false;
645
952
  s.selected = selectedId() ?? s.selected;
@@ -653,7 +960,9 @@ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), s
653
960
  void readRun();
654
961
  };
655
962
  const readIndex = async () => {
656
- s.index = await fetchJson("/api/terragucci/runs");
963
+ // behold#571: a tool's page reads its replay's runs from `source`, and keeps its own repo's alone.
964
+ const src = source();
965
+ s.index = filter(src ? await src.index() : await fetchJson("/api/terragucci/runs"));
657
966
  const runs = s.index && s.index.doc ? s.index.doc.runs : [];
658
967
  const live = liveRun(runs);
659
968
  onLive(live);
@@ -661,18 +970,44 @@ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), s
661
970
  };
662
971
  const readRun = async () => {
663
972
  const id = selectedId();
664
- if (id === null) return draw();
973
+ if (id === null) {
974
+ if (s.index && s.index.doc) s.run = { doc: null, absent: "No run to show yet." };
975
+ return draw();
976
+ }
665
977
  const entry = s.index && s.index.doc ? s.index.doc.runs.find((r) => r.id === id) : null;
666
978
  // A run that ended reads once; one still going reads on every tick.
667
- const key = `${id}|${entry ? entry.status : ""}|${entry ? entry.ended_at : ""}`;
979
+ const src = source();
980
+ const key = `${src ? "replay" : "live"}|${id}|${entry ? entry.status : ""}|${entry ? entry.ended_at : ""}`;
668
981
  if (key === lastRunKey && entry && !LIVE.has(entry.status) && s.run && s.run.doc && s.run.doc.id === id) return draw();
669
- if (s.run && s.run.doc && s.run.doc.id !== id) s.ui = { job: null, open: null, setup: null };
982
+ if (s.run && s.run.doc && s.run.doc.id !== id && !s.pending) s.ui = { job: null, open: null, setup: null };
670
983
  lastRunKey = key;
671
- s.run = await fetchJson(`/api/terragucci/runs/${id}`);
984
+ s.run = src ? await src.run(id) : await fetchJson(`/api/terragucci/runs/${id}`);
672
985
  draw();
673
986
  };
674
987
  void readIndex();
675
988
  setInterval(() => void readIndex(), 3_000);
676
989
  setInterval(draw, 1_000);
990
+ /** behold#571: show a run's log line from elsewhere (the approval panel's chips): its job, its step open, the line in view and lit, its note open. */
991
+ s.show = (run, job, step, n) => {
992
+ s.selected = run;
993
+ follow = false;
994
+ s.picked = now().getTime();
995
+ s.pending = { run, job, step, n };
996
+ if (s.run && s.run.doc && s.run.doc.id === run) draw();
997
+ else void readRun();
998
+ };
677
999
  return s;
678
1000
  }
1001
+
1002
+ /** How long a line asked for stays lit. */
1003
+ export const FLASH_MS = 2_500;
1004
+ /** How long a tour's note stays open, and the rest before the next. */
1005
+ export const TOUR_NOTE_MS = 6_000;
1006
+ export const TOUR_REST_MS = 2_000;
1007
+ /** How long without a pointer or a touch before the kiosk counts as unattended. */
1008
+ export const IDLE_MS = 45_000;
1009
+
1010
+ /** Whether this screen takes a tour: not a touch screen, where a finger opens the notes. */
1011
+ function tourable() {
1012
+ return !(typeof matchMedia === "function" && matchMedia("(pointer: coarse)").matches);
1013
+ }