@intentius/behold 0.29.7 → 0.29.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/dist/cli.js +1221 -765
- package/package.json +1 -1
- package/web/app.js +60 -4
- package/web/index.html +2 -0
- package/web/kiosk-annotations.js +528 -0
- package/web/kiosk-annotations.test.js +83 -0
- package/web/kiosk-approval.js +241 -0
- package/web/kiosk-approval.test.js +122 -0
- package/web/kiosk-popover.js +1 -0
- package/web/kiosk-repo.js +25 -0
- package/web/kiosk-repo.test.js +43 -0
- package/web/kiosk-roots.js +15 -7
- package/web/kiosk-runs.js +360 -25
- package/web/kiosk-runs.test.js +104 -1
- package/web/kiosk-vendor.js +397 -0
- package/web/kiosk-vendor.test.js +281 -0
- package/web/kiosk.html +160 -17
- package/web/kiosk.js +465 -51
- package/web/kiosk.test.js +73 -8
- package/web/theme.js +112 -0
- package/web/theme.test.js +51 -1
- package/web/vendors/choudoufu-128.png +0 -0
- package/web/vendors/choudoufu-64.png +0 -0
- package/web/view-repo.js +44 -0
package/web/kiosk.test.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { STEP_VIEW, STEP_WORDS, aboutRows, approveGloss, focusRoots, introText, scenarioHead, frameSrc, factLines, kioskView, legendWords, matchingPull, pendingApproval, renderKiosk, renderViewToggle, runPanel, runPin, stepView, viewZoom } from "./kiosk.js";
|
|
2
|
+
import { INTRO_HEAD, STEP_VIEW, STEP_WORDS, aboutRows, approvalPanel, approveGloss, focusRoots, introText, scenarioHead, frameSrc, factLines, kioskView, legendWords, matchingPull, pendingApproval, renderKiosk, renderViewToggle, runPanel, runPin, stepView, viewZoom } from "./kiosk.js";
|
|
3
3
|
import { PICK_HOLD_MS } from "./kiosk-runs.js";
|
|
4
4
|
|
|
5
5
|
const demo = (step, extra = {}) => ({ cycle: 1, scenario: "a-output", title: "t", step, label: "", started_at: "2026-10-15T03:00:00Z", next: null, pull: 7, commit: "abc12345", ...extra });
|
|
@@ -127,8 +127,9 @@ describe("the scenario's card under the title (behold#571)", () => {
|
|
|
127
127
|
const s = { demo: { source: "fx", read: "2026-10-15T03:00:00Z", demo: demo("failed", { scenario: "failure", about }) }, answer: null, progress: null };
|
|
128
128
|
renderKiosk(bar, s, new Date("2026-10-15T03:01:00Z"));
|
|
129
129
|
const left = find(bar, "k-left")[0];
|
|
130
|
-
|
|
131
|
-
expect(
|
|
130
|
+
// behold#571: the title, the card and the step, each in its own fixed box.
|
|
131
|
+
expect(left.children.map((c) => c.className.split(" ")[0])).toEqual(["k-box", "k-box", "k-box"]);
|
|
132
|
+
expect(left.children.map((c) => c.children.map((x) => x.className.split(" ")[0]))).toEqual([["k-title"], ["k-about"], ["k-step"]]);
|
|
132
133
|
const card = find(bar, "k-about")[0];
|
|
133
134
|
expect(card.tagName).toBe("dl");
|
|
134
135
|
expect(card.children.map((c) => [c.tagName, c.textContent])).toEqual([
|
|
@@ -140,6 +141,65 @@ describe("the scenario's card under the title (behold#571)", () => {
|
|
|
140
141
|
s.demo.demo = demo("reset", { scenario: "base", about: null });
|
|
141
142
|
renderKiosk(bar, s, new Date("2026-10-15T03:01:00Z"));
|
|
142
143
|
expect(find(bar, "k-about")).toEqual([]);
|
|
144
|
+
|
|
145
|
+
// behold#571: no intro band at a live step; between scenarios the intro takes the card's box.
|
|
146
|
+
const intro = "Terraform keeps what it built in a state file.";
|
|
147
|
+
s.demo.demo = demo("waiting", { scenario: "failure", about, intro });
|
|
148
|
+
renderKiosk(bar, s, new Date("2026-10-15T03:01:00Z"));
|
|
149
|
+
expect(find(bar, "k-intro")).toEqual([]);
|
|
150
|
+
s.demo.demo = demo("reset", { scenario: "failure", about, intro });
|
|
151
|
+
renderKiosk(bar, s, new Date("2026-10-15T03:01:00Z"));
|
|
152
|
+
const shown = find(bar, "k-intro");
|
|
153
|
+
expect(shown).toHaveLength(1);
|
|
154
|
+
expect(shown[0].children.map((c) => c.textContent)).toEqual([INTRO_HEAD, intro]);
|
|
155
|
+
expect(find(bar, "k-about")).toEqual([]);
|
|
156
|
+
} finally {
|
|
157
|
+
globalThis.document = was;
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("the approval panel's drawing (behold#571)", () => {
|
|
163
|
+
// A DOM with what the panel's chip needs: attributes, data and a click.
|
|
164
|
+
const panelDocument = () => ({
|
|
165
|
+
createElement: (tag) => {
|
|
166
|
+
const n = { tagName: tag, className: "", textContent: "", children: [], attrs: {}, dataset: {}, listeners: {} };
|
|
167
|
+
n.appendChild = (c) => (n.children.push(c), c);
|
|
168
|
+
n.append = (...cs) => cs.forEach((c) => n.children.push(typeof c === "string" ? { tagName: "#text", textContent: c, children: [] } : c));
|
|
169
|
+
n.setAttribute = (k, v) => (n.attrs[k] = v);
|
|
170
|
+
n.addEventListener = (k, f) => (n.listeners[k] = f);
|
|
171
|
+
return n;
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
const find = (n, cls) => ((n.className || "").split(" ").includes(cls) ? [n] : []).concat(...(n.children || []).map((c) => find(c, cls)));
|
|
175
|
+
|
|
176
|
+
it("draws four rows, lit as the model says, and a chip that shows its log line", () => {
|
|
177
|
+
const was = globalThis.document;
|
|
178
|
+
globalThis.document = panelDocument();
|
|
179
|
+
try {
|
|
180
|
+
const a = { id: "T11", product: "terragucci", feature: "Waiting for approval", note: "The wave stops here.", docs: { product: "terragucci", path: "/guides/approve-a-wave/" } };
|
|
181
|
+
const line = { run: 444, job: "apply-wave-2", step: 2, n: 13, a };
|
|
182
|
+
const shown = [];
|
|
183
|
+
const model = {
|
|
184
|
+
wave: 2,
|
|
185
|
+
digest: "jcs1-sha256:0accc105ba060b9974468c91def518039d935ad6629f8a15869f6e89ef748b61",
|
|
186
|
+
rows: [
|
|
187
|
+
{ id: "requested", label: "Requested", state: "lit", text: "Wave 2 asked for an approval of this plan.", at: "2026-10-15T04:09:41Z", commit: "57869649aaaa", line },
|
|
188
|
+
{ id: "approved", label: "Approved", state: "dim", text: "Next the driver signs.", command: "chant approve tf-apply wave-2" },
|
|
189
|
+
{ id: "resumed", label: "Resumed", state: "dim", text: "Then the waves run again." },
|
|
190
|
+
{ id: "spent", label: "Spent", state: "dim", text: "Then the approval is used up." },
|
|
191
|
+
],
|
|
192
|
+
};
|
|
193
|
+
const panel = approvalPanel(model, { showLine: (l) => shown.push(l) });
|
|
194
|
+
const head = find(panel, "k-ap-head")[0];
|
|
195
|
+
expect([head.textContent, ...head.children.map((c) => c.textContent)].join("")).toBe("How this hold ends · wave 2 plan 0accc105ba06 · times in UTC");
|
|
196
|
+
expect(find(panel, "k-ap-row").map((r) => r.className)).toEqual(["k-ap-row k-ap-lit", "k-ap-row k-ap-dim", "k-ap-row k-ap-dim", "k-ap-row k-ap-dim"]);
|
|
197
|
+
expect(find(panel, "k-ap-facts")[0].textContent).toBe("04:09:41 · ledger 57869649");
|
|
198
|
+
expect(find(panel, "k-command")[0].textContent).toBe("chant approve tf-apply wave-2");
|
|
199
|
+
const chip = find(panel, "r-ann")[0];
|
|
200
|
+
expect(chip.textContent).toBe("terragucci · Waiting for approval");
|
|
201
|
+
chip.listeners.click();
|
|
202
|
+
expect(shown).toEqual([line]);
|
|
143
203
|
} finally {
|
|
144
204
|
globalThis.document = was;
|
|
145
205
|
}
|
|
@@ -174,6 +234,9 @@ describe("which view the frame shows (#571)", () => {
|
|
|
174
234
|
it("opens the frame on a view's zoom", () => {
|
|
175
235
|
expect(frameSrc("https://demo.example")).toContain("zoom=resources");
|
|
176
236
|
expect(frameSrc("https://demo.example", viewZoom("components"))).toContain("zoom=components");
|
|
237
|
+
// behold#571: the kiosk's theme goes to the frame with the repo.
|
|
238
|
+
expect(frameSrc("https://demo.example", "components", "demo", "Dawnfox")).toContain("theme=Dawnfox");
|
|
239
|
+
expect(frameSrc("https://demo.example", "components", null, null)).not.toContain("theme=");
|
|
177
240
|
expect(viewZoom("resources")).toBe("resources");
|
|
178
241
|
expect(viewZoom("nonsense")).toBe("resources");
|
|
179
242
|
});
|
|
@@ -247,11 +310,13 @@ describe("the kiosk for a viewer new to the demo (behold#571)", () => {
|
|
|
247
310
|
}
|
|
248
311
|
};
|
|
249
312
|
|
|
250
|
-
it("shows the intro
|
|
251
|
-
|
|
252
|
-
expect(
|
|
253
|
-
|
|
254
|
-
expect(
|
|
313
|
+
it("shows the intro in the card's box between scenarios, never as a band, and none without one", () => {
|
|
314
|
+
// behold#571: a live step has no intro band; "What is this?" holds it.
|
|
315
|
+
expect(find(draw(demo("planned", { intro })).bar, "k-intro")).toEqual([]);
|
|
316
|
+
const shown = find(draw(demo("reset", { intro })).bar, "k-intro");
|
|
317
|
+
expect(shown).toHaveLength(1);
|
|
318
|
+
expect(shown[0].children.map((c) => c.textContent)).toEqual([INTRO_HEAD, intro]);
|
|
319
|
+
expect(find(draw(demo("reset")).bar, "k-intro")).toEqual([]);
|
|
255
320
|
expect(find(draw(demo("planned", { intro: null })).bar, "k-intro")).toEqual([]);
|
|
256
321
|
expect(introText(demo("planned", { intro: " " }))).toBe(null);
|
|
257
322
|
});
|
package/web/theme.js
CHANGED
|
@@ -186,6 +186,114 @@ export function tokensFor(th) {
|
|
|
186
186
|
// pending change).
|
|
187
187
|
runtime: th.palette[6], // cyan — runtime child (expected, not drift)
|
|
188
188
|
cat: [1, 2, 3, 4, 5, 6, 9, 10, 11, 12, 13, 14].map((i) => th.palette[i]),
|
|
189
|
+
...kioskSurfaces(th),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// --- the kiosk's surfaces (behold#571) -------------------------------------
|
|
194
|
+
// The kiosk lays several panes side by side; on one flat near-black they run
|
|
195
|
+
// together. Each region gets its own calm level instead: the page, a step up
|
|
196
|
+
// for the header's boxes, and two alternating pane levels, with a zebra tint
|
|
197
|
+
// for list rows and softened ink for text. All derived from the palette, so
|
|
198
|
+
// every theme gets them; theme.test.js holds the floors across the corpus.
|
|
199
|
+
|
|
200
|
+
/** The zebra row's tint: this share of --fg over the row's surface. */
|
|
201
|
+
export const ZEBRA = 0.035;
|
|
202
|
+
/** The surfaces' steps in OKLCH lightness from --bg, toward --fg's side. */
|
|
203
|
+
export const SURFACE_STEPS = { box: 0.03, paneA: 0.055, paneB: 0.08 };
|
|
204
|
+
/** How bright body ink may get against --bg: softer than a pure white on near-black. */
|
|
205
|
+
export const SOFT_CEILING = 13;
|
|
206
|
+
/** The least OKLCH lightness between two neighbouring levels, so each reads as its own box. */
|
|
207
|
+
export const MIN_STEP = 0.02;
|
|
208
|
+
|
|
209
|
+
/** The root dots' hues, by theme (behold#571): violet, orange, orchid, silver, tan. Never a state's. */
|
|
210
|
+
export const ROOT_HUES = {
|
|
211
|
+
dark: ["#b392f0", "#ff9f5a", "#e66ce8", "#e4e4e4", "#c89b6d"],
|
|
212
|
+
light: ["#7c3aed", "#c2410c", "#a21caf", "#334155", "#8b5a2b"],
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
/** A colour pushed until it clears `floor` against every one of `against` (worst first). */
|
|
216
|
+
function pushAll(hex, against, floor, dark) {
|
|
217
|
+
let out = hex;
|
|
218
|
+
for (let i = 0; i < 4; i++) {
|
|
219
|
+
const worst = against.reduce((w, b) => (contrast(out, b) < contrast(out, w) ? b : w), against[0]);
|
|
220
|
+
if (contrast(out, worst) >= floor) return out;
|
|
221
|
+
out = pushForContrast(out, worst, floor, dark);
|
|
222
|
+
}
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The surfaces and the ink that reads on them: `surfaceBox` (the header's
|
|
228
|
+
* boxes), `surfacePaneA` and `surfacePaneB` (the panes, alternating), each a
|
|
229
|
+
* step further from --bg in OKLCH lightness (lighter on a dark theme, darker
|
|
230
|
+
* on a light one; the other way where the palette leaves no room), `zebra`
|
|
231
|
+
* (the tint list rows alternate with), `soft` (body ink: --fg, eased toward
|
|
232
|
+
* --bg where it would glare, and never under 4.5:1 on any surface or zebra
|
|
233
|
+
* row), `mutedInk` (secondary ink, 3:1 on all of them), each state colour's
|
|
234
|
+
* `*Ink` (3:1 on all of them) and `roots` (the root dots, 3:1 on all of them).
|
|
235
|
+
*/
|
|
236
|
+
export function kioskSurfaces(th) {
|
|
237
|
+
const b = hexToOklch(th.bg);
|
|
238
|
+
const f = hexToOklch(th.fg);
|
|
239
|
+
// Toward fg's side (lighter on a dark theme), unless that runs out of room or
|
|
240
|
+
// eats the text's headroom on a mid-tone ground (Blue Dolphin, Hot Dog Stand).
|
|
241
|
+
// The ink's side: --fg's, unless even the far end of it can't clear 4.5:1 with
|
|
242
|
+
// room to spare (white on Hot Dog Stand's red): then the other side.
|
|
243
|
+
let inkUp = f.L >= b.L;
|
|
244
|
+
if (contrast(inkUp ? "#ffffff" : "#000000", th.bg) < 5.5) inkUp = !inkUp;
|
|
245
|
+
const fgSide = inkUp === f.L >= b.L;
|
|
246
|
+
const towardInk = inkUp ? 1 : -1;
|
|
247
|
+
const roomFor = (d) => (d > 0 ? b.L + 0.12 <= 0.97 : b.L - 0.12 >= 0.03);
|
|
248
|
+
const want = fgSide && contrast(th.fg, th.bg) >= 7 ? towardInk : -towardInk;
|
|
249
|
+
const dir = roomFor(want) ? want : -want;
|
|
250
|
+
// Each level at least MIN_STEP of OKLCH lightness past the one before, as the
|
|
251
|
+
// colour rounds to 8 bits (near black, small steps all round to #000000).
|
|
252
|
+
const levels = [];
|
|
253
|
+
let prev = b.L;
|
|
254
|
+
for (const dl of [SURFACE_STEPS.box, SURFACE_STEPS.paneA - SURFACE_STEPS.box, SURFACE_STEPS.paneB - SURFACE_STEPS.paneA]) {
|
|
255
|
+
let want = prev + dir * dl;
|
|
256
|
+
let hex = oklchToHex({ ...b, L: clamp(want, 0, 1) });
|
|
257
|
+
for (let i = 0; i < 40 && Math.abs(hexToOklch(hex).L - prev) < MIN_STEP; i++) {
|
|
258
|
+
want += dir * 0.005;
|
|
259
|
+
hex = oklchToHex({ ...b, L: clamp(want, 0, 1) });
|
|
260
|
+
}
|
|
261
|
+
levels.push(hex);
|
|
262
|
+
prev = hexToOklch(hex).L;
|
|
263
|
+
}
|
|
264
|
+
const [surfaceBox, surfacePaneA, surfacePaneB] = levels;
|
|
265
|
+
const surfaces = [th.bg, surfaceBox, surfacePaneA, surfacePaneB];
|
|
266
|
+
const grounds = [...surfaces, ...surfaces.map((s) => mix(s, th.fg, ZEBRA))];
|
|
267
|
+
const lightInk = inkUp;
|
|
268
|
+
// Body ink: --fg eased toward --bg until it sits at SOFT_CEILING, then held at 4.5:1 on every ground.
|
|
269
|
+
let soft = fgSide ? th.fg : inkUp ? "#ffffff" : "#000000";
|
|
270
|
+
if (fgSide && contrast(th.fg, th.bg) > SOFT_CEILING) {
|
|
271
|
+
let lo = 0, hi = 1;
|
|
272
|
+
for (let i = 0; i < 20; i++) {
|
|
273
|
+
const t = (lo + hi) / 2;
|
|
274
|
+
if (contrast(mix(th.fg, th.bg, t), th.bg) > SOFT_CEILING) lo = t;
|
|
275
|
+
else hi = t;
|
|
276
|
+
}
|
|
277
|
+
soft = mix(th.fg, th.bg, lo);
|
|
278
|
+
}
|
|
279
|
+
soft = pushAll(soft, grounds, 4.5, lightInk);
|
|
280
|
+
const muted = pushAll(desaturate(mix(th.bg, th.fg, 0.5), 0.75), grounds, 3, lightInk);
|
|
281
|
+
const P = th.palette;
|
|
282
|
+
const ink = (c) => pushAll(c, grounds, 3, lightInk);
|
|
283
|
+
const hues = th.dark ? ROOT_HUES.dark : ROOT_HUES.light;
|
|
284
|
+
return {
|
|
285
|
+
surfaceBox,
|
|
286
|
+
surfacePaneA,
|
|
287
|
+
surfacePaneB,
|
|
288
|
+
zebra: th.fg,
|
|
289
|
+
soft,
|
|
290
|
+
mutedInk: muted,
|
|
291
|
+
managedInk: ink(P[2]),
|
|
292
|
+
foreignInk: ink(P[3]),
|
|
293
|
+
pendingInk: ink(P[4]),
|
|
294
|
+
degradedInk: ink(P[1]),
|
|
295
|
+
runtimeInk: ink(P[6]),
|
|
296
|
+
roots: hues.map(ink),
|
|
189
297
|
};
|
|
190
298
|
}
|
|
191
299
|
|
|
@@ -193,6 +301,8 @@ export function tokensFor(th) {
|
|
|
193
301
|
// block in index.html mirrors these for the DEFAULT theme — smoke/ui-smoke.mjs
|
|
194
302
|
// recomputes it from here and fails if the two ever drift apart.
|
|
195
303
|
const CSS_VARS = ["bg", "panel", "line", "fg", "muted", "edge", "managed", "foreign", "pending", "degraded", "neutral", "runtime", "rule", "focus", "active", "well", "shadow"];
|
|
304
|
+
// behold#571: the kiosk's surfaces and ink, as --surface-box, --soft, --managed-ink and the like; the root dots as --root-0 … --root-4.
|
|
305
|
+
const KIOSK_VARS = { surfaceBox: "surface-box", surfacePaneA: "surface-pane-a", surfacePaneB: "surface-pane-b", zebra: "zebra", soft: "soft", mutedInk: "muted-ink", managedInk: "managed-ink", foreignInk: "foreign-ink", pendingInk: "pending-ink", degradedInk: "degraded-ink", runtimeInk: "runtime-ink" };
|
|
196
306
|
|
|
197
307
|
// pinhole (the graph painter) reads its OWN vars, `--pin-<token>` (emitted as
|
|
198
308
|
// `var(--pin-token, <baked default>)`). Derive them from the active Ghostty theme so the graph
|
|
@@ -249,6 +359,8 @@ export function applyTheme(tokens = _tokens) {
|
|
|
249
359
|
if (!tokens || typeof document === "undefined") return;
|
|
250
360
|
const root = document.documentElement;
|
|
251
361
|
for (const k of CSS_VARS) root.style.setProperty("--" + k, tokens[k]);
|
|
362
|
+
for (const [k, v] of Object.entries(KIOSK_VARS)) if (tokens[k]) root.style.setProperty("--" + v, tokens[k]);
|
|
363
|
+
(tokens.roots || []).forEach((c, i) => root.style.setProperty(`--root-${i}`, c));
|
|
252
364
|
if (_active) { const pin = pinTokensFor(_active); for (const k of PIN_VARS) root.style.setProperty("--pin-" + k, pin[k]); }
|
|
253
365
|
root.style.setProperty("color-scheme", tokens.dark ? "dark" : "light");
|
|
254
366
|
}
|
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 { contrast, desaturate, mix, tokensFor } from "./theme.js";
|
|
9
|
+
import { MIN_STEP, SOFT_CEILING, ZEBRA, contrast, desaturate, hexToOklch, mix, tokensFor } from "./theme.js";
|
|
10
10
|
import { THEMES } from "./themes.js";
|
|
11
11
|
|
|
12
12
|
describe("tokensFor — the --muted contrast floor (#240)", () => {
|
|
@@ -33,3 +33,53 @@ describe("tokensFor — the --muted contrast floor (#240)", () => {
|
|
|
33
33
|
}
|
|
34
34
|
});
|
|
35
35
|
});
|
|
36
|
+
|
|
37
|
+
// behold#571: the kiosk's surfaces. The page, the header's boxes and two
|
|
38
|
+
// alternating pane levels, a zebra tint for list rows, softened body ink, and
|
|
39
|
+
// the state and root colours as ink: every shipped palette has to come out
|
|
40
|
+
// of tokensFor() with each region its own box and every word legible on it.
|
|
41
|
+
describe("tokensFor — the kiosk's surfaces (behold#571)", () => {
|
|
42
|
+
const each = (fn) => {
|
|
43
|
+
const bad = [];
|
|
44
|
+
for (const th of Object.values(THEMES)) {
|
|
45
|
+
const t = tokensFor(th);
|
|
46
|
+
const surfaces = [th.bg, t.surfaceBox, t.surfacePaneA, t.surfacePaneB];
|
|
47
|
+
const grounds = [...surfaces, ...surfaces.map((x) => mix(x, t.zebra, ZEBRA))];
|
|
48
|
+
const why = fn(t, th, { surfaces, grounds });
|
|
49
|
+
if (why) bad.push(`${th.name}: ${why}`);
|
|
50
|
+
}
|
|
51
|
+
return bad;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
it("keeps body ink at 4.5:1 or better on every surface and zebra row", () => {
|
|
55
|
+
expect(each((t, _th, { grounds }) => grounds.filter((g) => contrast(t.soft, g) < 4.5).map((g) => `${t.soft} on ${g} is ${contrast(t.soft, g).toFixed(2)}`)[0])).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("keeps secondary ink at 3:1 or better on every surface and zebra row", () => {
|
|
59
|
+
expect(each((t, _th, { grounds }) => grounds.filter((g) => contrast(t.mutedInk, g) < 3).map((g) => `${t.mutedInk} on ${g} is ${contrast(t.mutedInk, g).toFixed(2)}`)[0])).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("keeps the state colours and the root colours at 3:1 or better on every surface and zebra row", () => {
|
|
63
|
+
const inks = (t) => [t.managedInk, t.foreignInk, t.pendingInk, t.degradedInk, t.runtimeInk, ...t.roots];
|
|
64
|
+
expect(each((t, _th, { grounds }) => inks(t).flatMap((c) => grounds.filter((g) => contrast(c, g) < 3).map((g) => `${c} on ${g}`))[0])).toEqual([]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("tells each surface from the one beside it: at least MIN_STEP of OKLCH lightness apart", () => {
|
|
68
|
+
expect(each((_t, _th, { surfaces }) => {
|
|
69
|
+
const L = surfaces.map((x) => hexToOklch(x).L);
|
|
70
|
+
const i = L.findIndex((l, k) => k > 0 && Math.abs(l - L[k - 1]) < MIN_STEP - 1e-9);
|
|
71
|
+
return i > 0 ? `${surfaces[i - 1]} and ${surfaces[i]} are ${Math.abs(L[i] - L[i - 1]).toFixed(3)} apart` : null;
|
|
72
|
+
})).toEqual([]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("softens a glaring white on near-black and leaves a calm --fg as it is", () => {
|
|
76
|
+
const glare = Object.values(THEMES).find((th) => th.fg.toLowerCase() === "#ffffff" && th.bg.toLowerCase() === "#000000");
|
|
77
|
+
const t = tokensFor(glare);
|
|
78
|
+
expect(t.soft.toLowerCase()).not.toBe("#ffffff");
|
|
79
|
+
// At the ceiling, give or take the 8-bit rounding of the eased colour.
|
|
80
|
+
expect(contrast(t.soft, glare.bg)).toBeLessThanOrEqual(SOFT_CEILING + 0.15);
|
|
81
|
+
expect(contrast(t.soft, glare.bg)).toBeLessThan(contrast(glare.fg, glare.bg));
|
|
82
|
+
const mocha = tokensFor(THEMES["Catppuccin Mocha"]);
|
|
83
|
+
expect(mocha.soft).toBe(THEMES["Catppuccin Mocha"].fg);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
Binary file
|
|
Binary file
|
package/web/view-repo.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// behold#571: the repo this page reads, on a server that serves several
|
|
2
|
+
// (`behold serve --repos`). The kiosk opens behold's own page in its frame
|
|
3
|
+
// with `?repo=<name>`; every read the page makes then carries it, so the
|
|
4
|
+
// graph, the reports and the popover are that repo's. Imported first by
|
|
5
|
+
// app.js, so the wrap is in place before the page's first read. Without
|
|
6
|
+
// `repo` in the address nothing changes.
|
|
7
|
+
|
|
8
|
+
/** The page's `repo`, or null. */
|
|
9
|
+
export function pageRepo(search) {
|
|
10
|
+
const v = new URLSearchParams(search || "").get("repo");
|
|
11
|
+
return v ? v : null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** `url` with `repo` set, when it is one of this server's /api/ reads; otherwise `url` unchanged. */
|
|
15
|
+
export function withRepo(url, repo, origin) {
|
|
16
|
+
if (!repo || typeof url !== "string") return url;
|
|
17
|
+
let u;
|
|
18
|
+
try {
|
|
19
|
+
u = new URL(url, origin);
|
|
20
|
+
} catch {
|
|
21
|
+
return url;
|
|
22
|
+
}
|
|
23
|
+
if (u.origin !== origin || !u.pathname.startsWith("/api/") || u.searchParams.has("repo")) return url;
|
|
24
|
+
u.searchParams.set("repo", repo);
|
|
25
|
+
return url.startsWith(origin) ? u.toString() : `${u.pathname}${u.search}${u.hash}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Wrap fetch and EventSource so each /api/ read carries `repo`. */
|
|
29
|
+
export function followRepo(win, repo) {
|
|
30
|
+
if (!repo || !win) return;
|
|
31
|
+
const origin = win.location.origin;
|
|
32
|
+
const fetch0 = win.fetch && win.fetch.bind(win);
|
|
33
|
+
if (fetch0) win.fetch = (input, init) => fetch0(typeof input === "string" ? withRepo(input, repo, origin) : input, init);
|
|
34
|
+
const ES = win.EventSource;
|
|
35
|
+
if (ES) {
|
|
36
|
+
win.EventSource = class extends ES {
|
|
37
|
+
constructor(url, opts) {
|
|
38
|
+
super(withRepo(String(url), repo, origin), opts);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (typeof window !== "undefined") followRepo(window, pageRepo(window.location.search));
|