@intentius/behold 0.29.5 → 0.29.7

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/AGENTS.md CHANGED
@@ -71,7 +71,12 @@ and none is the checkout's git remote) adds two reads, in
71
71
  Terraform root of the checkout with the newest `plan`, `drift` and `apply`
72
72
  run that held it; a missing stage means no report holds that root, which is
73
73
  not "no changes". `cards` maps a card id to its marks (`verdict: drift |
74
- plan | wave`, terragucci's words, the instance addresses, the run). Nothing
74
+ plan | wave`, terragucci's words, the instance addresses, the run). Each
75
+ mark's `values` gives each changed attribute's `before` and `after`, or
76
+ `unknown: true` for a value known only after apply. A sensitive attribute
77
+ is never there, and a value longer than 300 characters of JSON is left out
78
+ with `long: true`. `applied` maps a card id to what its root's newest
79
+ applied wave wrote. The kiosk's popover reads both (behold#571). Nothing
75
80
  sets `_status`: a report is never painted as live. `waiting[]` carries each
76
81
  wave's roots, destroys, and the `command` a person runs. `estate` is
77
82
  terragucci's own counts when the source has `estate.json`. A key such as a
package/dist/cli.js CHANGED
@@ -22722,6 +22722,20 @@ var TerragucciReadError = class extends Error {
22722
22722
  var refuse10 = (error, remedy) => {
22723
22723
  throw new TerragucciReadError({ error, code: "terragucci-report", remedy });
22724
22724
  };
22725
+ var VALUE_CAP = 300;
22726
+ function attributeValues(a) {
22727
+ const out = { path: a.path };
22728
+ let long = false;
22729
+ for (const k of ["before", "after"]) {
22730
+ if (!(k in a) || a[k] === void 0) continue;
22731
+ const json2 = JSON.stringify(a[k]);
22732
+ if (json2 === void 0 || json2.length > VALUE_CAP) long = true;
22733
+ else out[k] = a[k];
22734
+ }
22735
+ if (a.unknown === true) out.unknown = true;
22736
+ if (long) out.long = true;
22737
+ return out;
22738
+ }
22725
22739
  var SCHEMA_FILES = { report: "report.schema.json", index: "report-index.schema.json", estate: "estate.schema.json" };
22726
22740
  var SCHEMA_IDS = { report: REPORT_SCHEMA, index: INDEX_SCHEMA, estate: ESTATE_SCHEMA };
22727
22741
  var isRecord14 = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
@@ -22897,7 +22911,7 @@ async function readTerragucci(source2, opts = {}) {
22897
22911
  action: c.action,
22898
22912
  ...c.type ? { type: c.type } : {},
22899
22913
  ...c.module ? { module: c.module } : {},
22900
- attributes: (c.attributes ?? []).filter((a) => !a.sensitive).map((a) => ({ path: a.path }))
22914
+ attributes: (c.attributes ?? []).filter((a) => !a.sensitive).map(attributeValues)
22901
22915
  })),
22902
22916
  ...stage === "tf-apply" && row.approval ? { approval: row.approval } : {},
22903
22917
  ...stage === "tf-apply" && row.applied ? { applied: row.applied } : {}
@@ -23079,6 +23093,7 @@ function joinTerragucci(read, nodes, roots) {
23079
23093
  byRoot.get(key).set(n.id.slice(at2 + root.length + 2), n.id);
23080
23094
  }
23081
23095
  const cards = {};
23096
+ const applied = {};
23082
23097
  const unmatchedChanges = [];
23083
23098
  const out = [];
23084
23099
  const known = new Set(roots.map((r) => r.path));
@@ -23089,7 +23104,17 @@ function joinTerragucci(read, nodes, roots) {
23089
23104
  for (const [slot, run3] of [["drift", runs2.drift], ["plan", runs2.plan], ["apply", runs2.apply]]) {
23090
23105
  if (!run3) continue;
23091
23106
  marks[slot] = verdict(run3);
23092
- if (slot === "apply" && run3.approval !== "waiting") continue;
23107
+ if (slot === "apply" && run3.approval !== "waiting") {
23108
+ if (run3.applied) {
23109
+ for (const c of run3.changes) {
23110
+ const id = blocks?.get(blockPath(c.address));
23111
+ if (!id || !c.attributes?.length) continue;
23112
+ const a = applied[id] ??= { values: [], run: ref(run3) };
23113
+ for (const v of c.attributes) a.values.push({ ...v, address: c.address });
23114
+ }
23115
+ }
23116
+ continue;
23117
+ }
23093
23118
  const kind = slot === "apply" ? "wave" : slot;
23094
23119
  const words = slot === "drift" ? DRIFT_WORDS : PLAN_WORDS;
23095
23120
  const grouped = /* @__PURE__ */ new Map();
@@ -23102,9 +23127,12 @@ function joinTerragucci(read, nodes, roots) {
23102
23127
  continue;
23103
23128
  }
23104
23129
  const k = `${id}\0${c.action}`;
23105
- const m = grouped.get(k) ?? { verdict: kind, action: c.action, words: said4, addresses: [], attributes: [], run: ref(run3) };
23130
+ const m = grouped.get(k) ?? { verdict: kind, action: c.action, words: said4, addresses: [], attributes: [], values: [], run: ref(run3) };
23106
23131
  m.addresses.push(c.address);
23107
- for (const a of c.attributes ?? []) if (!m.attributes.includes(a.path)) m.attributes.push(a.path);
23132
+ for (const a of c.attributes ?? []) {
23133
+ if (!m.attributes.includes(a.path)) m.attributes.push(a.path);
23134
+ m.values.push({ ...a, address: c.address });
23135
+ }
23108
23136
  grouped.set(k, m);
23109
23137
  }
23110
23138
  for (const [k, m] of grouped) (cards[k.slice(0, k.indexOf("\0"))] ??= []).push(m);
@@ -23115,7 +23143,8 @@ function joinTerragucci(read, nodes, roots) {
23115
23143
  cards,
23116
23144
  roots: out,
23117
23145
  unmatched: { roots: Object.keys(read.roots).filter((p) => !known.has(p)).sort(), changes: unmatchedChanges },
23118
- waiting: read.waiting
23146
+ waiting: read.waiting,
23147
+ applied
23119
23148
  };
23120
23149
  }
23121
23150
 
@@ -24215,6 +24244,7 @@ var UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
24215
24244
  var isRecord17 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
24216
24245
  var str13 = (v) => typeof v === "string";
24217
24246
  var int = (v) => typeof v === "number" && Number.isInteger(v);
24247
+ var positive = (v) => int(v) && v > 0 ? v : null;
24218
24248
  function demoProblem(doc) {
24219
24249
  if (!isRecord17(doc)) return "it is not an object";
24220
24250
  if (!int(doc.cycle)) return "cycle is not an integer";
@@ -24269,7 +24299,11 @@ async function readDemo(source2, now = /* @__PURE__ */ new Date()) {
24269
24299
  next: d.next ? { action: d.next.action, at: d.next.at } : null,
24270
24300
  pull: d.pull,
24271
24301
  commit: d.commit,
24272
- about: d.about ? { changes: d.about.changes, watch: d.about.watch, shows: d.about.shows } : null
24302
+ about: d.about ? { changes: d.about.changes, watch: d.about.watch, shows: d.about.shows } : null,
24303
+ intro: str13(d.intro) && d.intro.trim() ? d.intro.trim() : null,
24304
+ name: str13(d.name) && d.name.trim() ? d.name.trim() : null,
24305
+ scenarios: positive(d.scenarios),
24306
+ index: positive(d.index)
24273
24307
  }
24274
24308
  };
24275
24309
  }
@@ -27332,6 +27366,14 @@ function createApp(cfg, broadcaster = new Broadcaster(), frames = new FrameBuffe
27332
27366
  // #571: the repo declares its roots as components, so the page opens on
27333
27367
  // the components zoom (web/app.js `bootZoom`).
27334
27368
  ...servedTerragucciComponents() ? { terragucciComponents: true } : {},
27369
+ // behold#571: the declared roots and the wave jobs that apply them, so
27370
+ // the kiosk gives each root its colour and ties a job row to its roots.
27371
+ ...(() => {
27372
+ const decl = servedTerragucciComponents();
27373
+ if (!decl) return {};
27374
+ const waves = decl.pipeline.jobs.filter((j) => typeof j.wave === "number").map((j) => ({ job: j.name, wave: j.wave, roots: [...j.applies] }));
27375
+ return { terragucciRoots: { roots: Object.keys(decl.roots).sort(), waves } };
27376
+ })(),
27335
27377
  // #517: a SQL Yodeler repo is migrated by its pipeline; same contract.
27336
27378
  ...(() => {
27337
27379
  const r = guardedRepo();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/behold",
3
- "version": "0.29.5",
3
+ "version": "0.29.7",
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/app.js CHANGED
@@ -10,7 +10,9 @@
10
10
  // renderPanelView never rewrites, so the select mounts once and survives re-renders).
11
11
  import { createRefreshQueue } from "./refresh-queue.js";
12
12
  import { readViewQuery, writeViewQuery, settleView, settlePlace, refusalLine } from "./view-url.js";
13
- import { readEmbed, selectMessage, viewFromMessage } from "./embed.js";
13
+ import { quietCardLines, readEmbed, selectMessage, viewFromMessage } from "./embed.js";
14
+ import { WORDS as POP_WORDS, cardRoot as popCardRoot, placePopover, popReduce, popState, resourcePopover, rootPopover } from "./kiosk-popover.js";
15
+ import { cssString, hoverCss, hoverFromMessage, hoverLinks, hoverMessage, isLightBg, rootColour, rootText } from "./kiosk-roots.js";
14
16
  import { gateCards } from "./workspace-gates.js";
15
17
  import { cornerOf, estateLine, hrefOf as tgHref, legend as tgLegend, markLine, rootRows as tgRootRows, waveCards } from "./terragucci.js";
16
18
  import { laneElement as tgLane, rootOfCard as tgRootOfCard, historyElement as tgHistoryLane } from "./terragucci-timeline.js";
@@ -3378,6 +3380,7 @@ let tgTimeline = null; // #506: /api/terragucci/timeline, the audit record's lan
3378
3380
  let tgHistory = null; // #522: /api/lifecycle/history, the gate ledgers' history
3379
3381
  async function initTerragucci() {
3380
3382
  const project = await apiFetch("/api/project").then((r) => r.json()).catch(() => ({}));
3383
+ kioskRoots = project.terragucciRoots || null; // behold#571
3381
3384
  if (!project.terragucciReports) return;
3382
3385
  await loadTerragucci();
3383
3386
  // #505: the chant/lifecycle lane, and what moved while this page is open.
@@ -3485,6 +3488,7 @@ function paintTgProgress() {
3485
3488
  // merged. Not live; redrawn on each render and each read.
3486
3489
  const TG_TONE = { plan: "var(--pending)", wait: "var(--foreign)", busy: "var(--runtime)", ok: "var(--managed)", bad: "var(--degraded)", idle: "var(--neutral)" };
3487
3490
  function drawTgReads() {
3491
+ if (embedOpts.bare) queueMicrotask(kioskDecorate); // behold#571: once this draw is done
3488
3492
  watchTgRuns(); // #571: the wave boxes' jobs, while the components zoom is on screen
3489
3493
  const svg = document.querySelector("#graph svg");
3490
3494
  if (!svg) return;
@@ -3502,7 +3506,7 @@ function drawTgReads() {
3502
3506
  }
3503
3507
  for (const t of svg.querySelectorAll("[data-tg-hid]")) {
3504
3508
  t.removeAttribute("data-tg-hid");
3505
- t.removeAttribute("visibility");
3509
+ if (!t.hasAttribute("data-kiosk-quiet")) t.removeAttribute("visibility");
3506
3510
  }
3507
3511
  for (const g of svg.querySelectorAll("[data-tg-dim]")) {
3508
3512
  g.removeAttribute("data-tg-dim");
@@ -4083,6 +4087,266 @@ function markTerragucciCards() {
4083
4087
  }
4084
4088
  drawTgReads(); // #568
4085
4089
  }
4090
+ // --- behold#571: the kiosk frame's popover, root colours and hover links -------
4091
+ // The kiosk frames this page bare, with no Inspect pane. Pointing at a card
4092
+ // shows a popover beside it (web/kiosk-popover.js says what it holds); a
4093
+ // click keeps it open. Each root's name carries a dot in its own colour, and
4094
+ // hovering a card or box here lights its root and wave in the kiosk's other
4095
+ // panes, and theirs light it here (`behold:hover`, web/kiosk-roots.js).
4096
+ // Nothing happens until someone points: a TV with no pointer sees the page
4097
+ // as before.
4098
+ let kioskRoots = null; // /api/project's terragucciRoots: { roots, waves }
4099
+ let kioskPop = popState();
4100
+ let kioskHover = null; // this frame's pointer: { root } | { wave } | null
4101
+ let kioskHostHover = null; // the kiosk's pointer, from its message
4102
+ function kioskRootList() {
4103
+ if (kioskRoots && Array.isArray(kioskRoots.roots) && kioskRoots.roots.length) return kioskRoots.roots;
4104
+ return tgAnswer && Array.isArray(tgAnswer.roots) ? tgAnswer.roots.map((r) => r.path) : [];
4105
+ }
4106
+ function kioskWaves() {
4107
+ return (kioskRoots && kioskRoots.waves) || [];
4108
+ }
4109
+ function kioskLight() {
4110
+ return isLightBg(getComputedStyle(document.documentElement).getPropertyValue("--bg"));
4111
+ }
4112
+ function kioskCompMode() {
4113
+ return !!(view.components && terragucciComponentsRepo && tgComponentFacets);
4114
+ }
4115
+ function kioskRootOfComponent(c) {
4116
+ const m = (tgComponentFacets && tgComponentFacets.roots) || {};
4117
+ return m[c] || c;
4118
+ }
4119
+ /** The terragucci root (its path) of a resources-zoom box name. */
4120
+ function kioskRootOfBox(name) {
4121
+ const r = tgAnswer && Array.isArray(tgAnswer.roots) ? tgAnswer.roots.find((x) => x.name === name) : null;
4122
+ return r ? r.path : name;
4123
+ }
4124
+ /** What the pointer is on: a card's or a box's root, or a wave's box. */
4125
+ function kioskHoverOf(target) {
4126
+ if (!target || !target.closest) return null;
4127
+ const card = target.closest("[data-node-id]");
4128
+ if (card) {
4129
+ const id = card.getAttribute("data-node-id");
4130
+ if (kioskCompMode()) return { root: kioskRootOfComponent(id) };
4131
+ const at = popCardRoot(tgAnswer, id);
4132
+ return at ? { root: at.path } : null;
4133
+ }
4134
+ const rect = target.closest("rect[data-group-id]");
4135
+ if (!rect) return null;
4136
+ const g = rect.getAttribute("data-group-id") || "";
4137
+ const w = /^wave-(\d+)$/.exec(g);
4138
+ if (w) return { wave: Number(w[1]) };
4139
+ const root = kioskRootOfBox(g);
4140
+ return kioskRootList().includes(root) ? { root } : null;
4141
+ }
4142
+ const sameHover = (a, b) => (a && a.root) === (b && b.root) && (a && a.wave) === (b && b.wave);
4143
+ function kioskSetHover(h) {
4144
+ if (sameHover(h, kioskHover)) return;
4145
+ kioskHover = h;
4146
+ kioskGlow();
4147
+ if (!embedOpts.host || window.parent === window) return;
4148
+ try {
4149
+ window.parent.postMessage(hoverMessage(h), embedOpts.host);
4150
+ } catch {
4151
+ /* the parent went away */
4152
+ }
4153
+ }
4154
+ /** Light what the two pointers link: a root's box (or card) and a wave's box, with one style element that survives a redraw. */
4155
+ function kioskGlow() {
4156
+ let style = document.getElementById("k-hover-style");
4157
+ if (!style) {
4158
+ style = document.createElement("style");
4159
+ style.id = "k-hover-style";
4160
+ document.head.appendChild(style);
4161
+ }
4162
+ const a = hoverLinks(kioskHover, kioskWaves());
4163
+ const b = hoverLinks(kioskHostHover, kioskWaves());
4164
+ const links = { roots: [...new Set([...a.roots, ...b.roots])], waves: [...new Set([...a.waves, ...b.waves])] };
4165
+ const boxes = (root) => {
4166
+ const names = new Set([root]);
4167
+ const r = tgAnswer && Array.isArray(tgAnswer.roots) ? tgAnswer.roots.find((x) => x.path === root || x.name === root) : null;
4168
+ if (r) names.add(r.name);
4169
+ const comps = tgComponentFacets && tgComponentFacets.roots ? Object.keys(tgComponentFacets.roots).filter((c) => tgComponentFacets.roots[c] === root) : [];
4170
+ for (const c of comps) names.add(c);
4171
+ return [...names].map((n) => `#graph rect[data-group-id=${cssString(n)}], #graph g[data-node-id=${cssString(n)}]`).join(", ");
4172
+ };
4173
+ style.textContent = hoverCss(links, { root: boxes, wave: (w) => `#graph rect[data-group-id="wave-${w}"]` }, "filter: drop-shadow(0 0 2px var(--focus)) drop-shadow(0 0 7px var(--focus));");
4174
+ }
4175
+ /** The dots by each root's name: on its box's title (Resources) or its card (Roots by wave). */
4176
+ function kioskDots(svg) {
4177
+ for (const d of svg.querySelectorAll("[data-k-dot]")) d.remove();
4178
+ const roots = kioskRootList();
4179
+ if (!roots.length) return;
4180
+ const light = kioskLight();
4181
+ const dotBy = (text, root) => {
4182
+ const colour = rootColour(root, roots, { light });
4183
+ if (!colour) return;
4184
+ let bb;
4185
+ try {
4186
+ bb = text.getBBox();
4187
+ } catch {
4188
+ return;
4189
+ }
4190
+ if (!bb.width) return;
4191
+ const c = document.createElementNS("http://www.w3.org/2000/svg", "circle");
4192
+ c.setAttribute("data-k-dot", "1");
4193
+ // After the name: before it sits the card's icon.
4194
+ c.setAttribute("cx", String(bb.x + bb.width + 9));
4195
+ c.setAttribute("cy", String(bb.y + bb.height / 2));
4196
+ c.setAttribute("r", "5.5");
4197
+ c.setAttribute("fill", colour);
4198
+ c.setAttribute("pointer-events", "none");
4199
+ text.parentNode.insertBefore(c, text.nextSibling);
4200
+ };
4201
+ if (kioskCompMode()) {
4202
+ for (const card of svg.querySelectorAll("[data-node-id]")) {
4203
+ const id = card.getAttribute("data-node-id");
4204
+ const t = [...card.querySelectorAll("text")].find((x) => (x.textContent || "").trim() === id);
4205
+ if (t) dotBy(t, kioskRootOfComponent(id));
4206
+ }
4207
+ return;
4208
+ }
4209
+ for (const rect of svg.querySelectorAll("rect[data-group-id]")) {
4210
+ const name = rect.getAttribute("data-group-id");
4211
+ const root = kioskRootOfBox(name);
4212
+ if (!roots.includes(root)) continue;
4213
+ const t = [...(rect.parentNode ? rect.parentNode.querySelectorAll(":scope > text") : [])].find((x) => (x.textContent || "").trim() === name && x.getAttribute("visibility") !== "hidden");
4214
+ if (t) dotBy(t, root);
4215
+ }
4216
+ }
4217
+ function kioskDecorate() {
4218
+ const svg = document.querySelector("#graph svg");
4219
+ if (!svg) return;
4220
+ kioskDots(svg);
4221
+ kioskGlow();
4222
+ kioskPopRender();
4223
+ }
4224
+ function kioskPopEvent(ev) {
4225
+ const next = popReduce(kioskPop, ev, Date.now());
4226
+ if (next === kioskPop) return;
4227
+ kioskPop = next;
4228
+ kioskPopRender();
4229
+ }
4230
+ function kioskPopRender() {
4231
+ let pop = document.getElementById("k-pop");
4232
+ const svg = document.querySelector("#graph svg");
4233
+ const id = kioskPop.shown;
4234
+ const card = id && svg ? svg.querySelector(`[data-node-id="${CSS.escape(id)}"]`) : null;
4235
+ const answer = tgAnswerDrawn();
4236
+ const content = !card || !answer ? null : kioskCompMode() ? rootPopover(answer, kioskRootOfComponent(id), resourcesByComponent[id]) : resourcePopover(answer, id);
4237
+ if (!content) {
4238
+ if (pop) pop.hidden = true;
4239
+ return;
4240
+ }
4241
+ if (!pop) {
4242
+ pop = document.createElement("div");
4243
+ pop.id = "k-pop";
4244
+ pop.setAttribute("role", "tooltip");
4245
+ document.body.appendChild(pop);
4246
+ }
4247
+ pop.replaceChildren();
4248
+ pop.classList.toggle("k-pinned", kioskPop.pinned);
4249
+ const roots = kioskRootList();
4250
+ const light = kioskLight();
4251
+ const add = (tag, cls, text) => {
4252
+ const n = document.createElement(tag);
4253
+ n.className = cls;
4254
+ if (text !== undefined) n.textContent = text;
4255
+ pop.appendChild(n);
4256
+ return n;
4257
+ };
4258
+ const list = (rows) => {
4259
+ const ul = add("ul", "k-pop-lines");
4260
+ for (const r of rows) {
4261
+ const li = document.createElement("li");
4262
+ li.className = r.tone === "unknown" ? "k-pop-unknown" : "";
4263
+ li.textContent = r.text;
4264
+ ul.appendChild(li);
4265
+ }
4266
+ return ul;
4267
+ };
4268
+ if (content.address !== undefined) {
4269
+ add("div", "k-pop-addr", content.address);
4270
+ add("div", "k-pop-root").appendChild(rootText(document, `It sits in root ${content.root}.`, roots, { light }));
4271
+ add("p", "k-pop-head", content.head);
4272
+ if (content.lines.length) {
4273
+ const ul = list(content.lines);
4274
+ if (content.more) ul.appendChild(Object.assign(document.createElement("li"), { className: "k-pop-more", textContent: `and ${content.more} more` }));
4275
+ }
4276
+ if (content.applied) {
4277
+ add("p", "k-pop-note", content.applied.when);
4278
+ list(content.applied.lines);
4279
+ } else if (content.note) add("p", "k-pop-note", content.note);
4280
+ } else {
4281
+ add("div", "k-pop-addr").appendChild(rootText(document, content.root, roots, { light }));
4282
+ add("p", "k-pop-head", content.head);
4283
+ const ul = add("ul", "k-pop-lines");
4284
+ for (const r of content.rows) {
4285
+ const li = document.createElement("li");
4286
+ li.className = "k-pop-res";
4287
+ const st = document.createElement("span");
4288
+ st.className = `k-pop-state k-pop-${r.state}`;
4289
+ st.textContent = r.words;
4290
+ li.append(document.createTextNode(`${r.address} `), st);
4291
+ ul.appendChild(li);
4292
+ }
4293
+ if (content.more > 0) ul.appendChild(Object.assign(document.createElement("li"), { className: "k-pop-more", textContent: `and ${content.more} more` }));
4294
+ }
4295
+ add("p", "k-pop-hint", kioskPop.pinned ? POP_WORDS.pinnedHint : POP_WORDS.pinHint);
4296
+ pop.hidden = false;
4297
+ const cb = card.getBoundingClientRect();
4298
+ const pb = pop.getBoundingClientRect();
4299
+ const at = placePopover({ left: cb.left, top: cb.top, width: cb.width, height: cb.height }, { width: pb.width, height: pb.height }, { width: window.innerWidth, height: window.innerHeight });
4300
+ pop.style.left = `${Math.round(at.left)}px`;
4301
+ pop.style.top = `${Math.round(at.top)}px`;
4302
+ }
4303
+ /** Wire the frame's pointer once: the popover, and the hover links to the kiosk. Bare frames only. */
4304
+ function wireKioskFrame() {
4305
+ if (!embedOpts.bare) return;
4306
+ const graph = document.getElementById("graph");
4307
+ if (!graph) return;
4308
+ graph.addEventListener("pointerover", (e) => {
4309
+ const card = e.target.closest && e.target.closest("[data-node-id]");
4310
+ if (card) {
4311
+ // The card's own tooltips would sit over the popover.
4312
+ for (const t of card.querySelectorAll("title")) t.remove();
4313
+ kioskPopEvent({ type: "enter", id: card.getAttribute("data-node-id") });
4314
+ }
4315
+ kioskSetHover(kioskHoverOf(e.target));
4316
+ });
4317
+ graph.addEventListener("pointerout", (e) => {
4318
+ const card = e.target.closest && e.target.closest("[data-node-id]");
4319
+ const into = e.relatedTarget;
4320
+ if (card && !(into && card.contains(into))) kioskPopEvent({ type: "leave", id: card.getAttribute("data-node-id") });
4321
+ if (!into || !graph.contains(into)) kioskSetHover(null);
4322
+ });
4323
+ graph.addEventListener(
4324
+ "click",
4325
+ (e) => {
4326
+ const card = e.target.closest && e.target.closest("[data-node-id]");
4327
+ if (card) kioskPopEvent({ type: "click", id: card.getAttribute("data-node-id") });
4328
+ else kioskPopEvent({ type: "outside" });
4329
+ },
4330
+ true,
4331
+ );
4332
+ document.addEventListener("click", (e) => {
4333
+ if (!graph.contains(e.target) && !(e.target.closest && e.target.closest("#k-pop"))) kioskPopEvent({ type: "outside" });
4334
+ });
4335
+ document.addEventListener("keydown", (e) => {
4336
+ if (e.key === "Escape") kioskPopEvent({ type: "escape" });
4337
+ });
4338
+ setInterval(() => kioskPopEvent({ type: "tick" }), 1000);
4339
+ window.addEventListener("message", (e) => {
4340
+ if (!embedOpts.host || e.origin !== embedOpts.host) return;
4341
+ if (e.data && e.data.type === "behold:unpin") return kioskPopEvent({ type: "outside" });
4342
+ const h = hoverFromMessage(e.data);
4343
+ if (h === undefined) return;
4344
+ kioskHostHover = h;
4345
+ kioskGlow();
4346
+ });
4347
+ }
4348
+ wireKioskFrame();
4349
+
4086
4350
  function markAge(marks) {
4087
4351
  const newest = marks.map((m) => m.run.finished).sort().pop();
4088
4352
  const s = Math.max(0, (Date.now() - Date.parse(newest)) / 1000);
@@ -5329,6 +5593,8 @@ function render(ir, svg, m) {
5329
5593
  svgEl.removeAttribute("width");
5330
5594
  svgEl.removeAttribute("height");
5331
5595
  svgEl.setAttribute("preserveAspectRatio", "xMidYMid meet");
5596
+ // behold#571: the kiosk's frame drops the cards' kind and meta lines.
5597
+ if (embedOpts.bare) quietCardLines(svgEl);
5332
5598
  setupGraphViewBox(svgEl);
5333
5599
  wireEdgeHighlight(svgEl, ir);
5334
5600
  // Mark each wave lane as a GitLab CI stage when the CI projection is loaded
package/web/embed.js CHANGED
@@ -79,3 +79,23 @@ export function viewFromMessage(event, host) {
79
79
  }
80
80
  return want;
81
81
  }
82
+
83
+ /**
84
+ * behold#571: a card's line the kiosk's frame leaves out, for a viewer new to
85
+ * Terraform: the kind line ("Component · chant", "terraform_data ·
86
+ * terraform"), and the `archetype:`, `wave:` and `root:` meta lines (the wave
87
+ * or the root is the box the card sits in). behold's own page keeps them.
88
+ */
89
+ export function kioskQuietLine(text) {
90
+ const t = typeof text === "string" ? text.trim() : "";
91
+ return /^Component\b/.test(t) || /^[\w.-]+ · [\w.-]+$/.test(t) || /^archetype:\s*\S/.test(t) || /^wave:\s*\d+$/.test(t) || /^root:\s*\S+$/.test(t);
92
+ }
93
+
94
+ /** Hide the kioskQuietLine lines of every card in a drawn graph; marks them so a redraw of the marks leaves them hidden. */
95
+ export function quietCardLines(svg) {
96
+ for (const t of svg.querySelectorAll("[data-node-id] text")) {
97
+ if (!kioskQuietLine(t.textContent)) continue;
98
+ t.setAttribute("visibility", "hidden");
99
+ t.setAttribute("data-kiosk-quiet", "1");
100
+ }
101
+ }
package/web/embed.test.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { readEmbed, selectMessage, viewFromMessage, THEME_ALIASES } from "./embed.js";
2
+ import { kioskQuietLine, readEmbed, selectMessage, viewFromMessage, THEME_ALIASES } from "./embed.js";
3
3
 
4
4
  describe("readEmbed (#476)", () => {
5
5
  it("reads embed, an exact host origin and a theme", () => {
@@ -63,3 +63,12 @@ describe("viewFromMessage focus (#569)", () => {
63
63
  expect(viewFromMessage({ origin: host, data: { type: "behold:view", focus: "" } }, host)).toEqual({ focus: null });
64
64
  });
65
65
  });
66
+
67
+ describe("kioskQuietLine (behold#571)", () => {
68
+ it("leaves out a card's kind and meta lines in the kiosk's frame", () => {
69
+ for (const t of ["Component · chant", "terraform_data · terraform", "archetype: infra", "wave: 2", " root: app "]) expect(kioskQuietLine(t)).toBe(true);
70
+ });
71
+ it("keeps the names, the states and the reads", () => {
72
+ for (const t of ["network", "terraform_data.http_proxy_port", "waiting", "reads network · planned · 1 unknown", "wave 2 · planned", "", null]) expect(kioskQuietLine(t)).toBe(false);
73
+ });
74
+ });
package/web/index.html CHANGED
@@ -419,6 +419,28 @@
419
419
  .bare #app { grid-template-columns: 1fr 0 !important; }
420
420
  .bare #graph svg > rect[fill^="url(#pin-"] { display: none; }
421
421
  .bare #graph svg { cursor: default; }
422
+ /* behold#571: the marks drawn over a card let the pointer through to it, so pointing anywhere on the card opens its popover. */
423
+ .bare #graph text[data-tg-root], .bare #graph g[data-tg-root] { pointer-events: none; }
424
+ /* behold#571: the kiosk frame's popover, beside the card it describes. */
425
+ #k-pop { position: fixed; z-index: 50; max-width: min(30em, 46vw); padding: 0.7em 0.9em 0.6em; background: var(--panel); color: var(--fg);
426
+ border: 1px solid var(--rule); border-radius: 8px; box-shadow: 0 6px 22px color-mix(in srgb, var(--shadow) 70%, transparent);
427
+ font: 14px/1.4 var(--font-ui); pointer-events: auto; }
428
+ #k-pop[hidden] { display: none; }
429
+ #k-pop.k-pinned { border-color: var(--focus); }
430
+ #k-pop p { margin: 0.25em 0; }
431
+ .k-pop-addr { font: 700 1.05em var(--font-mono); overflow-wrap: anywhere; }
432
+ .k-pop-root { color: var(--muted); font-size: 0.92em; }
433
+ .k-pop-head { font-weight: 600; }
434
+ .k-pop-lines { list-style: none; margin: 0.2em 0 0.3em; padding: 0.35em 0.6em; background: var(--well); border-radius: 6px; font: 0.95em/1.45 var(--font-mono); }
435
+ .k-pop-lines li { overflow-wrap: anywhere; }
436
+ .k-pop-unknown, .k-pop-unknown.k-pop-state { color: var(--foreign); }
437
+ .k-pop-more { color: var(--muted); }
438
+ .k-pop-note { color: var(--muted); font-size: 0.92em; }
439
+ .k-pop-hint { color: var(--muted); font-size: 0.85em; border-top: 1px solid var(--rule); padding-top: 0.35em; margin-top: 0.4em !important; }
440
+ .k-pop-state { margin-left: 0.4em; font-family: var(--font-ui); font-weight: 600; white-space: nowrap; }
441
+ .k-pop-change { color: var(--pending); }
442
+ .k-pop-same { color: var(--muted); }
443
+ .k-root::before { content: ""; display: inline-block; width: 0.6em; height: 0.6em; border-radius: 50%; background: var(--k-root); margin: 0 0.3em 0.1em 0.05em; vertical-align: middle; }
422
444
  #panel-tabs #hintk { flex: none; align-self: center; padding: 2px 6px; margin-left: 2px; }
423
445
  #panel-collapse { flex: none; align-self: center; background: none; border: none; color: var(--muted);
424
446
  font-size: var(--t-body); cursor: pointer; line-height: 1; padding: 2px 4px; }