@intentius/behold 0.29.8 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/behold",
3
- "version": "0.29.8",
3
+ "version": "0.29.9",
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
@@ -228,6 +228,9 @@
228
228
  /* With no graph to show, the graph pane folds away and the runs and the log take its width. */
229
229
  body.k-nograph #stage { grid-template-columns: minmax(15em, 22%) minmax(0, 1fr); }
230
230
  body.k-nograph #view-pane { display: none; }
231
+ /* 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. */
232
+ body.k-notyet #runs-side, body.k-notyet #runs-main, body.k-notyet #k-runs-toggle { display: none !important; }
233
+ body.k-notyet #stage { grid-template-columns: minmax(0, 1fr); }
231
234
  .k-fact { color: var(--fg); }
232
235
  @media (max-width: 900px) and (min-width: 601px) { #bar { grid-template-columns: 1fr; } #stage, body.k-nograph #stage { grid-template-columns: 1fr; } }
233
236
  /*
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));
@@ -723,13 +723,18 @@ export function startKiosk({ bar, frame, foot, side, main, views, tools = null }
723
723
  /** The time the page shows: the replay's, while it replays. */
724
724
  const shownNow = () => (s.mode === "replay" && s.replay && s.replay.at ? s.replay.at : now());
725
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);
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
+ };
727
732
  // behold#571: a behold serving several repos (`serve --repos`) answers the
728
733
  // graph's reads from the checkout of the repo the page is about, asked by
729
734
  // name. `viewRepos` is what it serves, `aimed` the repo the reads are for.
730
735
  s.viewRepos = null;
731
736
  s.aimed = null;
732
- const wantRepo = () => repoParam(s.repo, s.viewRepos);
737
+ const wantRepo = () => repoParam(s.repo, s.viewRepos) || (vendor && s.mode === "none" ? notRunRepo(tool, s.viewRepos) : null);
733
738
  const fetchServed = fetchJson;
734
739
  fetchJson = (url) => fetchServed(repoUrl(url, wantRepo()));
735
740
  /** When the page moves to another served repo, read its project, reports and run view again, and point the frame at it. */
@@ -745,11 +750,13 @@ export function startKiosk({ bar, frame, foot, side, main, views, tools = null }
745
750
  let lastFocus;
746
751
  const shownView = () => kioskView(s.demo && s.demo.demo, { components: s.components, pick: s.pick, now: now().getTime() });
747
752
  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);
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;
751
758
  const focus = roots ? roots.join(",") : null;
752
- const run = runPin(d);
759
+ const run = d ? runPin(d) : "base";
753
760
  const zoom = viewZoom(shownView().view);
754
761
  const key = `${focus}|${run}|${zoom}`;
755
762
  if (!force && key === lastFocus) return;
@@ -848,6 +855,7 @@ export function startKiosk({ bar, frame, foot, side, main, views, tools = null }
848
855
  s.view = shown.view;
849
856
  const ok = frameOk();
850
857
  s.noReports = !ok;
858
+ s.notRunGraph = !!vendor && s.mode === "none" && ok;
851
859
  s.approval = approvalNow();
852
860
  // behold#571: the header's boxes redraw every second; each keeps where a viewer scrolled it.
853
861
  const scrolled = Object.fromEntries([...bar.querySelectorAll(".k-box")].map((b) => [b.dataset.box, b.scrollTop]));
@@ -860,7 +868,11 @@ export function startKiosk({ bar, frame, foot, side, main, views, tools = null }
860
868
  whatText();
861
869
  renderViewToggle(views, shown, { components: s.components && ok, onPick: pickView });
862
870
  // 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);
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
+ }
864
876
  tellFrame(false);
865
877
  };
866
878