@intentius/behold 0.29.8 → 0.29.10

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/dist/cli.js CHANGED
@@ -21775,6 +21775,7 @@ function watchSource(projectDir, onChange, debounceMs = 200) {
21775
21775
  clearTimeout(timer);
21776
21776
  timer = setTimeout(onChange, debounceMs);
21777
21777
  });
21778
+ watcher.on("error", (e) => console.warn(`behold: watching ${dir}: ${e.message}`));
21778
21779
  return () => {
21779
21780
  clearTimeout(timer);
21780
21781
  watcher.close();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/behold",
3
- "version": "0.29.8",
3
+ "version": "0.29.10",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "description": "behold — a live control plane on chant. See your whole estate (every substrate in one graph), coloured by drift; act through delegated, gated Ops.",
package/web/kiosk-runs.js CHANGED
@@ -658,30 +658,36 @@ export const FEATURE_LINES = 3;
658
658
  * behold#571: fold the features strip to FEATURE_LINES whole lines: what
659
659
  * would wrap past them is hidden, never cut through, and a "+N more" button
660
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.
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.
662
665
  */
663
666
  export function fitFeatures(list, open, lines = FEATURE_LINES) {
664
667
  if (!list) return;
665
668
  const more = list.querySelector(".r-feats-more");
666
669
  const items = [...list.children].filter((c) => c !== more);
667
670
  for (const i of items) i.hidden = false;
668
- if (open) {
669
- more.hidden = false;
670
- return;
671
- }
672
671
  more.hidden = true;
673
672
  const tops = [...new Set(items.map((i) => i.offsetTop))].sort((a, b) => a - b);
674
673
  if (tops.length <= lines) return;
675
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
+ }
676
680
  const shown = items.filter((i) => i.offsetTop <= last);
677
681
  for (const i of items) if (i.offsetTop > last) i.hidden = true;
678
- more.hidden = false;
679
682
  const count = () => items.filter((i) => i.hidden && i.classList.contains("r-feat")).length;
680
- more.textContent = `+${count()} more`;
681
- // The button sits on the last line: hide features from its end until it fits there.
682
- while (more.offsetTop > last && shown.length) {
683
- shown.pop().hidden = true;
683
+ if (count() > 0) {
684
+ more.hidden = false;
684
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
+ }
685
691
  }
686
692
  // A tool's name with none of its features shown after it.
687
693
  const vis = items.filter((i) => !i.hidden);
@@ -894,8 +900,21 @@ export function startRuns(
894
900
  const line = mark && mark.closest(".r-ann-line");
895
901
  return line ? line.dataset.annKey : null;
896
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, () => {});
897
916
  if (main.addEventListener) {
898
- const seen = () => (lastPointer = now().getTime());
917
+ const seen =() => (lastPointer = now().getTime());
899
918
  for (const ev of ["pointermove", "pointerdown", "touchstart", "wheel", "keydown"]) document.addEventListener(ev, seen, { passive: true });
900
919
  main.addEventListener("pointerover", (e) => {
901
920
  if (e.pointerType === "touch") return;
@@ -252,6 +252,33 @@ describe("the features strip, folded to whole lines (behold#571)", () => {
252
252
  expect(open.more.hidden).toBe(false);
253
253
  });
254
254
 
255
+ it("never shows \"+0 more\": past the last line only a tool's name, so no button", () => {
256
+ const items = [item("r-feats-product", 0), item("r-feat", 0), item("r-feat", 20), item("r-feat", 40), item("r-feats-product", 60)];
257
+ const l = list(items, 40);
258
+ fitFeatures(l.el, false);
259
+ expect(items.map((i) => i.hidden)).toEqual([false, false, false, false, true]);
260
+ expect(l.more.hidden).toBe(true);
261
+ expect(l.more.textContent).not.toBe("+0 more");
262
+ });
263
+
264
+ it("shows \"+N more\" with N the features hidden, and no button once a wider list fits them all", () => {
265
+ const items = [item("r-feat", 0), item("r-feat", 20), item("r-feat", 40), item("r-feat", 60), item("r-feat", 60), item("r-feat", 80)];
266
+ const l = list(items, 40);
267
+ fitFeatures(l.el, false);
268
+ expect(l.more).toMatchObject({ hidden: false, textContent: "+3 more" });
269
+ // Measured again after the list widened: everything on three lines.
270
+ for (const [i, top] of [0, 0, 20, 20, 40, 40].entries()) items[i].offsetTop = top;
271
+ fitFeatures(l.el, false);
272
+ expect(items.every((i) => !i.hidden)).toBe(true);
273
+ expect(l.more.hidden).toBe(true);
274
+ });
275
+
276
+ it("opened, offers to fold back only when folding would hide a feature", () => {
277
+ const open = list([item("r-feat", 0), item("r-feat", 20), item("r-feat", 40), item("r-feats-product", 60)], 60);
278
+ fitFeatures(open.el, true);
279
+ expect(open.more.hidden).toBe(true);
280
+ });
281
+
255
282
  it("drops a tool's name left last on a line with none of its features after it", () => {
256
283
  const items = [item("r-feat", 0), item("r-feat", 20), item("r-feats-product", 40), item("r-feat", 60)];
257
284
  const l = list(items, 40);
@@ -336,14 +336,31 @@ export function replayBanner(cycle, minutes) {
336
336
  return Number.isInteger(minutes) && minutes > 0 ? `${head} · live again in about ${minutes} min` : head;
337
337
  }
338
338
 
339
- /** The page's words when the tool's scenario has no cycle yet. */
340
- export function notRunWords(vendor) {
339
+ /** The page's words when the tool's scenario has no cycle yet; with `graph`, they say the graph below is its repo on the base. */
340
+ export function notRunWords(vendor, { graph = false } = {}) {
341
+ const text = `The ${vendor.name} scenario shows here once it has run through the demo's pipeline.`;
341
342
  return {
342
343
  title: "This scenario hasn't run yet",
343
- text: `The ${vendor.name} scenario shows here once it has run through the demo's pipeline.`,
344
+ text: graph ? `${text} Below, its repo as it stands on the base.` : text,
344
345
  };
345
346
  }
346
347
 
348
+ /**
349
+ * The view checkout of a tool's repo, by the names terragucci's demo driver
350
+ * gives the repos: `demo` for Terraform, `demo-<tool>` for the others. Null
351
+ * for a tool with no page.
352
+ */
353
+ export function toolCheckout(tool) {
354
+ if (!vendorOf(tool)) return null;
355
+ return tool === "terraform" ? "demo" : `demo-${tool}`;
356
+ }
357
+
358
+ /** behold#571: the checkout a tool's page shows before its first cycle names its repo: the tool's, when this behold serves it (`/api/project`'s viewRepos); null otherwise. */
359
+ export function notRunRepo(tool, served) {
360
+ const name = toolCheckout(tool);
361
+ return name && Array.isArray(served) && served.includes(name) ? name : null;
362
+ }
363
+
347
364
  /** The legend's words, without naming a competing engine on an engine's page. */
348
365
  export function rootWord(tool) {
349
366
  return namesOther("Terraform", tool) ? "a root" : "a Terraform root";
@@ -15,6 +15,7 @@ import {
15
15
  liveFor,
16
16
  linkSegments,
17
17
  namesOther,
18
+ notRunRepo,
18
19
  notRunWords,
19
20
  otherTerms,
20
21
  replayAt,
@@ -25,6 +26,7 @@ import {
25
26
  scrubDemo,
26
27
  servesRepo,
27
28
  stepDurations,
29
+ toolCheckout,
28
30
  } from "./kiosk-vendor.js";
29
31
 
30
32
  const demo = (extra = {}) => ({ cycle: 3, scenario: "b-waves", title: "A new subnet", step: "waiting", label: "Wave 2 waits", started_at: "2026-10-20T10:00:00Z", next: { action: "approve wave-2", at: null }, pull: 41, commit: "abc", about: null, intro: null, name: "a new subnet", scenarios: 3, index: 2, ...extra });
@@ -246,6 +248,20 @@ describe("a tool's page, drawn (behold#571)", () => {
246
248
  globalThis.document = was;
247
249
  }
248
250
  });
251
+ it("names the checkout of a tool's repo, and offers it before the first cycle only when it is served", () => {
252
+ expect(toolCheckout("terraform")).toBe("demo");
253
+ expect(toolCheckout("terragrunt")).toBe("demo-terragrunt");
254
+ expect(toolCheckout("nope")).toBe(null);
255
+ expect(notRunRepo("terragrunt", ["demo", "demo-terragrunt"])).toBe("demo-terragrunt");
256
+ expect(notRunRepo("terragrunt", ["demo"])).toBe(null);
257
+ expect(notRunRepo("terragrunt", null)).toBe(null);
258
+ expect(notRunRepo("nope", ["demo"])).toBe(null);
259
+ });
260
+ it("says the graph below is the repo on the base only when there is one", () => {
261
+ expect(notRunWords(VENDORS.terragrunt).text).not.toContain("Below");
262
+ expect(notRunWords(VENDORS.terragrunt, { graph: true }).text).toContain("Below, its repo as it stands on the base.");
263
+ expect(notRunWords(VENDORS.terragrunt, { graph: true }).title).toBe("This scenario hasn't run yet");
264
+ });
249
265
  it("says calmly that a tool's scenario hasn't run yet, with its name and link", async () => {
250
266
  const { renderKiosk } = await import("./kiosk.js");
251
267
  const was = globalThis.document;
package/web/kiosk.html CHANGED
@@ -137,6 +137,7 @@
137
137
  .k-box.k-more-below { -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 1.3em), transparent); mask-image: linear-gradient(to bottom, #000 calc(100% - 1.3em), transparent); }
138
138
  .k-box.k-more-above { -webkit-mask-image: linear-gradient(to top, #000 calc(100% - 1.3em), transparent); mask-image: linear-gradient(to top, #000 calc(100% - 1.3em), transparent); }
139
139
  .k-box.k-more-above.k-more-below { -webkit-mask-image: linear-gradient(to bottom, transparent, #000 1.3em, #000 calc(100% - 1.3em), transparent); mask-image: linear-gradient(to bottom, transparent, #000 1.3em, #000 calc(100% - 1.3em), transparent); }
140
+ .k-box-empty { background: transparent; }
140
141
  .k-box-title { background: transparent; padding: 0.1em 0.1em 0; overflow: hidden; }
141
142
  /* behold#571: between scenarios the card's box carries what the demo is about. */
142
143
  .k-intro { margin: 0; font-size: 1.12em; line-height: 1.4; color: var(--fg); }
@@ -217,6 +218,8 @@
217
218
  border-radius: 8px; box-shadow: 0 6px 24px color-mix(in srgb, var(--shadow) 70%, transparent); font-size: 1.15em; line-height: 1.45; }
218
219
  #k-what-pop[hidden] { display: none; }
219
220
  #k-what-pop p { margin: 0; }
221
+ #k-what-pop .k-what-more { margin-top: 0.5em; }
222
+ #k-what-pop a { color: var(--focus); }
220
223
  .k-menu summary { cursor: pointer; color: var(--muted); list-style: none; }
221
224
  .k-menu summary::-webkit-details-marker { display: none; }
222
225
  .k-menu summary::after { content: " ▾"; }
@@ -228,6 +231,9 @@
228
231
  /* With no graph to show, the graph pane folds away and the runs and the log take its width. */
229
232
  body.k-nograph #stage { grid-template-columns: minmax(15em, 22%) minmax(0, 1fr); }
230
233
  body.k-nograph #view-pane { display: none; }
234
+ /* behold#571: before the tool's first cycle, no runs and no log: those panes fold away, the repo's graph takes the width, or nothing shows below the header. */
235
+ body.k-notyet #runs-side, body.k-notyet #runs-main, body.k-notyet #k-runs-toggle { display: none !important; }
236
+ body.k-notyet #stage { grid-template-columns: minmax(0, 1fr); }
231
237
  .k-fact { color: var(--fg); }
232
238
  @media (max-width: 900px) and (min-width: 601px) { #bar { grid-template-columns: 1fr; } #stage, body.k-nograph #stage { grid-template-columns: 1fr; } }
233
239
  /*
package/web/kiosk.js CHANGED
@@ -17,7 +17,7 @@ import { PICK_HOLD_MS, annMark, mergeRun, startRuns } from "./kiosk-runs.js";
17
17
  import { PANEL_STEPS, PANEL_TITLE, approvalModel, cycleRuns, rowFacts, shortDigest } from "./kiosk-approval.js";
18
18
  import { getThemeName, mountThemePicker, onThemeChange } from "./theme.js";
19
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";
20
+ import { creditLines, filterIndex, keepRun, liveEstimate, liveFor, namesOther, notRunRepo, notRunWords, replayAt, replayBanner, replayIndex, rootWord, runAsOf, scrubDemo, servesRepo, vendorOf } from "./kiosk-vendor.js";
21
21
  import { repoParam, repoUrl } from "./kiosk-repo.js";
22
22
  import { STATE_WORDS, changingRoots, costLine, pinsCommit, planFitsPin, pullPin, readEdges, rootsCount, totalsLine, waveRows, blastRows } from "./terragucci-change.js";
23
23
 
@@ -496,7 +496,7 @@ function drawBar(bar, s, now, foot) {
496
496
  const vendor = vendorOf(s.vendor);
497
497
  if (vendor && s.mode === "none") {
498
498
  // behold#571: the tool's scenario has no cycle yet: its name, its link, and a calm line.
499
- const words = notRunWords(vendor);
499
+ const words = notRunWords(vendor, { graph: !!s.notRunGraph });
500
500
  const box = el("div", "k-empty");
501
501
  const title = el("h1", "k-title");
502
502
  title.append(brandEl(vendor), el("span", "k-title-text", words.title));
@@ -584,6 +584,8 @@ function drawBar(bar, s, now, foot) {
584
584
  if (panel.note) holdBox.appendChild(elRoots("p", "k-note", panel.note, s));
585
585
  else if (panel.stage === "pull-request") for (const u of unreviewedLines(s.answer)) holdBox.appendChild(elRoots("p", "k-note k-unknown", u, s));
586
586
  }
587
+ // behold#571: render what's present: an empty hold keeps its place (nothing moves when one comes) but draws no box.
588
+ if (!holdBox.children.length) holdBox.className += " k-box-empty";
587
589
  right.appendChild(holdBox);
588
590
  bar.appendChild(right);
589
591
 
@@ -691,8 +693,9 @@ export const LEDGER_POLL_MS = 10_000;
691
693
  /** behold#571: the approval panel's words for the approval the driver gives next, over its command. */
692
694
  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
695
 
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
+ /** behold#571: "What is this?" says what behold is; the demo's own intro stays on the card between scenarios. */
697
+ export const WHAT_TEXT = "This is behold, a live view of infrastructure. It draws the setup as a graph and shows its pipeline runs, their logs and the approvals they wait for, as they happen. Here it watches a demo pipeline that runs one change through several Terraform states in a loop.";
698
+ export const WHAT_DOCS = { text: "behold's documentation", url: "https://intentius.io/behold/" };
696
699
 
697
700
  /** behold#571: a fade at a box's edge where its text goes on past it. */
698
701
  function fade(b) {
@@ -723,13 +726,18 @@ export function startKiosk({ bar, frame, foot, side, main, views, tools = null }
723
726
  /** The time the page shows: the replay's, while it replays. */
724
727
  const shownNow = () => (s.mode === "replay" && s.replay && s.replay.at ? s.replay.at : now());
725
728
  /** 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);
729
+ const frameOk = () => {
730
+ if (!vendor) return true;
731
+ // Before the tool's first cycle: its repo's checkout, when this behold serves it.
732
+ if (s.mode === "none") return !!notRunRepo(tool, s.viewRepos) && s.aimed === notRunRepo(tool, s.viewRepos);
733
+ return servesRepo(s.answer && !s.answer.error ? s.answer.project : null, s.repo);
734
+ };
727
735
  // behold#571: a behold serving several repos (`serve --repos`) answers the
728
736
  // graph's reads from the checkout of the repo the page is about, asked by
729
737
  // name. `viewRepos` is what it serves, `aimed` the repo the reads are for.
730
738
  s.viewRepos = null;
731
739
  s.aimed = null;
732
- const wantRepo = () => repoParam(s.repo, s.viewRepos);
740
+ const wantRepo = () => repoParam(s.repo, s.viewRepos) || (vendor && s.mode === "none" ? notRunRepo(tool, s.viewRepos) : null);
733
741
  const fetchServed = fetchJson;
734
742
  fetchJson = (url) => fetchServed(repoUrl(url, wantRepo()));
735
743
  /** When the page moves to another served repo, read its project, reports and run view again, and point the frame at it. */
@@ -745,11 +753,13 @@ export function startKiosk({ bar, frame, foot, side, main, views, tools = null }
745
753
  let lastFocus;
746
754
  const shownView = () => kioskView(s.demo && s.demo.demo, { components: s.components, pick: s.pick, now: now().getTime() });
747
755
  const tellFrame = (force) => {
748
- const d = s.demo && s.demo.demo;
749
- if (!d) return; // nothing said yet: the frame keeps its default until the narration is read
750
- const roots = focusRoots(d, s.answer, s.progress);
756
+ // behold#571: before the tool's first cycle the frame draws its repo on the base, with nothing in flight.
757
+ const notRun = !!vendor && s.mode === "none";
758
+ const d = notRun ? null : s.demo && s.demo.demo;
759
+ if (!d && !notRun) return; // nothing said yet: the frame keeps its default until the narration is read
760
+ const roots = d ? focusRoots(d, s.answer, s.progress) : null;
751
761
  const focus = roots ? roots.join(",") : null;
752
- const run = runPin(d);
762
+ const run = d ? runPin(d) : "base";
753
763
  const zoom = viewZoom(shownView().view);
754
764
  const key = `${focus}|${run}|${zoom}`;
755
765
  if (!force && key === lastFocus) return;
@@ -848,6 +858,7 @@ export function startKiosk({ bar, frame, foot, side, main, views, tools = null }
848
858
  s.view = shown.view;
849
859
  const ok = frameOk();
850
860
  s.noReports = !ok;
861
+ s.notRunGraph = !!vendor && s.mode === "none" && ok;
851
862
  s.approval = approvalNow();
852
863
  // behold#571: the header's boxes redraw every second; each keeps where a viewer scrolled it.
853
864
  const scrolled = Object.fromEntries([...bar.querySelectorAll(".k-box")].map((b) => [b.dataset.box, b.scrollTop]));
@@ -860,7 +871,11 @@ export function startKiosk({ bar, frame, foot, side, main, views, tools = null }
860
871
  whatText();
861
872
  renderViewToggle(views, shown, { components: s.components && ok, onPick: pickView });
862
873
  // 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);
874
+ if (typeof document !== "undefined" && document.body && document.body.classList) {
875
+ document.body.classList.toggle("k-nograph", !!vendor && !ok);
876
+ // 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.
877
+ document.body.classList.toggle("k-notyet", !!vendor && s.mode === "none");
878
+ }
864
879
  tellFrame(false);
865
880
  };
866
881
 
@@ -947,14 +962,16 @@ export function startKiosk({ bar, frame, foot, side, main, views, tools = null }
947
962
  };
948
963
  if (tools && tools.runsToggle) tools.runsToggle.addEventListener("click", () => setRunsOpen(!document.body.classList.contains("k-runs-open")));
949
964
 
950
- // behold#571: "What is this?" opens the intro over the page (no layout moves); a click elsewhere or Escape closes it.
965
+ // behold#571: "What is this?" opens what behold is over the page (no layout moves); a click elsewhere or Escape closes it.
951
966
  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));
967
+ if (!tools || !tools.pop || tools.pop.firstElementChild) return;
968
+ const docs = el("a", null, WHAT_DOCS.text);
969
+ docs.href = WHAT_DOCS.url;
970
+ docs.target = "_blank";
971
+ docs.rel = "noopener";
972
+ const more = el("p", "k-what-more");
973
+ more.append(docs);
974
+ tools.pop.replaceChildren(el("p", null, WHAT_TEXT), more);
958
975
  };
959
976
  if (tools && tools.what && tools.pop) {
960
977
  const setWhat = (open) => {