@intentius/behold 0.29.17 → 0.29.18

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.17",
3
+ "version": "0.29.18",
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
@@ -3489,7 +3489,10 @@ function paintTgProgress() {
3489
3489
  // says which run all of it comes from. `view.focus` brings some roots forward
3490
3490
  // and dims the rest. From the pull request's plan, or from the run view once
3491
3491
  // merged. Not live; redrawn on each render and each read.
3492
- const TG_TONE = { plan: "var(--pending)", wait: "var(--foreign)", busy: "var(--runtime)", ok: "var(--managed)", bad: "var(--degraded)", idle: "var(--neutral)" };
3492
+ // behold#571: the state colours as the frame draws them on cards and boxes, each legible on every ground (theme.js frameInks).
3493
+ const TG_TONE = { plan: "var(--frame-pending, var(--pending))", wait: "var(--frame-foreign, var(--foreign))", busy: "var(--frame-runtime, var(--runtime))", ok: "var(--frame-managed, var(--managed))", bad: "var(--frame-degraded, var(--degraded))", idle: "var(--frame-neutral, var(--neutral))" };
3494
+ /** The same, for words on a card: read on the card's own ground. */
3495
+ const TG_CARD_TONE = Object.fromEntries(Object.entries(TG_TONE).map(([k, v]) => [k, v.replace("--frame-", "--frame-card-")]));
3493
3496
  function drawTgReads() {
3494
3497
  if (embedOpts.bare) queueMicrotask(kioskDecorate); // behold#571: once this draw is done
3495
3498
  watchTgRuns(); // #571: the wave boxes' jobs, while the components zoom is on screen
@@ -3748,7 +3751,7 @@ function drawTgReads() {
3748
3751
  };
3749
3752
  const edgeIn = (r) => edgePoints.filter((p) => p.x >= r.x - 3 && p.x <= r.x2 + 3 && p.y >= r.y - 3 && p.y <= r.y2 + 3).length;
3750
3753
  const defs = svg.querySelector("defs") || svg.insertBefore(make("defs", {}), svg.firstChild);
3751
- for (const [id, color] of [["tg-arrow-unknown", "var(--foreign)"], ["tg-arrow-planned", "var(--pending)"], ["tg-arrow-applied", "var(--focus)"]]) {
3754
+ for (const [id, color] of [["tg-arrow-unknown", "var(--frame-foreign, var(--foreign))"], ["tg-arrow-planned", "var(--frame-pending, var(--pending))"], ["tg-arrow-applied", "var(--frame-focus, var(--focus))"]]) {
3752
3755
  if (svg.querySelector(`#${id}`)) continue;
3753
3756
  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" });
3754
3757
  m.appendChild(make("path", { d: "M0,0 L10,5 L0,10 z", fill: color }));
@@ -3759,7 +3762,7 @@ function drawTgReads() {
3759
3762
  for (const e of edges) {
3760
3763
  const card = tgReadCardIds(tgAnswer, e).map((id) => svg.querySelector('[data-node-id="' + CSS.escape(id) + '"]')).find(Boolean);
3761
3764
  const kind = e.unknown.length ? "unknown" : e.outputs === "planned" ? "planned" : "applied";
3762
- const color = kind === "unknown" ? "var(--foreign)" : kind === "planned" ? "var(--pending)" : "var(--focus)";
3765
+ const color = kind === "unknown" ? "var(--frame-foreign, var(--foreign))" : kind === "planned" ? "var(--frame-pending, var(--pending))" : "var(--frame-focus, var(--focus))";
3763
3766
  if (card) {
3764
3767
  // The tag on the reader's card, at its bottom right, clear of the card's own "root:" line.
3765
3768
  const rect = card.querySelector("rect");
@@ -3800,7 +3803,11 @@ function drawTgReads() {
3800
3803
  drawn.push({ p0, p1 });
3801
3804
  labels.push({ e, p0, p1, color, kind });
3802
3805
  }
3803
- clearMarksOfReads(svg, drawn); // behold#571: the marks step aside from the read lines
3806
+ // behold#571: the marks step aside from the read lines, and a box lifts its marks off an arrowhead (its box then reaches higher).
3807
+ for (const [rect] of clearMarksOfReads(svg, drawn)) {
3808
+ const now = boxOf(rect);
3809
+ for (const b of boxes) if (now && Math.abs(b.x - now.x) < 0.5 && Math.abs(b.x2 - now.x2) < 0.5 && Math.abs(b.y2 - now.y2) < 0.5) b.y = now.y;
3810
+ }
3804
3811
  // Each read's words beside its line: covering no text, crossing no line,
3805
3812
  // over as few cards and box headers as can be.
3806
3813
  const cardBoxes = cardEls.map((g) => cardBoxOf.get(g)).filter(Boolean);
@@ -3990,7 +3997,7 @@ function drawTgComponentMarks(svg, { drawnAnswer, focus, boxOf, make, edgePoints
3990
3997
  const words = width(m.words, 12, 700) <= room ? m.words : m.short;
3991
3998
  sub.setAttribute("visibility", "hidden");
3992
3999
  sub.setAttribute("data-tg-hid", "1");
3993
- const t = make("text", { "data-tg-root": "card", x, y, "font-size": 12, "font-weight": 700, fill: quiet ? "var(--muted)" : tone(m.tone) }, words);
4000
+ const t = make("text", { "data-tg-root": "card", x, y, "font-size": 12, "font-weight": 700, fill: quiet ? "var(--frame-card-muted, var(--muted))" : TG_CARD_TONE[m.tone] || TG_CARD_TONE.idle }, words);
3994
4001
  if (m.state === "applying") t.setAttribute("class", "pin-pulse");
3995
4002
  if (!inFocus) t.style.opacity = "0.6";
3996
4003
  t.appendChild(make("title", {}, [`wave ${m.wave} · ${m.words}`, ...m.facts].join("\n") + "\n(from terragucci's reports, not live)"));
@@ -4093,7 +4100,7 @@ function markTerragucciCards() {
4093
4100
  tag.setAttribute("text-anchor", "end");
4094
4101
  tag.setAttribute("font-size", "11");
4095
4102
  tag.setAttribute("font-weight", "600");
4096
- tag.setAttribute("fill", corner.tone === "drift" ? "var(--degraded)" : corner.tone === "wave" ? "var(--pending)" : "var(--muted)");
4103
+ tag.setAttribute("fill", corner.tone === "drift" ? "var(--frame-card-degraded, var(--degraded))" : corner.tone === "wave" ? "var(--frame-card-pending, var(--pending))" : "var(--frame-card-muted, var(--muted))");
4097
4104
  tag.textContent = `${corner.text} · ${markAge(marks)}`;
4098
4105
  const title = document.createElementNS("http://www.w3.org/2000/svg", "title");
4099
4106
  title.setAttribute("data-tg-mark", "title");
@@ -5583,9 +5590,10 @@ function titleRowFits(row, left) {
5583
5590
  /**
5584
5591
  * The marks' own row, just under the title: the box grows upward by a row
5585
5592
  * and its title moves up into it, so no card moves and none is covered. The
5586
- * marks run left to right from the box's left, stepping past an edge where
5587
- * they can, and keep their icons: short of room they run onto another row,
5588
- * and the box grows by that one too. Only at phone width do the icons go
5593
+ * marks stay one cluster, the forge first, at the row's start or end where
5594
+ * no edge crosses it, else at its start with the edge behind. They keep
5595
+ * their icons: short of room the cluster runs onto another row, and the box
5596
+ * grows by that one too. Only at phone width do the icons go
5589
5597
  * first, while one row of names alone holds them.
5590
5598
  */
5591
5599
  function marksOwnRow(row, rect, title, grown = 0) {
@@ -5596,31 +5604,43 @@ function marksOwnRow(row, rect, title, grown = 0) {
5596
5604
  const avoid = (row.getAttribute("data-pm-avoid") || "").split(",").filter(Boolean).map(Number);
5597
5605
  const marks = [...row.querySelectorAll(":scope > .pm-mark")];
5598
5606
  const widths = markWidths(marks);
5599
- // Left to right in rows, a mark that would sit on an edge stepping past it.
5600
- const lay = (icons, step, maxRows) => {
5601
- const xs = [];
5602
- const rowOf = [];
5603
- let r = 0;
5604
- let x = left;
5607
+ // One cluster, the forge first, then the tools: in rows when one row can't hold it, each row's
5608
+ // part together. A part goes at the row's start or its end, whichever an edge doesn't cross
5609
+ // (`clear`); else at the start, any edge passing behind it.
5610
+ const lay = (icons, clear, maxRows) => {
5611
+ const parts = [[]];
5612
+ let used = 0;
5605
5613
  for (let i = 0; i < marks.length; i++) {
5606
5614
  const w = markWidth(marks[i], widths[i], icons);
5607
- const at = (x0) => {
5608
- let p = x0;
5609
- if (step) for (const e of avoid.filter((e) => e > p - 4 && e < p + w + 4).sort((a, b) => a - b)) p = Math.max(p, e + 6);
5610
- return p;
5611
- };
5612
- let p = at(x);
5613
- if (p + w > right && x > left) {
5614
- r++;
5615
- x = left;
5616
- p = at(x);
5615
+ const part = parts[parts.length - 1];
5616
+ const need = used + (part.length ? MARK_STEP : 0) + w;
5617
+ if (part.length && need > right - left) {
5618
+ parts.push([i]);
5619
+ used = w;
5620
+ } else {
5621
+ part.push(i);
5622
+ used = need;
5623
+ }
5624
+ if (w > right - left || parts.length > maxRows) return null;
5625
+ }
5626
+ const xs = [];
5627
+ const rowOf = [];
5628
+ for (const [r, part] of parts.entries()) {
5629
+ const width = part.reduce((s, i) => s + markWidth(marks[i], widths[i], icons), 0) + MARK_STEP * (part.length - 1);
5630
+ const crosses = (x0) => avoid.some((e) => e > x0 - 4 && e < x0 + width + 4);
5631
+ let x0 = left;
5632
+ if (clear) {
5633
+ const spot = [left, right - width].find((x) => !crosses(x));
5634
+ if (spot === undefined) return null;
5635
+ x0 = spot;
5636
+ }
5637
+ for (const i of part) {
5638
+ xs[i] = x0;
5639
+ rowOf[i] = r;
5640
+ x0 += markWidth(marks[i], widths[i], icons) + MARK_STEP;
5617
5641
  }
5618
- if (p + w > right || r >= maxRows) return null;
5619
- xs.push(p);
5620
- rowOf.push(r);
5621
- x = p + w + MARK_STEP;
5622
5642
  }
5623
- return { xs, rowOf, rows: r + 1 };
5643
+ return { xs, rowOf, rows: parts.length };
5624
5644
  };
5625
5645
  // Phone width is the viewer's screen: the kiosk frames this page narrow on a wide one.
5626
5646
  const phone = (() => {
@@ -5682,7 +5702,14 @@ function clearMarksOfReads(svgEl, lines) {
5682
5702
  if (f >= 0 && f <= 1) xs.push(p0.x + (p1.x - p0.x) * f - dx);
5683
5703
  }
5684
5704
  }
5685
- if (!xs.length) continue;
5705
+ if (xs.length) stepAside(row, xs, own);
5706
+ }
5707
+ return liftOffArrowheads(svgEl, lines);
5708
+ }
5709
+
5710
+ /** Lay a row out again clear of `xs` too, or leave it as it was where that doesn't fit. */
5711
+ function stepAside(row, xs, own) {
5712
+ {
5686
5713
  const was = row.getAttribute("data-pm-avoid") || "";
5687
5714
  row.setAttribute("data-pm-avoid", [was, ...xs.map((x) => x.toFixed(1))].filter(Boolean).join(","));
5688
5715
  // The old places, should the new avoidance not fit.
@@ -5699,6 +5726,70 @@ function clearMarksOfReads(svgEl, lines) {
5699
5726
  }
5700
5727
  }
5701
5728
 
5729
+ /** How far a read's arrowhead reaches back from its tip, and how wide it spreads (marker 7 x stroke 3). */
5730
+ const ARROWHEAD = { length: 22, half: 11 };
5731
+
5732
+ /**
5733
+ * No arrowhead sits on a mark. A read that ends in a box whose marks its head
5734
+ * would cover lifts the box's title and marks a row up, the box growing by
5735
+ * that row, so the head lands on open ground just over the card. Returns each
5736
+ * lifted box as [its rect, the rows it grew by].
5737
+ */
5738
+ function liftOffArrowheads(svgEl, lines) {
5739
+ const G = MARK.size + 6;
5740
+ const lifted = [];
5741
+ const hit = (a, b) => a.x < b.x2 && a.x2 > b.x && a.y < b.y2 && a.y2 > b.y;
5742
+ const heads = lines.map(({ p0, p1 }) => {
5743
+ const len = Math.hypot(p1.x - p0.x, p1.y - p0.y) || 1;
5744
+ const bx = p1.x - ((p1.x - p0.x) / len) * ARROWHEAD.length;
5745
+ const by = p1.y - ((p1.y - p0.y) / len) * ARROWHEAD.length;
5746
+ const h = ARROWHEAD.half;
5747
+ return { x: Math.min(p1.x, bx) - h, y: Math.min(p1.y, by) - h, x2: Math.max(p1.x, bx) + h, y2: Math.max(p1.y, by) + h };
5748
+ });
5749
+ for (const row of svgEl.querySelectorAll(".pipeline-marks")) {
5750
+ if (row.style.display === "none") continue;
5751
+ const m = /translate\(\s*([-\d.]+)[\s,]+([-\d.]+)/.exec((row.parentNode.getAttribute && row.parentNode.getAttribute("transform")) || "");
5752
+ const dx = m ? Number(m[1]) : 0;
5753
+ const dy = m ? Number(m[2]) : 0;
5754
+ const boxes = [...row.querySelectorAll(":scope > .pm-mark")]
5755
+ .filter((mk) => mk.style.display !== "none")
5756
+ .map((mk) => {
5757
+ try {
5758
+ const b = mk.getBBox();
5759
+ return { x: b.x + dx - 2, y: b.y + dy - 2, x2: b.x + b.width + dx + 2, y2: b.y + b.height + dy + 2 };
5760
+ } catch {
5761
+ return null;
5762
+ }
5763
+ })
5764
+ .filter(Boolean);
5765
+ if (!heads.some((h) => boxes.some((b) => hit(h, b)))) continue;
5766
+ const rect = svgEl.querySelector(`rect[data-group-id="wave-${row.getAttribute("data-pm-wave")}"]`);
5767
+ const title = row.nextElementSibling && row.nextElementSibling.tagName.toLowerCase() === "text" ? row.nextElementSibling : null;
5768
+ if (!rect) continue;
5769
+ if (!rect.hasAttribute("data-pm-y0")) {
5770
+ rect.setAttribute("data-pm-y0", rect.getAttribute("y"));
5771
+ rect.setAttribute("data-pm-h0", rect.getAttribute("height"));
5772
+ }
5773
+ if (title && !title.hasAttribute("data-pm-ty0")) title.setAttribute("data-pm-ty0", title.getAttribute("y"));
5774
+ const titleCy = title ? Number(title.getAttribute("y")) : null;
5775
+ rect.setAttribute("y", String(Number(rect.getAttribute("y")) - G));
5776
+ rect.setAttribute("height", String(Number(rect.getAttribute("height")) + G));
5777
+ if (title) title.setAttribute("y", String(titleCy - G));
5778
+ row.setAttribute("data-pm-cy", String(Number(row.getAttribute("data-pm-cy")) - G));
5779
+ for (const n of row.querySelectorAll(".pm-icon, .pm-label")) n.setAttribute("y", String(Number(n.getAttribute("y")) - G));
5780
+ // The job's status rides its title's line.
5781
+ if (title) {
5782
+ const tb = title.getBBox();
5783
+ for (const s of svgEl.querySelectorAll('[data-tg-root="wave-status"]')) {
5784
+ const y = Number(s.getAttribute("y"));
5785
+ if (Math.abs(y - (tb.y + tb.height / 2 + G + dy)) < tb.height) s.setAttribute("y", String(y - G));
5786
+ }
5787
+ }
5788
+ lifted.push([rect, G]);
5789
+ }
5790
+ return lifted;
5791
+ }
5792
+
5702
5793
  /** The x's at which the graph's edges cross a band of the svg (`y0`..`y1`), in the svg's own coordinates. */
5703
5794
  function edgeXsIn(svgEl, y0, y1) {
5704
5795
  const xs = [];
package/web/theme.js CHANGED
@@ -65,6 +65,27 @@ export function oklchToHex({ L, C, H }) {
65
65
  l2s(-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s));
66
66
  }
67
67
 
68
+ // behold#571: the most chroma an OKLCH lightness and hue holds inside sRGB,
69
+ // so a lightness walk keeps its hue by giving up chroma, not by clipping.
70
+ function oklchInGamut({ L, C, H }) {
71
+ const h = H * Math.PI / 180, a = C * Math.cos(h), bb = C * Math.sin(h);
72
+ const l = (L + 0.3963377774 * a + 0.2158037573 * bb) ** 3;
73
+ const m = (L - 0.1055613458 * a - 0.0638541728 * bb) ** 3;
74
+ const s = (L - 0.0894841775 * a - 1.2914855480 * bb) ** 3;
75
+ const rgb = [4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s];
76
+ return rgb.every((v) => v >= -1e-4 && v <= 1 + 1e-4);
77
+ }
78
+ export function oklchToHexMapped({ L, C, H }) {
79
+ if (oklchInGamut({ L, C, H })) return oklchToHex({ L, C, H });
80
+ let lo = 0, hi = C;
81
+ for (let i = 0; i < 24; i++) {
82
+ const mid = (lo + hi) / 2;
83
+ if (oklchInGamut({ L, C: mid, H })) lo = mid;
84
+ else hi = mid;
85
+ }
86
+ return oklchToHex({ L, C: lo, H });
87
+ }
88
+
68
89
  // Drop a colour's chroma without touching its lightness — the chrome's one
69
90
  // "quieter" step (#229). Used on the tokens that SURROUND the drift hues, never
70
91
  // on the drift hues themselves.
@@ -313,9 +334,26 @@ const KIOSK_VARS = { surfaceBox: "surface-box", surfacePaneA: "surface-pane-a",
313
334
  export function pinTokensFor(th) {
314
335
  const P = th.palette, fgMix = (t) => mix(th.bg, th.fg, t);
315
336
  const good = P[2], warn = P[1], accent = P[4], runtime = P[6]; // P[6] = the cyan the CSS --runtime var rides
316
- const panel = fgMix(0.18); // node card base — elevated well off the bg
337
+ // behold#571: a card a state's words can read on at 4.5:1. A mid-tone palette
338
+ // (Blue Dolphin, Grass) mixes a card even white or black can't reach that on;
339
+ // its lightness steps away from the text's side until one of them can.
340
+ const panel = (() => {
341
+ const p0 = fgMix(0.18);
342
+ const best = (c) => Math.max(contrast(c, "#ffffff"), contrast(c, "#000000"));
343
+ if (best(p0) >= 4.8) return p0;
344
+ const o = hexToOklch(p0);
345
+ const dir = contrast(p0, "#ffffff") >= contrast(p0, "#000000") ? -1 : 1;
346
+ for (let step = 1; step <= 40; step++) {
347
+ const c = oklchToHexMapped({ ...o, L: clamp(o.L + dir * step * 0.005, 0, 1) });
348
+ if (best(c) >= 4.8) return c;
349
+ }
350
+ return p0;
351
+ })(); // node card base — elevated well off the bg
317
352
  const fill = (c) => mix(panel, c, 0.4); // panel pulled strongly toward the status hue
318
- const stroke = (c) => mix(th.bg, c, 0.62);
353
+ // behold#571: a status outline is a thin line on the background and the wave box: 3:1 on both, its hue kept.
354
+ const up = hexToOklch(th.bg).L < 0.6;
355
+ const line = (c) => lightnessFor(c, [th.bg, fgMix(0.045)], 3, up);
356
+ const stroke = (c) => line(mix(th.bg, c, 0.62));
319
357
  return {
320
358
  bg0: th.bg, bg1: fgMix(0.045), dots: fgMix(0.12),
321
359
  text: th.fg, textMuted: fgMix(0.5), textFaint: fgMix(0.38), edge: fgMix(0.3),
@@ -324,9 +362,62 @@ export function pinTokensFor(th) {
324
362
  goodFill: fill(good), goodStroke: stroke(good), goodBar: good,
325
363
  warnFill: fill(warn), warnStroke: stroke(warn), warnBar: warn,
326
364
  runtimeFill: fill(runtime), runtimeStroke: stroke(runtime), runtimeBar: runtime,
327
- selectedStroke: accent,
365
+ selectedStroke: line(accent),
366
+ };
367
+ }
368
+ /**
369
+ * behold#571: the state colours as the graph frame draws them as text and thin
370
+ * lines: a card's state words and its outline, a wave box's job status and
371
+ * outline, a read's line. Each is the state's hue with its OKLCH lightness
372
+ * moved until it reads at WCAG AA (4.5:1) on the ground its words sit on, and
373
+ * at 3:1 (a line's floor) on every other ground of the frame: `box` inks for
374
+ * the background and the wave boxes, `card` inks for the cards.
375
+ */
376
+ export const FRAME_STATES = ["managed", "foreign", "pending", "degraded", "runtime", "neutral", "focus", "muted"];
377
+ export function frameGrounds(th) {
378
+ const pin = pinTokensFor(th);
379
+ return { box: [th.bg, pin.bg1], card: [pin.neutralFill] };
380
+ }
381
+ const worstOn = (c, grounds) => Math.min(...grounds.map((g) => contrast(c, g)));
382
+ /**
383
+ * `hex` with its OKLCH lightness walked, its hue kept, until it clears
384
+ * `floor` on each of `grounds` and `lineFloor` on each of `others`. Chroma
385
+ * gives way only where sRGB can't hold it at that lightness; a theme whose
386
+ * grounds leave no room at all gets white or black.
387
+ */
388
+ function lightnessFor(hex, grounds, floor, up, others = [], lineFloor = 3) {
389
+ const ok = (c) => worstOn(c, grounds) >= floor && (!others.length || worstOn(c, others) >= lineFloor);
390
+ if (ok(hex)) return hex;
391
+ const o = hexToOklch(hex);
392
+ const walk = (dir) => {
393
+ for (let step = 1; step <= 200; step++) {
394
+ const L = clamp(o.L + dir * step * 0.005, 0, 1);
395
+ const out = oklchToHexMapped({ L, C: o.C, H: o.H });
396
+ if (ok(out)) return out;
397
+ if (L === 0 || L === 1) break;
398
+ }
399
+ return null;
328
400
  };
401
+ const found = walk(up ? 1 : -1) || walk(up ? -1 : 1);
402
+ if (found) return found;
403
+ const ends = up ? ["#ffffff", "#000000"] : ["#000000", "#ffffff"];
404
+ return ends.find(ok) || ends.reduce((a, b) => (worstOn(b, grounds) > worstOn(a, grounds) ? b : a));
405
+ }
406
+ export function frameInks(th) {
407
+ const t = tokensFor(th);
408
+ const g = frameGrounds(th);
409
+ const all = [...g.box, ...g.card];
410
+ // Lighter on a ground that is dark, darker on a light one.
411
+ const up = (gs) => hexToOklch(gs[0]).L < 0.6;
412
+ const box = {};
413
+ const card = {};
414
+ for (const s of FRAME_STATES) {
415
+ box[s] = lightnessFor(t[s], g.box, 4.5, up(g.box), all);
416
+ card[s] = lightnessFor(t[s], g.card, 4.5, up(g.card), all);
417
+ }
418
+ return { box, card };
329
419
  }
420
+
330
421
  const PIN_VARS = ["bg0", "bg1", "dots", "text", "textMuted", "textFaint", "edge", "neutralFill", "neutralStroke", "neutralBar", "accentFill", "accentStroke", "accentBar", "goodFill", "goodStroke", "goodBar", "warnFill", "warnStroke", "warnBar", "runtimeFill", "runtimeStroke", "runtimeBar", "selectedStroke"];
331
422
 
332
423
  function hash(s) { let h = 2166136261; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return h >>> 0; }
@@ -362,6 +453,14 @@ export function applyTheme(tokens = _tokens) {
362
453
  for (const [k, v] of Object.entries(KIOSK_VARS)) if (tokens[k]) root.style.setProperty("--" + v, tokens[k]);
363
454
  (tokens.roots || []).forEach((c, i) => root.style.setProperty(`--root-${i}`, c));
364
455
  if (_active) { const pin = pinTokensFor(_active); for (const k of PIN_VARS) root.style.setProperty("--pin-" + k, pin[k]); }
456
+ // behold#571: the state colours the graph frame draws as text and lines, legible on every ground (frameInks).
457
+ if (_active) {
458
+ const inks = frameInks(_active);
459
+ for (const k of FRAME_STATES) {
460
+ root.style.setProperty("--frame-" + k, inks.box[k]);
461
+ root.style.setProperty("--frame-card-" + k, inks.card[k]);
462
+ }
463
+ }
365
464
  root.style.setProperty("color-scheme", tokens.dark ? "dark" : "light");
366
465
  }
367
466
 
package/web/theme.test.js CHANGED
@@ -6,7 +6,7 @@
6
6
  // sweeps all 552 palettes for the same assertion; this pins the exact
7
7
  // palette the issue was filed against, with no browser involved.
8
8
  import { describe, it, expect } from "vitest";
9
- import { MIN_STEP, SOFT_CEILING, ZEBRA, contrast, desaturate, hexToOklch, mix, tokensFor } from "./theme.js";
9
+ import { FRAME_STATES, MIN_STEP, SOFT_CEILING, ZEBRA, contrast, desaturate, frameGrounds, frameInks, hexToOklch, mix, pinTokensFor, tokensFor } from "./theme.js";
10
10
  import { THEMES } from "./themes.js";
11
11
 
12
12
  describe("tokensFor — the --muted contrast floor (#240)", () => {
@@ -83,3 +83,58 @@ describe("tokensFor — the kiosk's surfaces (behold#571)", () => {
83
83
  expect(mocha.soft).toBe(THEMES["Catppuccin Mocha"].fg);
84
84
  });
85
85
  });
86
+
87
+ // behold#571: the graph frame's state colours, as text and thin lines, in every theme.
88
+ describe("the graph frame's state colours", () => {
89
+ const all = Object.values(THEMES);
90
+ const hueGap = (a, b) => Math.min(Math.abs(a - b), 360 - Math.abs(a - b));
91
+
92
+ it("read at WCAG AA (4.5:1) on the ground their words sit on, and 3:1 as a line on every other, in every theme", () => {
93
+ const short = [];
94
+ for (const th of all) {
95
+ const inks = frameInks(th);
96
+ const g = frameGrounds(th);
97
+ for (const side of ["box", "card"]) {
98
+ for (const s of FRAME_STATES) {
99
+ const c = inks[side][s];
100
+ for (const gr of g[side]) if (contrast(c, gr) < 4.5) short.push(`${th.name} ${side} ${s} ${c} on ${gr}: ${contrast(c, gr).toFixed(2)}`);
101
+ // And as a line, 3:1 on every ground of the frame.
102
+ for (const gr of [...g.box, ...g.card]) if (contrast(c, gr) < 3) short.push(`${th.name} ${side} ${s} line ${c} on ${gr}: ${contrast(c, gr).toFixed(2)}`);
103
+ }
104
+ }
105
+ }
106
+ expect(short).toEqual([]);
107
+ });
108
+
109
+ it("draw a status outline at 3:1 or better on the background and the wave box, in every theme", () => {
110
+ const short = [];
111
+ for (const th of all) {
112
+ const pin = pinTokensFor(th);
113
+ for (const k of ["accentStroke", "goodStroke", "warnStroke", "runtimeStroke", "selectedStroke"]) {
114
+ for (const g of [pin.bg0, pin.bg1]) if (contrast(pin[k], g) < 3) short.push(`${th.name} ${k} ${pin[k]} on ${g}: ${contrast(pin[k], g).toFixed(2)}`);
115
+ }
116
+ }
117
+ expect(short).toEqual([]);
118
+ });
119
+
120
+ it("keep each state's hue where it has one, moving its lightness", () => {
121
+ const off = [];
122
+ for (const th of all) {
123
+ const t = tokensFor(th);
124
+ const inks = frameInks(th);
125
+ for (const s of FRAME_STATES) for (const side of ["box", "card"]) {
126
+ const a = hexToOklch(t[s]);
127
+ const b = hexToOklch(inks[side][s]);
128
+ if (a.C < 0.04 || b.C < 0.04) continue;
129
+ if (hueGap(a.H, b.H) > 20) off.push(`${th.name} ${side} ${s} ${t[s]} -> ${inks[side][s]}`);
130
+ }
131
+ }
132
+ expect(off).toEqual([]);
133
+ });
134
+
135
+ it("fixes Sea Shells' planned text, which read dark blue on a dark card", () => {
136
+ const th = THEMES["Sea Shells"];
137
+ expect(contrast(tokensFor(th).pending, pinTokensFor(th).neutralFill)).toBeLessThan(2);
138
+ expect(contrast(frameInks(th).card.pending, pinTokensFor(th).neutralFill)).toBeGreaterThanOrEqual(4.5);
139
+ });
140
+ });