@intentius/behold 0.11.0 → 0.12.0

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.
@@ -0,0 +1,194 @@
1
+ /**
2
+ * The run playhead's panel (#284 item 2) — the SPA half of src/run-playhead.ts.
3
+ *
4
+ * Same discipline as web/carve-steps.js, web/json-view.js and
5
+ * web/layout-store.js: everything this panel DECIDES is a pure function that
6
+ * takes data and returns data, unit-tested next door in run-playhead.test.js;
7
+ * the DOM half at the bottom is smoke/ui-smoke.mjs's job. The fetches, the SSE
8
+ * subscription and the graph re-pull all stay in app.js.
9
+ *
10
+ * What it renders, from the `runState` the server broadcasts:
11
+ * - one honest verdict line — and there is deliberately no wording for
12
+ * "probably finished". A stream that died says so.
13
+ * - the pending gate as a first-class card (#234): who is waiting, since when,
14
+ * what it holds back, and an Approve button that POSTs the EXISTING
15
+ * op-signal route. behold never signals on its own initiative.
16
+ * - the settled steps, in the order they settled.
17
+ */
18
+
19
+ /** Settled-step tones, in the same token vocabulary the apply progress panel
20
+ * uses (`APPLY_STATUS_COLOR` in app.js) so "green means it worked" reads the
21
+ * same everywhere. */
22
+ export const RUN_STEP_COLOR = {
23
+ ok: "var(--managed)",
24
+ fail: "var(--degraded)",
25
+ skipped: "var(--muted)",
26
+ };
27
+
28
+ /** How the run as a whole reads. `lost` is its own tone, not a shade of
29
+ * failure: behold does not know what happened, which is a different statement
30
+ * from "it failed". */
31
+ export const RUN_STATUS_COLOR = {
32
+ idle: "var(--muted)",
33
+ running: "var(--pending)",
34
+ ok: "var(--managed)",
35
+ failed: "var(--degraded)",
36
+ lost: "var(--foreign)",
37
+ };
38
+
39
+ /** `1062` → `1.1s`. */
40
+ export function took(ms) {
41
+ return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`;
42
+ }
43
+
44
+ /**
45
+ * How long a gate has been waiting, from its ISO `since`. Coarse on purpose —
46
+ * an approval that has been pending "3h" is the fact; the second it started
47
+ * is on the card as the raw instant.
48
+ */
49
+ export function waitingFor(since, now = Date.now()) {
50
+ const t = Date.parse(since);
51
+ if (!Number.isFinite(t)) return null;
52
+ const s = Math.max(0, Math.round((now - t) / 1000));
53
+ if (s < 60) return `${s}s`;
54
+ if (s < 3600) return `${Math.floor(s / 60)}m`;
55
+ if (s < 86400) return `${Math.floor(s / 3600)}h`;
56
+ return `${Math.floor(s / 86400)}d`;
57
+ }
58
+
59
+ /**
60
+ * The one-line verdict, or null when there is nothing to say at all.
61
+ *
62
+ * Every branch states what behold KNOWS. `lost` never borrows the word
63
+ * "finished", and an adopted run (the gate query answered for a run this
64
+ * session didn't start) doesn't claim zero settled steps as if it had watched.
65
+ */
66
+ export function runVerdict(run) {
67
+ if (!run || !run.op) return null;
68
+ const n = (run.records || []).length;
69
+ const settled = `${n} step${n === 1 ? "" : "s"} settled`;
70
+ if (run.status === "idle") {
71
+ return { text: `${run.op}: no per-step records — this session didn't start the run`, tone: "idle" };
72
+ }
73
+ if (run.status === "running") {
74
+ return {
75
+ text:
76
+ run.mode === "local"
77
+ ? `${run.op}: running on the local executor — steps arrive together when it ends`
78
+ : `${run.op}: running — ${settled}`,
79
+ tone: "running",
80
+ };
81
+ }
82
+ if (run.status === "ok") return { text: `${run.op}: exited cleanly — ${settled}`, tone: "ok" };
83
+ if (run.status === "failed") return { text: `${run.op}: a step failed — ${settled}`, tone: "failed" };
84
+ return { text: `${run.op}: stream lost — frozen at ${settled}`, tone: "lost" };
85
+ }
86
+
87
+ /** The settled steps, in arrival order, as rows. */
88
+ export function stepRows(run) {
89
+ return (run?.records || []).map((r) => ({
90
+ label: `${r.phase} · ${r.fn}`,
91
+ status: r.status,
92
+ took: r.status === "skipped" ? "skipped" : took(r.durationMs),
93
+ error: r.error || null,
94
+ }));
95
+ }
96
+
97
+ /**
98
+ * The pending gate card (#234), from the run state alone.
99
+ *
100
+ * `guards` and `node` come from the graph's own answer (`meta.gate`, built by
101
+ * src/run-playhead.ts's `pendingGateCard`) when the ops lens has been loaded;
102
+ * this is the fallback so the card still appears on the Deploy tab of a session
103
+ * that never opened the lens. `approve` is stated, not implied — it is the same
104
+ * delegated write the existing gate button performs.
105
+ */
106
+ export function gateCardFrom(run) {
107
+ if (!run || !run.op || !run.gate) return null;
108
+ return {
109
+ op: run.op,
110
+ signalName: run.gate.signalName,
111
+ description: run.gate.description || undefined,
112
+ since: run.gate.since,
113
+ approve: {
114
+ method: "POST",
115
+ path: `/api/ops/${encodeURIComponent(run.op)}/signal/${encodeURIComponent(run.gate.signalName)}`,
116
+ },
117
+ };
118
+ }
119
+
120
+ /** Does the panel have anything to show at all? */
121
+ export function hasPlayhead(run) {
122
+ return Boolean(run && (run.op || (run.records && run.records.length)));
123
+ }
124
+
125
+ // ── the DOM half ─────────────────────────────────────────────────────────────
126
+
127
+ function el(tag, cls, text) {
128
+ const n = document.createElement(tag);
129
+ if (cls) n.className = cls;
130
+ if (text !== undefined) n.textContent = text;
131
+ return n;
132
+ }
133
+
134
+ /**
135
+ * Render the playhead panel into `host` (cleared first — the whole subtree is
136
+ * rebuilt per event, exactly as renderDial() does).
137
+ *
138
+ * `card` is the graph's richer pending-gate answer when one has been loaded,
139
+ * else null. `actions.approve(op, signalName)` performs the delegated signal.
140
+ */
141
+ export function renderPlayhead(host, run, card, actions = {}) {
142
+ host.textContent = "";
143
+ const verdict = runVerdict(run);
144
+ if (!verdict) return host;
145
+
146
+ const line = el("div", "run-verdict", verdict.text);
147
+ line.style.color = RUN_STATUS_COLOR[verdict.tone] || "var(--muted)";
148
+ host.appendChild(line);
149
+
150
+ if (run.lost) host.appendChild(el("div", "run-lost", run.lost));
151
+ if (run.gateNote) host.appendChild(el("div", "run-lost", `Gate state unavailable — ${run.gateNote}`));
152
+
153
+ const gate = card || gateCardFrom(run);
154
+ if (gate) host.appendChild(renderGateCard(gate, actions));
155
+
156
+ const rows = stepRows(run);
157
+ if (rows.length) {
158
+ const list = el("div", "run-steps");
159
+ for (const r of rows) {
160
+ const row = el("div", "run-step");
161
+ const dot = el("span", "run-dot");
162
+ dot.style.background = RUN_STEP_COLOR[r.status] || "var(--muted)";
163
+ row.appendChild(dot);
164
+ row.appendChild(el("span", "grow", r.label));
165
+ row.appendChild(el("span", "run-took", r.took));
166
+ if (r.error) row.title = r.error;
167
+ list.appendChild(row);
168
+ }
169
+ host.appendChild(list);
170
+ }
171
+ return host;
172
+ }
173
+
174
+ /**
175
+ * The pending-approval card. Its button is the delegated write, and the card
176
+ * says so out loud: behold triggers Ops and signals gates a human presses; it
177
+ * never approves anything on its own initiative.
178
+ */
179
+ export function renderGateCard(gate, actions = {}) {
180
+ const box = el("div", "run-gate");
181
+ box.appendChild(el("div", "run-gate-title", `⏸ waiting for approval — ${gate.signalName}`));
182
+ if (gate.description) box.appendChild(el("div", "run-gate-desc", gate.description));
183
+ const waited = waitingFor(gate.since);
184
+ box.appendChild(
185
+ el("div", "run-gate-meta", `${gate.op} · pending ${waited ? `${waited} ` : ""}since ${gate.since}`),
186
+ );
187
+ if (gate.guards) box.appendChild(el("div", "run-gate-meta", `holds back: ${gate.guards}`));
188
+
189
+ const approve = el("button", "approve", `Approve ${gate.signalName}`);
190
+ approve.title = `${gate.approve.method} ${gate.approve.path} — the same delegated signal as chant run signal ${gate.op} ${gate.signalName}. behold never approves on its own.`;
191
+ approve.addEventListener("click", () => actions.approve?.(gate.op, gate.signalName));
192
+ box.appendChild(approve);
193
+ return box;
194
+ }
@@ -0,0 +1,134 @@
1
+ // #284 item 2: the pure half of web/run-playhead.js, tested the way
2
+ // web/carve-steps.js (#254), web/json-view.js (#259) and web/layout-store.js
3
+ // (#245) are — no DOM, no jsdom, no browser. Everything the panel DECIDES
4
+ // (what the run's verdict is, how long a gate has been waiting, what the
5
+ // approve button POSTs) is a function from data to data; the DOM half is
6
+ // smoke/ui-smoke.mjs's job.
7
+ import { describe, it, expect } from "vitest";
8
+ import { runVerdict, stepRows, gateCardFrom, hasPlayhead, took, waitingFor } from "./run-playhead.js";
9
+
10
+ const base = {
11
+ op: "playhead-probe",
12
+ mode: "temporal",
13
+ status: "running",
14
+ records: [],
15
+ gate: null,
16
+ gateNote: null,
17
+ startedAt: "2026-08-29T18:00:00.000Z",
18
+ endedAt: null,
19
+ lost: null,
20
+ };
21
+ const withRecords = (n) => ({
22
+ ...base,
23
+ records: Array.from({ length: n }, (_, i) => ({ phase: "P", fn: `f${i}`, status: "ok", durationMs: 1000 })),
24
+ });
25
+
26
+ describe("took", () => {
27
+ it("reads sub-second in ms and the rest in seconds", () => {
28
+ expect(took(5)).toBe("5ms");
29
+ expect(took(1062)).toBe("1.1s");
30
+ });
31
+ });
32
+
33
+ describe("waitingFor", () => {
34
+ const now = Date.parse("2026-08-29T18:30:00.000Z");
35
+ it("coarsens the wait, keeping the raw instant for the card", () => {
36
+ expect(waitingFor("2026-08-29T18:29:30.000Z", now)).toBe("30s");
37
+ expect(waitingFor("2026-08-29T18:00:00.000Z", now)).toBe("30m");
38
+ expect(waitingFor("2026-08-29T15:00:00.000Z", now)).toBe("3h");
39
+ expect(waitingFor("2026-08-26T18:00:00.000Z", now)).toBe("3d");
40
+ });
41
+
42
+ it("answers nothing rather than a wrong duration for an unreadable instant", () => {
43
+ expect(waitingFor("soon", now)).toBeNull();
44
+ expect(waitingFor("", now)).toBeNull();
45
+ });
46
+ });
47
+
48
+ describe("runVerdict", () => {
49
+ it("has nothing to say before anything has run", () => {
50
+ expect(runVerdict(null)).toBeNull();
51
+ expect(runVerdict({ op: null, status: "idle", records: [] })).toBeNull();
52
+ });
53
+
54
+ it("never calls a lost stream finished", () => {
55
+ const v = runVerdict({ ...withRecords(3), status: "lost", lost: "the stream ended" });
56
+ expect(v.tone).toBe("lost");
57
+ expect(v.text).toContain("frozen at 3 steps settled");
58
+ expect(v.text).not.toMatch(/complete|finished|done/i);
59
+ });
60
+
61
+ it("distinguishes a clean exit from a failed step", () => {
62
+ expect(runVerdict({ ...withRecords(2), status: "ok" })).toMatchObject({ tone: "ok" });
63
+ expect(runVerdict({ ...withRecords(2), status: "failed" }).text).toContain("a step failed");
64
+ });
65
+
66
+ it("explains why a local run shows nothing until it ends", () => {
67
+ expect(runVerdict({ ...base, mode: "local" }).text).toContain("steps arrive together when it ends");
68
+ });
69
+
70
+ it("doesn't claim to have watched a run it didn't start", () => {
71
+ const v = runVerdict({ ...base, status: "idle" });
72
+ expect(v.text).toContain("this session didn't start the run");
73
+ expect(v.text).not.toContain("0 steps");
74
+ });
75
+ });
76
+
77
+ describe("stepRows", () => {
78
+ it("keeps arrival order and labels a skip as a skip, not a duration", () => {
79
+ const rows = stepRows({
80
+ records: [
81
+ { phase: "Build", fn: "chantBuild", status: "ok", durationMs: 1062 },
82
+ { phase: "Verify", fn: "httpCheck", status: "fail", durationMs: 15164, error: "fetch failed" },
83
+ { phase: "Never", fn: "httpCheck", status: "skipped", durationMs: 0 },
84
+ ],
85
+ });
86
+ expect(rows.map((r) => `${r.label} ${r.took}`)).toEqual([
87
+ "Build · chantBuild 1.1s",
88
+ "Verify · httpCheck 15.2s",
89
+ "Never · httpCheck skipped",
90
+ ]);
91
+ expect(rows[1].error).toBe("fetch failed");
92
+ expect(rows[0].error).toBeNull();
93
+ });
94
+
95
+ it("survives a state with no records at all", () => {
96
+ expect(stepRows(null)).toEqual([]);
97
+ expect(stepRows({})).toEqual([]);
98
+ });
99
+ });
100
+
101
+ describe("gateCardFrom", () => {
102
+ it("is null unless a gate was actually NAMED — a stalled track is not a gate", () => {
103
+ expect(gateCardFrom(base)).toBeNull();
104
+ expect(gateCardFrom({ ...base, gateNote: "no run" })).toBeNull();
105
+ });
106
+
107
+ it("points approve at the existing delegated op-signal route", () => {
108
+ const card = gateCardFrom({
109
+ ...base,
110
+ op: "prod-promote",
111
+ gate: { signalName: "approve-promote", description: "Approve the prod promotion", since: "2026-08-29T18:00:12.000Z" },
112
+ });
113
+ expect(card).toEqual({
114
+ op: "prod-promote",
115
+ signalName: "approve-promote",
116
+ description: "Approve the prod promotion",
117
+ since: "2026-08-29T18:00:12.000Z",
118
+ approve: { method: "POST", path: "/api/ops/prod-promote/signal/approve-promote" },
119
+ });
120
+ });
121
+
122
+ it("escapes names into the signal path", () => {
123
+ const card = gateCardFrom({ ...base, op: "a b", gate: { signalName: "c/d", since: "x" } });
124
+ expect(card.approve.path).toBe("/api/ops/a%20b/signal/c%2Fd");
125
+ });
126
+ });
127
+
128
+ describe("hasPlayhead", () => {
129
+ it("is false on a fresh session and true once an Op is being watched", () => {
130
+ expect(hasPlayhead(null)).toBe(false);
131
+ expect(hasPlayhead({ op: null, records: [] })).toBe(false);
132
+ expect(hasPlayhead(base)).toBe(true);
133
+ });
134
+ });
@@ -0,0 +1,35 @@
1
+ // #240: a few pathological palettes (Darkermatrix and friends) leave --muted
2
+ // illegible on --panel because the quieted fg/panel mix lands well under a
3
+ // readable ratio. tokensFor() pushes --muted's OKLCH L away from --panel's
4
+ // until a real WCAG contrast() clears the floor (#229's guaranteed-visible
5
+ // trick for --active, generalised to an actual ratio). smoke/ui-smoke.mjs
6
+ // sweeps all 552 palettes for the same assertion; this pins the exact
7
+ // palette the issue was filed against, with no browser involved.
8
+ import { describe, it, expect } from "vitest";
9
+ import { contrast, desaturate, mix, tokensFor } from "./theme.js";
10
+ import { THEMES } from "./themes.js";
11
+
12
+ describe("tokensFor — the --muted contrast floor (#240)", () => {
13
+ it("Darkermatrix's naive fg/panel mix reads at ~1.25:1, the issue's reported ratio — the floor push has to move it", () => {
14
+ const th = THEMES["Darkermatrix"];
15
+ const panel = tokensFor(th).panel;
16
+ // What tokensFor() derived --muted from before #240's floor push:
17
+ // fg mixed to 0.5, one chroma step down.
18
+ const naiveMuted = desaturate(mix(th.bg, th.fg, 0.5), 0.75);
19
+ const ratio = contrast(naiveMuted, panel);
20
+ expect(ratio).toBeGreaterThan(1.2);
21
+ expect(ratio).toBeLessThan(1.3);
22
+ });
23
+
24
+ it("clears the 3:1 floor on Darkermatrix after the push", () => {
25
+ const t = tokensFor(THEMES["Darkermatrix"]);
26
+ expect(contrast(t.muted, t.panel)).toBeGreaterThanOrEqual(3);
27
+ });
28
+
29
+ it("holds the floor across every shipped palette, not just the one reported", () => {
30
+ for (const th of Object.values(THEMES)) {
31
+ const t = tokensFor(th);
32
+ expect(contrast(t.muted, t.panel)).toBeGreaterThanOrEqual(3);
33
+ }
34
+ });
35
+ });