@intentius/behold 0.29.1 → 0.29.2

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.1",
3
+ "version": "0.29.2",
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
@@ -18,7 +18,7 @@ import { renderLane } from "./terragucci-lifecycle.js";
18
18
  import { renderReleases } from "./releases.js";
19
19
  import { renderYodelLane } from "./sql-yodeler-lane.js";
20
20
  import { initTerragucciEstate } from "./terragucci-estate.js";
21
- import { changeSection as tgChangeSection, readCardIds as tgReadCardIds, readEdges as tgReadEdges, readLine as tgReadLine, readTag as tgReadTag } from "./terragucci-change.js";
21
+ import { changeSection as tgChangeSection, readCardIds as tgReadCardIds, readEdges as tgReadEdges, readLabel as tgReadLabel, readLine as tgReadLine, readTag as tgReadTag, rootStates as tgRootStates, viewCaption as tgViewCaption } from "./terragucci-change.js";
22
22
  import { auditLines as yrAuditLines, cornerText as yrCorner, datedWords as yrDated, environmentLines as yrEnvLines, joinMarks as yrJoin, legend as yrLegend, markLine as yrMarkLine } from "./yodel-report.js";
23
23
  import { cardProgress as tgCardProgress, progressLines as tgProgressLines, progressSection as tgProgressSection, stampProgress as tgStampProgress } from "./terragucci-progress.js";
24
24
  import { seconds, whyQuery, whyView } from "./why.js";
@@ -108,6 +108,7 @@ initTheme();
108
108
  const embedOpts = readEmbed(location.search);
109
109
  if (embedOpts.theme && !setTheme(embedOpts.theme, { persist: false })) console.warn(`behold: no theme named ${embedOpts.theme}`);
110
110
  if (embedOpts.embed) document.documentElement.classList.add("embed");
111
+ if (embedOpts.embed && embedOpts.bare) document.documentElement.classList.add("bare"); // #569: the graph alone, for a kiosk
111
112
  initPanel({ embed: embedOpts.embed });
112
113
  mountThemePicker(document.getElementById("panel-theme"));
113
114
 
