@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 +1 -1
- package/web/app.js +122 -31
- package/web/theme.js +102 -3
- package/web/theme.test.js +56 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/behold",
|
|
3
|
-
"version": "0.29.
|
|
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
|
-
|
|
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
|
-
|
|
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)" :
|
|
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
|
|
5587
|
-
*
|
|
5588
|
-
*
|
|
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
|
-
//
|
|
5600
|
-
|
|
5601
|
-
|
|
5602
|
-
|
|
5603
|
-
|
|
5604
|
-
let
|
|
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
|
|
5608
|
-
|
|
5609
|
-
|
|
5610
|
-
|
|
5611
|
-
|
|
5612
|
-
|
|
5613
|
-
|
|
5614
|
-
|
|
5615
|
-
|
|
5616
|
-
|
|
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:
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|