@@ -192,10 +193,17 @@ function colourPlan(graphIr) {
192
193
  const mode = activeColourMode();
193
194
  const tokens = getTokens();
194
195
  const kindOf = new Map(graphIr.nodes.map((n) => [n.id, n.kind || n.lexicon || "node"]));
196
+ const theme = getTheme();
197
+ // #569 review: a terragucci repo's view with no drift read draws each root's
198
+ // wave state on its box, so the cards wear the neutral ground: colour there
199
+ // means a wave's state, never a category hue that reads as an error.
200
+ if (mode === "drift" && tokens && tgStatesDrawn(graphIr)) {
201
+ const neutral = theme ? pinTokensFor(theme).neutralFill : tokens.neutral;
202
+ return { mode, fillOf: () => ({ fill: neutral, unpriced: false }) };
203
+ }
195
204
  if (mode === "drift" || !tokens) {
196
205
  return { mode, fillOf: (id) => ({ fill: colorForCategory(kindOf.get(id)), unpriced: false }) };
197
206
  }
198
- const theme = getTheme();
199
207
  // The drift overlay's own neutral card ground — the colour a card wears when
200
208
  // chant could not read it. #399: an entity with no block draws THIS, never
201
209
  // the zero end of the scale, which would say "free" or "saturated" about an
@@ -210,6 +218,12 @@ function colourPlan(graphIr) {
210
218
  return { mode, domain, ramp, fillOf: (id) => fillFor(figures.get(id), domain, ramp, neutralFill) };
211
219
  }
212
220
 
221
+ /** Whether the view draws a terragucci repo's root states over cards that carry no drift read. */
222
+ function tgStatesDrawn(graphIr) {
223
+ if (!tgAnswer || tgAnswer.error || !tgAnswer.roots || !tgAnswer.roots.length) return false;
224
+ return !graphIr.nodes.some((n) => n.attrs && n.attrs._status);
225
+ }
226
+
213
227
  function recolorNodesByCategory(ir) {
214
228
  if (ir) lastGraphIr = ir;
215
229
  const graphIr = ir || lastGraphIr;
@@ -1580,7 +1594,7 @@ function wire(ir) {
1580
1594
  // member (#475) is the estate member the view is on: a host opens behold with
1581
1595
  // ?member=, and picking a card or a member box moves it. It changes no fetch;
1582
1596
  // the graph is the whole estate, framed on that member's box.
1583
- const view = { member: null, env: null, gates: null, detail: 2, components: true, logical: false, runtime: false, ops: false, tier: null, target: null, stack: null, radial: false, collapse: false, compareTo: null };
1597
+ const view = { member: null, env: null, gates: null, focus: null, run: null, detail: 2, components: true, logical: false, runtime: false, ops: false, tier: null, target: null, stack: null, radial: false, collapse: false, compareTo: null };
1584
1598
 
1585
1599
  // #182: `components` is the boot default, but a project that declares no
1586
1600
  // components renders it as ZERO nodes — the first screen was a blank graph
@@ -2473,6 +2487,8 @@ function applyWantedView(want) {
2473
2487
  if (apply.lens) colourMode = apply.lens; // not written to localStorage: the link's pick, not yours
2474
2488
  if ("radial" in apply) view.radial = apply.radial;
2475
2489
  if ("gates" in apply) view.gates = apply.gates;
2490
+ if ("focus" in apply) view.focus = apply.focus; // #569: drawn by drawTgReads, no new read
2491
+ if ("run" in apply) view.run = apply.run; // #569: which run the view draws; read in the message handler
2476
2492
  if (want.member === null) {
2477
2493
  view.member = null;
2478
2494
  selectedNodeId = null;
@@ -2496,7 +2512,11 @@ window.addEventListener("message", (e) => {
2496
2512
  if (!want) return;
2497
2513
  const lensBefore = colourMode;
2498
2514
  const gatesBefore = view.gates;
2515
+ const focusBefore = view.focus;
2516
+ const runBefore = view.run;
2499
2517
  const moved = applyWantedView(want);
2518
+ if (view.run !== runBefore) void loadTgProgress({ fresh: true }); // #569: the run the narration is about
2519
+ else if (view.focus !== focusBefore && !moved) drawTgReads(); // #569
2500
2520
  // #477: the host's env for the gate strip moved. Read it now, not on the
2501
2521
  // next `changed`; renderStatusbar below writes it into the URL.
2502
2522
  if (view.gates !== gatesBefore) loadWorkspaceGates({ force: true });
@@ -2735,6 +2755,8 @@ function syncViewUrl() {
2735
2755
  node: place.node || selectedNodeId,
2736
2756
  radial: view.radial && !view.components && !view.logical && !view.ops,
2737
2757
  gates: view.gates,
2758
+ focus: view.focus,
2759
+ run: view.run,
2738
2760
  };
2739
2761
  const defaults = { env: (projectInfo && projectInfo.currentEnv) || null, lens: savedColourMode };
2740
2762
  const next = location.pathname + writeViewQuery(location.search, state, defaults) + location.hash;
@@ -3367,8 +3389,12 @@ function watchTerragucci() {
3367
3389
  es.addEventListener("reports", () => loadTerragucci({ fresh: true }));
3368
3390
  // #511: the run view moved while a wave applies.
3369
3391
  es.addEventListener("progress", (e) => {
3370
- tgProgress = JSON.parse(e.data);
3371
- paintTgProgress();
3392
+ const pushed = JSON.parse(e.data);
3393
+ // #569: the pushed view is the newest run's; a view pinned to another run reads its own again.
3394
+ if (tgPushFits(pushed)) {
3395
+ tgProgress = pushed;
3396
+ paintTgProgress();
3397
+ } else if (view.run && view.run !== "none" && view.run !== "base") void loadTgProgress({ fresh: true });
3372
3398
  });
3373
3399
  es.addEventListener("lifecycle", (e) => {
3374
3400
  tgLifecycle = JSON.parse(e.data);
@@ -3403,29 +3429,70 @@ async function loadTerragucci({ fresh = false } = {}) {
3403
3429
  // #511: /api/terragucci/progress, the newest apply's run view on the cards.
3404
3430
  let tgProgress = null;
3405
3431
  async function loadTgProgress({ fresh = false } = {}) {
3406
- tgProgress = await apiFetch(`/api/terragucci/progress${fresh ? "?fresh=1" : ""}`)
3432
+ // #569: `run` pins the run view: a commit's, or none at all (the pull request's plan, or the base).
3433
+ const pin = view.run;
3434
+ if (pin === "none" || pin === "base") {
3435
+ tgProgress = null;
3436
+ paintTgProgress();
3437
+ return;
3438
+ }
3439
+ const q = new URLSearchParams();
3440
+ if (pin) q.set("commit", pin);
3441
+ if (fresh) q.set("fresh", "1");
3442
+ const qs = q.toString();
3443
+ const answer = await apiFetch(`/api/terragucci/progress${qs ? `?${qs}` : ""}`)
3407
3444
  .then((r) => r.json())
3408
3445
  .catch((e) => ({ error: String(e) }));
3446
+ if (view.run !== pin) return; // the pin moved while this read ran
3447
+ tgProgress = answer;
3409
3448
  paintTgProgress();
3410
3449
  }
3450
+ /** #569: whether a pushed run view is the one the view draws: any, unless `run` pins another. */
3451
+ function tgPushFits(answer) {
3452
+ const pin = view.run;
3453
+ if (!pin) return true;
3454
+ if (pin === "none" || pin === "base") return false;
3455
+ return !!(answer && answer.run && answer.run.commit && (answer.run.commit.startsWith(pin) || pin.startsWith(answer.run.commit)));
3456
+ }
3457
+ /** #569: the reports as the view draws them: at `run=base` nothing is in flight, so no pull request either. */
3458
+ function tgAnswerDrawn() {
3459
+ return view.run === "base" && tgAnswer && !tgAnswer.error ? { ...tgAnswer, pull_request: null } : tgAnswer;
3460
+ }
3411
3461
  function paintTgProgress() {
3412
3462
  tgStampProgress(document.querySelector("#graph svg"), tgProgress, new Date());
3413
3463
  drawTgReads(); // #568: the run view's reads, once the change merged
3414
3464
  renderTerragucciPanel();
3415
3465
  }
3416
- // #568: the reads edges between roots of one repo, drawn over the card view:
3417
- // a dashed line from the reader (its data card when the checkout draws one,
3418
- // else its root's cards) to the root it reads, behind the cards, plus a tag on
3419
- // the reader's data card ("reads network · planned · 1 unknown"). From the
3420
- // pull request's plan, or from the run view once merged. Not live; redrawn on
3421
- // each render, so a card dragged since moves its line on the next one.
3466
+ // #568: the reads edges between roots of one repo, drawn over the card view.
3467
+ // #569: drawn so the view shows the change without the panel: each read is a
3468
+ // line from the reader (its data card when the checkout draws one, else its
3469
+ // root's box) to the box of the root it reads, ending at the box's border,
3470
+ // arrowed, over the boxes, with what it reads written on it; each root's box
3471
+ // carries its wave and that wave's state, in the state's colour; a caption
3472
+ // says which run all of it comes from. `view.focus` brings some roots forward
3473
+ // and dims the rest. From the pull request's plan, or from the run view once
3474
+ // merged. Not live; redrawn on each render and each read.
3475
+ const TG_TONE = { plan: "var(--pending)", wait: "var(--foreign)", busy: "var(--runtime)", ok: "var(--managed)", bad: "var(--degraded)", idle: "var(--neutral)" };
3422
3476
  function drawTgReads() {
3423
3477
  const svg = document.querySelector("#graph svg");
3424
3478
  if (!svg) return;
3425
3479
  for (const old of svg.querySelectorAll("[data-tg-reads]")) old.remove();
3480
+ for (const old of svg.querySelectorAll("[data-tg-root]")) old.remove();
3481
+ for (const r of svg.querySelectorAll("rect[data-tg-stroke0]")) {
3482
+ r.setAttribute("stroke", r.getAttribute("data-tg-stroke0"));
3483
+ r.setAttribute("stroke-width", r.getAttribute("data-tg-width0") || "1.2");
3484
+ r.removeAttribute("data-tg-stroke0");
3485
+ r.removeAttribute("data-tg-width0");
3486
+ }
3487
+ for (const t of svg.querySelectorAll("[data-tg-hid]")) {
3488
+ t.removeAttribute("data-tg-hid");
3489
+ t.removeAttribute("visibility");
3490
+ }
3491
+ for (const g of svg.querySelectorAll("[data-tg-dim]")) {
3492
+ g.removeAttribute("data-tg-dim");
3493
+ g.style.opacity = "";
3494
+ }
3426
3495
  if (!tgAnswer || tgAnswer.error || !tgAnswer.roots) return;
3427
- const edges = tgReadEdges(tgAnswer, tgProgress);
3428
- if (!edges.length) return;
3429
3496
  const toSvg = svg.getScreenCTM && svg.getScreenCTM();
3430
3497
  if (!toSvg) return;
3431
3498
  const inv = toSvg.inverse();
@@ -3444,56 +3511,286 @@ function drawTgReads() {
3444
3511
  return { x: Math.min(pts[0].x, pts[1].x), y: Math.min(pts[0].y, pts[1].y), x2: Math.max(pts[0].x, pts[1].x), y2: Math.max(pts[0].y, pts[1].y) };
3445
3512
  };
3446
3513
  const union = (boxes) => (boxes.length ? { x: Math.min(...boxes.map((b) => b.x)), y: Math.min(...boxes.map((b) => b.y)), x2: Math.max(...boxes.map((b) => b.x2)), y2: Math.max(...boxes.map((b) => b.y2)) } : null);
3447
- const rootBox = (path) => {
3448
- const r = tgAnswer.roots.find((x) => x.path === path);
3449
- if (!r) return null;
3514
+ const rootOf = (path) => tgAnswer.roots.find((x) => x.path === path) || null;
3515
+ const groupRect = (path) => {
3516
+ const r = rootOf(path);
3517
+ return r ? svg.querySelector(`rect[data-group-id="${CSS.escape(r.name)}"]`) : null;
3518
+ };
3519
+ const cardsOf = (path) => {
3520
+ const r = rootOf(path);
3521
+ if (!r) return [];
3450
3522
  const prefix = `${r.member}/${r.name}/`;
3451
- return union([...svg.querySelectorAll("[data-node-id]")].filter((g) => (g.getAttribute("data-node-id") || "").startsWith(prefix)).map(boxOf).filter(Boolean));
3523
+ return [...svg.querySelectorAll("[data-node-id]")].filter((g) => (g.getAttribute("data-node-id") || "").startsWith(prefix));
3524
+ };
3525
+ const rootBox = (path) => {
3526
+ const rect = groupRect(path);
3527
+ return (rect && boxOf(rect)) || union(cardsOf(path).map(boxOf).filter(Boolean));
3452
3528
  };
3453
3529
  const NS = "http://www.w3.org/2000/svg";
3454
- const layer = document.createElementNS(NS, "g");
3455
- layer.setAttribute("data-tg-reads", "1");
3456
- layer.setAttribute("pointer-events", "none");
3530
+ const make = (tag, attrs, text) => {
3531
+ const n = document.createElementNS(NS, tag);
3532
+ for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, String(v));
3533
+ if (text !== undefined) n.textContent = text;
3534
+ return n;
3535
+ };
3536
+ const layer = make("g", { "data-tg-reads": "1", "pointer-events": "none" });
3537
+ svg.appendChild(layer); // over the boxes and cards: the lines end at the borders, and the labels carry a background
3538
+ // A pill of text with a background, centred on (cx, cy); measured once it is in the svg.
3539
+ const pill = (parent, cx, cy, text, { stroke, fill = "var(--panel)", color = "var(--fg)", size = 13, weight = 600, anchor = "middle", cls = "" } = {}) => {
3540
+ const g = make("g", cls ? { class: cls } : {});
3541
+ const rect = make("rect", { rx: 6, fill, stroke: stroke || "var(--rule)", "stroke-width": 1.5 });
3542
+ const t = make("text", { x: cx, y: cy, "text-anchor": anchor, "dominant-baseline": "central", "font-size": size, "font-weight": weight, fill: color }, text);
3543
+ g.append(rect, t);
3544
+ parent.appendChild(g);
3545
+ let w = text.length * size * 0.58;
3546
+ try {
3547
+ w = t.getComputedTextLength() || w;
3548
+ } catch {
3549
+ /* not laid out: the estimate stands */
3550
+ }
3551
+ const padX = 8;
3552
+ const h = size + 10;
3553
+ const left = anchor === "end" ? cx - w : anchor === "start" ? cx : cx - w / 2;
3554
+ rect.setAttribute("x", String(left - padX));
3555
+ rect.setAttribute("y", String(cy - h / 2));
3556
+ rect.setAttribute("width", String(w + 2 * padX));
3557
+ rect.setAttribute("height", String(h));
3558
+ return { g, w: w + 2 * padX, h };
3559
+ };
3560
+
3561
+ // The roots: each box's wave and state, in its colour.
3562
+ const drawnAnswer = tgAnswerDrawn();
3563
+ const states = tgRootStates(drawnAnswer, tgProgress);
3564
+ const focus = view.focus ? new Set(view.focus.split(",")) : null;
3565
+ const boxes = [];
3566
+ for (const r of tgAnswer.roots) {
3567
+ const rect = groupRect(r.path);
3568
+ const st = states.roots[r.path];
3569
+ const b = rootBox(r.path);
3570
+ if (b) boxes.push(b);
3571
+ if (focus && !focus.has(r.path) && !focus.has(r.name)) {
3572
+ for (const el of [rect && rect.parentElement, ...cardsOf(r.path)]) {
3573
+ if (!el) continue;
3574
+ el.setAttribute("data-tg-dim", "1");
3575
+ el.style.opacity = "0.5";
3576
+ }
3577
+ }
3578
+ if (!rect || !st || !b) continue;
3579
+ const tone = TG_TONE[st.tone] || TG_TONE.idle;
3580
+ rect.setAttribute("data-tg-stroke0", rect.getAttribute("stroke") || "");
3581
+ rect.setAttribute("data-tg-width0", rect.getAttribute("stroke-width") || "1.2");
3582
+ if (st.state !== "outside" && st.state !== "unchanged") {
3583
+ rect.setAttribute("stroke", tone);
3584
+ rect.setAttribute("stroke-width", focus && (focus.has(r.path) || focus.has(r.name)) ? "4" : "2.5");
3585
+ }
3586
+ const mark = make("g", { "data-tg-root": r.path, "pointer-events": "none" });
3587
+ if (focus && !focus.has(r.path) && !focus.has(r.name)) mark.style.opacity = "0.6";
3588
+ layer.parentNode.insertBefore(mark, layer);
3589
+ // The badge: on the box's top border, right-aligned, like a tab; the "N
3590
+ // resources" count in the header row under it gives way.
3591
+ const local = rect.getBBox();
3592
+ const headY = local.y + 23;
3593
+ const tabY = local.y;
3594
+ for (const t of svg.querySelectorAll("text")) {
3595
+ if (t.closest("[data-node-id]") || t.closest("[data-tg-root]") || t.closest("[data-tg-reads]")) continue;
3596
+ const tb = t.getBBox();
3597
+ if (/^\d+ resources?$/.test((t.textContent || "").trim()) && Math.abs(tb.y + tb.height / 2 - headY) < 12 && tb.x > local.x && tb.x < local.x + local.width) {
3598
+ t.setAttribute("visibility", "hidden");
3599
+ t.setAttribute("data-tg-hid", "1");
3600
+ }
3601
+ }
3602
+ // Filled when something happened or waits on the root; an outline when nothing has yet.
3603
+ const solid = st.state !== "outside" && st.state !== "not-started" && st.state !== "unchanged";
3604
+ const badge = pill(mark, local.x + local.width - 16, tabY, st.words, { stroke: tone, fill: solid ? tone : "var(--panel)", color: solid ? "var(--bg)" : "var(--fg)", size: 14, weight: 700, anchor: "end", cls: st.state === "applying" ? "pin-pulse" : "" });
3605
+ badge.g.appendChild(make("title", {}, [st.words, ...st.facts].join("\n") + "\n(from terragucci's reports, not live)"));
3606
+ // The facts under it, at the box's bottom right: the plan a wave waits on, its changes.
3607
+ // Packed into one line that fits the box, most telling first; the rest is in the badge's tooltip.
3608
+ if (st.facts.length) {
3609
+ // Right-aligned and at most about three fifths of the box, so a read's arrow leaving the box has room beside it.
3610
+ const room = local.width * 0.6 - 14;
3611
+ const fill = st.state === "waiting" ? tone : "var(--muted)";
3612
+ const probe = make("text", { "font-size": 12.5, "font-weight": 600 });
3613
+ mark.appendChild(probe);
3614
+ const width = (t) => {
3615
+ probe.textContent = t;
3616
+ try {
3617
+ return probe.getComputedTextLength();
3618
+ } catch {
3619
+ return t.length * 7;
3620
+ }
3621
+ };
3622
+ const lines = [];
3623
+ let cur = "";
3624
+ const more = width(" …");
3625
+ for (const f of st.facts) {
3626
+ const next = cur ? `${cur} · ${f}` : f;
3627
+ if (!cur || width(next) <= room - more) cur = next;
3628
+ else {
3629
+ lines.push(cur);
3630
+ cur = f;
3631
+ }
3632
+ }
3633
+ if (cur) lines.push(cur);
3634
+ probe.remove();
3635
+ // One line: the box's bottom padding holds no more without touching a card.
3636
+ const shown = lines.slice(0, 1);
3637
+ if (lines.length > 1) shown[0] += " …";
3638
+ shown.forEach((t, i) => {
3639
+ mark.appendChild(make("text", { x: local.x + local.width - 14, y: local.y + local.height - 10 - (shown.length - 1 - i) * 15, "text-anchor": "end", "font-size": 12.5, "font-weight": 600, fill }, t));
3640
+ });
3641
+ }
3642
+ }
3643
+
3644
+ // The reads. #569 review: no line or label crosses text. Each line is the
3645
+ // shortest one, from a point on the reader's card (or box) border to a point
3646
+ // on the read root's box border, that crosses no text and as few cards as
3647
+ // there are; each label then sits beside its line where it covers no text
3648
+ // and crosses no line.
3649
+ const edges = tgReadEdges(drawnAnswer, tgProgress);
3650
+ const centre = (b) => ({ x: (b.x + b.x2) / 2, y: (b.y + b.y2) / 2 });
3651
+ const grow = (b, d) => ({ x: b.x - d, y: b.y - d, x2: b.x2 + d, y2: b.y2 + d });
3652
+ // Whether the segment p-q passes through the box (Liang-Barsky).
3653
+ const crosses = (p, q, b) => {
3654
+ let t0 = 0;
3655
+ let t1 = 1;
3656
+ const dx = q.x - p.x;
3657
+ const dy = q.y - p.y;
3658
+ for (const [pp, qq] of [[-dx, p.x - b.x], [dx, b.x2 - p.x], [-dy, p.y - b.y], [dy, b.y2 - p.y]]) {
3659
+ if (pp === 0) {
3660
+ if (qq < 0) return false;
3661
+ continue;
3662
+ }
3663
+ const r = qq / pp;
3664
+ if (pp < 0) t0 = Math.max(t0, r);
3665
+ else t1 = Math.min(t1, r);
3666
+ if (t0 > t1) return false;
3667
+ }
3668
+ return true;
3669
+ };
3670
+ // Points along a box's four sides, its corners left out.
3671
+ const borderPoints = (b, fractions) => {
3672
+ const out = [];
3673
+ for (const f of fractions) {
3674
+ const x = b.x + (b.x2 - b.x) * f;
3675
+ const y = b.y + (b.y2 - b.y) * f;
3676
+ out.push({ x, y: b.y }, { x, y: b.y2 }, { x: b.x, y }, { x: b.x2, y });
3677
+ }
3678
+ return out;
3679
+ };
3680
+ // Text to keep clear: every text drawn so far (cards, box names, badges,
3681
+ // facts, the checkout's own edge labels), a little grown.
3682
+ const textBoxes = [...svg.querySelectorAll("text")]
3683
+ .filter((t) => !t.closest("[data-tg-reads]") && t.getAttribute("visibility") !== "hidden" && (t.textContent || "").trim())
3684
+ .map(boxOf)
3685
+ .filter(Boolean)
3686
+ .map((b) => grow(b, 3));
3687
+ const cardEls = [...svg.querySelectorAll("[data-node-id]")];
3688
+ const cardBoxOf = new Map(cardEls.map((g) => [g, boxOf(g)]));
3689
+ const defs = svg.querySelector("defs") || svg.insertBefore(make("defs", {}), svg.firstChild);
3690
+ for (const [id, color] of [["tg-arrow-unknown", "var(--foreign)"], ["tg-arrow-planned", "var(--pending)"], ["tg-arrow-applied", "var(--focus)"]]) {
3691
+ if (svg.querySelector(`#${id}`)) continue;
3692
+ const m = make("marker", { id, viewBox: "0 0 10 10", refX: 9, refY: 5, markerWidth: 7, markerHeight: 7, orient: "auto-start-reverse", "data-tg-reads": "marker" });
3693
+ m.appendChild(make("path", { d: "M0,0 L10,5 L0,10 z", fill: color }));
3694
+ defs.appendChild(m);
3695
+ }
3696
+ const labels = [];
3697
+ const drawn = [];
3457
3698
  for (const e of edges) {
3458
3699
  const card = tgReadCardIds(tgAnswer, e).map((id) => svg.querySelector('[data-node-id="' + CSS.escape(id) + '"]')).find(Boolean);
3459
- const from = (card && boxOf(card)) || rootBox(e.from);
3460
- const to = rootBox(e.to);
3700
+ const kind = e.unknown.length ? "unknown" : e.outputs === "planned" ? "planned" : "applied";
3701
+ const color = kind === "unknown" ? "var(--foreign)" : kind === "planned" ? "var(--pending)" : "var(--focus)";
3461
3702
  if (card) {
3462
- const tag = document.createElementNS(NS, "text");
3463
- tag.setAttribute("data-tg-reads", "tag");
3703
+ // The tag on the reader's card, at its bottom right, clear of the card's own "root:" line.
3464
3704
  const rect = card.querySelector("rect");
3465
3705
  const local = rect && rect.getBBox ? rect.getBBox() : null;
3466
- tag.setAttribute("x", String((local ? local.x : 0) + 10));
3467
- tag.setAttribute("y", String((local ? local.y + local.height : 60) - 9));
3468
- tag.setAttribute("font-size", "11");
3469
- tag.setAttribute("font-weight", "600");
3470
- tag.setAttribute("fill", e.unknown.length ? "var(--pending)" : "var(--muted)");
3471
- tag.textContent = tgReadTag(e);
3472
- const t = document.createElementNS(NS, "title");
3473
- t.setAttribute("data-tg-reads", "title");
3474
- t.textContent = tgReadLine(e) + "\n(from terragucci reports, not live)";
3475
- card.append(t, tag);
3706
+ const tag = make("text", { "data-tg-reads": "tag", x: (local ? local.x + local.width : 150) - 10, y: (local ? local.y + local.height : 60) - 9, "text-anchor": "end", "font-size": 11, "font-weight": 700, fill: (card.querySelector("text") && card.querySelector("text").getAttribute("fill")) || "var(--fg)", opacity: 0.8 }, tgReadTag(e));
3707
+ card.append(make("title", { "data-tg-reads": "title" }, tgReadLine(e) + "\n(from terragucci reports, not live)"), tag);
3708
+ }
3709
+ const fromBox = (card && cardBoxOf.get(card)) || (card && boxOf(card)) || rootBox(e.from);
3710
+ const toBox = rootBox(e.to);
3711
+ if (!fromBox || !toBox) continue;
3712
+ const others = cardEls.filter((g) => g !== card).map((g) => cardBoxOf.get(g)).filter(Boolean);
3713
+ let best = null;
3714
+ for (const p0 of borderPoints(fromBox, [0.25, 0.5, 0.75])) {
3715
+ for (const p1 of borderPoints(toBox, [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9])) {
3716
+ // Pull the ends a hair off the borders, so a line never counts the box it starts or ends on.
3717
+ const len = Math.hypot(p1.x - p0.x, p1.y - p0.y);
3718
+ if (len < 20) continue;
3719
+ const ux = (p1.x - p0.x) / len;
3720
+ const uy = (p1.y - p0.y) / len;
3721
+ const a = { x: p0.x + ux * 2, y: p0.y + uy * 2 };
3722
+ const z = { x: p1.x - ux * 2, y: p1.y - uy * 2 };
3723
+ // Leaving the reader inward, or reaching the read box from inside it, is no route.
3724
+ if (crosses({ x: a.x + ux * 4, y: a.y + uy * 4 }, { x: a.x + ux * 4, y: a.y + uy * 4 }, fromBox)) continue;
3725
+ if (crosses({ x: z.x - ux * 4, y: z.y - uy * 4 }, { x: z.x - ux * 4, y: z.y - uy * 4 }, toBox)) continue;
3726
+ const texts = textBoxes.filter((t) => crosses(a, z, t)).length;
3727
+ const cards = others.filter((c) => crosses(a, z, c)).length;
3728
+ const lines = drawn.filter((d) => crosses(a, z, grow({ x: Math.min(d.p0.x, d.p1.x), y: Math.min(d.p0.y, d.p1.y), x2: Math.max(d.p0.x, d.p1.x), y2: Math.max(d.p0.y, d.p1.y) }, 0)) && Math.hypot(d.p1.x - p1.x, d.p1.y - p1.y) < 30).length;
3729
+ const score = texts * 10000 + cards * 2000 + lines * 500 + len;
3730
+ if (!best || score < best.score) best = { p0, p1, score };
3731
+ }
3476
3732
  }
3477
- if (!from || !to) continue;
3478
- const line = document.createElementNS(NS, "line");
3479
- line.setAttribute("x1", String((from.x + from.x2) / 2));
3480
- line.setAttribute("y1", String((from.y + from.y2) / 2));
3481
- line.setAttribute("x2", String((to.x + to.x2) / 2));
3482
- line.setAttribute("y2", String((to.y + to.y2) / 2));
3483
- line.setAttribute("stroke", e.unknown.length ? "var(--pending)" : "var(--muted)");
3484
- line.setAttribute("stroke-width", "2");
3485
- line.setAttribute("stroke-dasharray", e.outputs === "planned" ? "6 4" : "2 4");
3486
- line.setAttribute("data-tg-read-from", e.from);
3487
- line.setAttribute("data-tg-read-to", e.to);
3488
- const t = document.createElementNS(NS, "title");
3489
- t.textContent = tgReadLine(e);
3490
- line.appendChild(t);
3733
+ if (!best) continue;
3734
+ const { p0, p1 } = best;
3735
+ const line = make("line", { x1: p0.x, y1: p0.y, x2: p1.x, y2: p1.y, stroke: color, "stroke-width": 3, "stroke-dasharray": e.outputs === "planned" ? "9 6" : "none", "marker-end": `url(#tg-arrow-${kind})`, "data-tg-read-from": e.from, "data-tg-read-to": e.to });
3736
+ line.appendChild(make("title", {}, tgReadLine(e)));
3737
+ if (focus && !(focus.has(e.from) || focus.has(e.to))) line.style.opacity = "0.5";
3491
3738
  layer.appendChild(line);
3739
+ drawn.push({ p0, p1 });
3740
+ labels.push({ e, p0, p1, color, kind });
3741
+ }
3742
+ // Each read's words beside its line: covering no text, crossing no line,
3743
+ // over as few cards and box headers as can be.
3744
+ const cardBoxes = cardEls.map((g) => cardBoxOf.get(g)).filter(Boolean);
3745
+ const headers = boxes.map((b) => ({ x: b.x, y: b.y - 14, x2: b.x2, y2: b.y + 36 }));
3746
+ const placed = [];
3747
+ const overlap = (r, b) => Math.max(0, Math.min(r.x2, b.x2) - Math.max(r.x, b.x)) * Math.max(0, Math.min(r.y2, b.y2) - Math.max(r.y, b.y));
3748
+ const sum = (r, list, k) => list.reduce((a, b) => a + k * overlap(r, b), 0);
3749
+ for (const l of labels) {
3750
+ const words = `${l.e.from} ${tgReadLabel(l.e)}`;
3751
+ const size = 13;
3752
+ const w = words.length * size * 0.56 + 16;
3753
+ const h = size + 10;
3754
+ const dx = l.p1.x - l.p0.x;
3755
+ const dy = l.p1.y - l.p0.y;
3756
+ let best = null;
3757
+ for (const t of [0.5, 0.35, 0.65, 0.2, 0.8]) {
3758
+ const m = { x: l.p0.x + dx * t, y: l.p0.y + dy * t };
3759
+ // Beside the line, then farther off it into open ground: the boxes sit close.
3760
+ const spots = [];
3761
+ for (let k = 0; k < 6; k++) {
3762
+ const off = k * (h + 6);
3763
+ spots.push(
3764
+ { x: m.x + w / 2 + 12 + off, y: m.y, off },
3765
+ { x: m.x - w / 2 - 12 - off, y: m.y, off },
3766
+ { x: m.x, y: m.y - h / 2 - 10 - off, off },
3767
+ { x: m.x, y: m.y + h / 2 + 10 + off, off },
3768
+ );
3769
+ }
3770
+ for (const c of spots) {
3771
+ const r = { x: c.x - w / 2, y: c.y - h / 2, x2: c.x + w / 2, y2: c.y + h / 2 };
3772
+ const lineHits = drawn.filter((d) => crosses(d.p0, d.p1, r)).length;
3773
+ // Outside the boxes' own span the label may leave the drawing when the view is fitted: keep it in.
3774
+ const span = union(boxes);
3775
+ const out = span ? (r.x2 - r.x) * (r.y2 - r.y) - overlap(r, { x: span.x - 40, y: span.y - 60, x2: span.x2 + 40, y2: span.y2 + 40 }) : 0;
3776
+ const score = out * 80 + lineHits * 1e6 + sum(r, textBoxes, 50) + sum(r, placed, 50) + sum(r, cardBoxes, 1) + sum(r, headers, 1.5) + sum(r, boxes, 0.15) + Math.abs(t - 0.5) * 300 + c.off * 4;
3777
+ if (!best || score < best.score) best = { c, r, score };
3778
+ }
3779
+ }
3780
+ placed.push(best.r);
3781
+ const p = pill(layer, best.c.x, best.c.y, words, { stroke: l.color, fill: "var(--bg)", color: "var(--fg)", size, weight: 600 });
3782
+ if (focus && !(focus.has(l.e.from) || focus.has(l.e.to))) p.g.style.opacity = "0.5";
3783
+ }
3784
+
3785
+ // Which run the marks come from, above the boxes.
3786
+ const caption = view.run === "base" ? "nothing in flight: the states went back to the base" : tgViewCaption(drawnAnswer, tgProgress, new Date());
3787
+ const all = union(boxes);
3788
+ if (caption && all) {
3789
+ layer.appendChild(make("text", { "data-tg-reads": "caption", x: all.x, y: all.y - 22, "font-size": 16, "font-weight": 600, fill: "var(--muted)" }, `Showing ${caption}`));
3492
3790
  }
3493
- // Behind every card: first in the svg.
3494
- svg.insertBefore(layer, svg.firstChild);
3495
3791
  }
3496
3792
  function markTerragucciCards() {
3793
+ recolorNodesByCategory(); // #569 review: the reports arrived, so the cards may go neutral
3497
3794
  const svg = document.querySelector("#graph svg");
3498
3795
  tgStampProgress(svg, tgProgress, new Date()); // #511
3499
3796
  if (!svg || !tgAnswer || !tgAnswer.cards) return;
package/web/embed.js CHANGED
@@ -7,6 +7,8 @@
7
7
  // panel and the inspect pane start folded
8
8
  // ?theme=<name> a theme for this frame only (light, dark, or any theme
9
9
  // name the picker lists); never saved
10
+ // ?bare=1 with embed: the graph alone, no panel, inspect pane,
11
+ // buttons or now line (#569, the kiosk's frame)
10
12
  // ?host=<origin> the parent's origin. Messages go only there, and only
11
13
  // messages from there are taken. No host, no messages.
12
14
  //
@@ -35,7 +37,8 @@ export function readEmbed(search) {
35
37
  }
36
38
  const t = (q.get("theme") || "").trim();
37
39
  const theme = t ? THEME_ALIASES[t.toLowerCase()] || t : null;
38
- return { embed, host, theme };
40
+ const bare = embed && /^(1|true|on|yes)$/i.test(q.get("bare") || "");
41
+ return { embed, host, theme, bare };
39
42
  }
40
43
 
41
44
  /** The message a pick sends the parent. `node` is null when a member's box was
@@ -61,9 +64,9 @@ export function viewFromMessage(event, host) {
61
64
  if (typeof v === "boolean") want.radial = v;
62
65
  continue;
63
66
  }
64
- if (k === "env" || k === "gates") {
67
+ if (k === "env" || k === "gates" || k === "focus" || k === "run") {
65
68
  // env: null or "" is the source graph. gates: null or "" hands the
66
- // gate strip back to the graph's env.
69
+ // gate strip back to the graph's env. focus: null or "" brings nothing forward. run: null or "" draws the newest run again.
67
70
  if (v === null || v === "") want[k] = null;
68
71
  else if (typeof v === "string") want[k] = v;
69
72
  continue;
package/web/embed.test.js CHANGED
@@ -7,8 +7,13 @@ describe("readEmbed (#476)", () => {
7
7
  embed: true,
8
8
  host: "http://localhost:7777",
9
9
  theme: THEME_ALIASES.dark,
10
+ bare: false,
10
11
  });
11
12
  });
13
+ it("reads bare only inside embed (#569)", () => {
14
+ expect(readEmbed("?embed=1&bare=1").bare).toBe(true);
15
+ expect(readEmbed("?bare=1").bare).toBe(false);
16
+ });
12
17
  it("takes no host that isn't an http(s) origin", () => {
13
18
  expect(readEmbed("?host=localhost").host).toBe(null);
14
19
  expect(readEmbed("?host=javascript%3Aalert(1)").host).toBe(null);
@@ -17,7 +22,7 @@ describe("readEmbed (#476)", () => {
17
22
  expect(readEmbed("?theme=Atom%20One%20Light").theme).toBe("Atom One Light");
18
23
  });
19
24
  it("is off by default", () => {
20
- expect(readEmbed("")).toEqual({ embed: false, host: null, theme: null });
25
+ expect(readEmbed("")).toEqual({ embed: false, host: null, theme: null, bare: false });
21
26
  });
22
27
  });
23
28
 
@@ -49,3 +54,12 @@ describe("viewFromMessage (#476)", () => {
49
54
  expect(viewFromMessage({ origin: host, data: { type: "behold:view" } }, null)).toBe(null);
50
55
  });
51
56
  });
57
+
58
+ describe("viewFromMessage focus (#569)", () => {
59
+ const host = "http://localhost:7777";
60
+ it("takes focus as box names, and null or empty as nothing brought forward", () => {
61
+ expect(viewFromMessage({ origin: host, data: { type: "behold:view", focus: "network,other" } }, host)).toEqual({ focus: "network,other" });
62
+ expect(viewFromMessage({ origin: host, data: { type: "behold:view", focus: null } }, host)).toEqual({ focus: null });
63
+ expect(viewFromMessage({ origin: host, data: { type: "behold:view", focus: "" } }, host)).toEqual({ focus: null });
64
+ });
65
+ });
package/web/index.html CHANGED
@@ -412,6 +412,13 @@
412
412
  .embed #panel.collapsed #panel-tabs button[data-tab] { display: none; }
413
413
  .embed #panel.collapsed { width: auto; }
414
414
  .embed #panel.collapsed #panel-tabs { gap: 4px; padding: 2px 4px; }
415
+ /* #569: ?embed=1&bare=1, the kiosk's frame: the graph alone. Nothing to
416
+ open, nothing to press, and no backdrop of its own: the page's. */
417
+ .bare #panel, .bare #inspect, .bare #inspect-reopen, .bare #zoom-toggle, .bare #back-to-infra,
418
+ .bare #nowline, .bare #ws-gates, .bare #loading-overlay { display: none !important; }
419
+ .bare #app { grid-template-columns: 1fr 0 !important; }
420
+ .bare #graph svg > rect[fill^="url(#pin-"] { display: none; }
421
+ .bare #graph svg { cursor: default; }
415
422
  #panel-tabs #hintk { flex: none; align-self: center; padding: 2px 6px; margin-left: 2px; }
416
423
  #panel-collapse { flex: none; align-self: center; background: none; border: none; color: var(--muted);
417
424
  font-size: var(--t-body); cursor: pointer; line-height: 1; padding: 2px 4px; }