@intentius/behold 0.29.6 → 0.29.7

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,234 @@
1
+ // behold#571: the kiosk frame's popover. In the kiosk the graph is framed
2
+ // bare (`?embed=1&bare=1`), with no Inspect pane, so a visitor pointing at a
3
+ // card sees what the plan does to it here instead: in the Resources view, the
4
+ // resource's planned change one attribute a line; in Roots by wave, the
5
+ // root's resources and which of them change. Pure logic: what the popover
6
+ // says, where it goes, and when it stays open. app.js draws it.
7
+ //
8
+ // The values come from the same terragucci read the Inspect pane's
9
+ // terragucci sections use (/api/terragucci: the marks on each card, which
10
+ // now carry each attribute's before and after, and what the newest applied
11
+ // wave wrote). Nothing here reads anything else.
12
+
13
+ import { PICK_HOLD_MS } from "./kiosk-runs.js";
14
+ import { clock } from "./terragucci.js";
15
+
16
+ /** How many attribute lines the popover shows before "and N more". */
17
+ export const MAX_LINES = 6;
18
+
19
+ /** How long a click keeps the popover open: as long as a click holds the view toggle. */
20
+ export const PIN_HOLD_MS = PICK_HOLD_MS;
21
+
22
+ /** The plan's action on a resource, in words for a viewer new to Terraform. */
23
+ export const ACTION_HEAD = {
24
+ create: "The plan creates this resource.",
25
+ update: "The plan changes this resource.",
26
+ replace: "The plan destroys this resource and creates it again.",
27
+ delete: "The plan destroys this resource.",
28
+ forget: "The plan drops this resource from the state and leaves it running.",
29
+ import: "The plan brings this resource into the state.",
30
+ };
31
+
32
+ export const WORDS = {
33
+ same: "The plan leaves this resource as it is.",
34
+ untouched: "This plan does not touch its root, so nothing changes here.",
35
+ noPlan: "No plan yet for this resource.",
36
+ unknown: "known after apply, so no review shows this value",
37
+ long: "too long to show here",
38
+ noneYet: "none yet",
39
+ gone: "gone",
40
+ leftIsApplied: "Left of each arrow is the value applied now.",
41
+ pinHint: "Click to keep this open.",
42
+ pinnedHint: "Click elsewhere or press Escape to close this.",
43
+ noResources: "No resources found for this root.",
44
+ noRootChange: "No resource in this root changes.",
45
+ changes: "changes",
46
+ same2: "no change",
47
+ unknown2: "known after apply",
48
+ };
49
+
50
+ /** A value as a line shows it: a string bare, anything else as JSON, cut to `max` characters. */
51
+ export function valueWords(v, max = 40) {
52
+ let s;
53
+ if (typeof v === "string") s = v === "" ? '""' : v;
54
+ else if (v === null) s = "null";
55
+ else s = JSON.stringify(v);
56
+ if (s === undefined) s = String(v);
57
+ return s.length > max ? `${s.slice(0, max - 3)}...` : s;
58
+ }
59
+
60
+ /** The instance part of an address beyond its block (`[0]`, `["a"]`), or "". */
61
+ function instanceKey(address) {
62
+ const m = /(\[[^\]]*\])$/.exec(address || "");
63
+ return m ? m[1] : "";
64
+ }
65
+
66
+ /** One attribute's line: `path: before -> after`, or the unknown and long cases. */
67
+ export function attributeLine(v, { prefix = "" } = {}) {
68
+ const name = `${prefix}${v.path}`;
69
+ if (v.unknown) return { text: `${name}: ${"before" in v ? `${valueWords(v.before)} -> ` : ""}${WORDS.unknown}`, tone: "unknown" };
70
+ if (v.long) return { text: `${name}: ${WORDS.long}`, tone: null };
71
+ const before = "before" in v ? valueWords(v.before) : WORDS.noneYet;
72
+ const after = "after" in v ? valueWords(v.after) : WORDS.gone;
73
+ return { text: `${name}: ${before} -> ${after}`, tone: null };
74
+ }
75
+
76
+ /** The mark the popover reads: a waiting wave's (the newest plan) before the pull request's plan. Drift marks are not a plan. */
77
+ export function planMark(marks) {
78
+ const list = Array.isArray(marks) ? marks : [];
79
+ return list.find((m) => m.verdict === "wave") || list.find((m) => m.verdict === "plan") || null;
80
+ }
81
+
82
+ /** The root entry of the terragucci read a card sits in: the longest `<member>/<name>/` prefix of its id. */
83
+ export function cardRoot(answer, nodeId) {
84
+ if (!answer || !Array.isArray(answer.roots) || !nodeId) return null;
85
+ let best = null;
86
+ for (const r of answer.roots) {
87
+ const prefix = `${r.member}/${r.name}/`;
88
+ if (nodeId.startsWith(prefix) && (!best || prefix.length > best.prefix.length)) best = { prefix, root: r };
89
+ }
90
+ return best ? { ...best.root, block: nodeId.slice(best.prefix.length) } : null;
91
+ }
92
+
93
+ /** Whether the read's plan holds a root: the pull request's plan, or any plan or wave report on it. */
94
+ function rootPlanned(answer, root) {
95
+ if (answer.pull_request && Array.isArray(answer.pull_request.roots)) return answer.pull_request.roots.includes(root.path);
96
+ return !!(root.plan || (root.apply && root.apply.approval === "waiting"));
97
+ }
98
+
99
+ /**
100
+ * What the popover says about one resource card (the Resources view):
101
+ * `{ address, root, head, lines, more, applied, note }`. `lines` is at most
102
+ * MAX_LINES `{ text, tone }`, `more` how many it left out. `applied`, when the
103
+ * root's newest applied wave wrote any of the attributes that change,
104
+ * `{ when, lines }`; else `note` says the left of each arrow is what is
105
+ * applied now. Null when the card is not one of the read's roots.
106
+ */
107
+ export function resourcePopover(answer, nodeId, now = new Date()) {
108
+ if (!answer || answer.error) return null;
109
+ const at = cardRoot(answer, nodeId);
110
+ if (!at) return null;
111
+ const marks = answer.cards ? answer.cards[nodeId] : null;
112
+ const mark = planMark(marks);
113
+ const address = mark && mark.addresses && mark.addresses.length === 1 ? mark.addresses[0] : at.block.split("/").join(".");
114
+ const out = { address, root: at.name, head: "", lines: [], more: 0, applied: null, note: null, change: false, unknown: false };
115
+ if (!mark) {
116
+ out.head = rootPlanned(answer, at) ? WORDS.same : answer.pull_request || at.plan ? WORDS.untouched : WORDS.noPlan;
117
+ return out;
118
+ }
119
+ out.change = true;
120
+ out.head = ACTION_HEAD[mark.action] || ACTION_HEAD.update;
121
+ const values = Array.isArray(mark.values) ? mark.values : [];
122
+ const several = (mark.addresses || []).length > 1;
123
+ const lines = values.map((v) => attributeLine(v, { prefix: several ? `${instanceKey(v.address) || v.address} ` : "" }));
124
+ out.unknown = values.some((v) => v.unknown);
125
+ out.lines = lines.slice(0, MAX_LINES);
126
+ out.more = Math.max(0, lines.length - MAX_LINES);
127
+ // What the state holds now: the newest applied wave's values, when it wrote one of these attributes after this plan.
128
+ const applied = answer.applied ? answer.applied[nodeId] : null;
129
+ const paths = new Set(values.map((v) => v.path));
130
+ const fresh = applied && applied.run && (!mark.run || !mark.run.finished || applied.run.finished >= mark.run.finished);
131
+ const wrote = fresh ? applied.values.filter((v) => paths.has(v.path) && !v.unknown && "after" in v) : [];
132
+ if (wrote.length) {
133
+ out.applied = {
134
+ when: `The applied state holds these values now, from wave ${applied.run.wave ?? "?"} at ${clock(applied.run.finished, now)}.`,
135
+ lines: wrote.slice(0, MAX_LINES).map((v) => ({ text: `${v.path}: ${valueWords(v.after)}`, tone: null })),
136
+ };
137
+ } else if (values.some((v) => "before" in v)) out.note = WORDS.leftIsApplied;
138
+ return out;
139
+ }
140
+
141
+ /** A card id for a resource of the components zoom's resources facet (`<root>/<block>`): `<member>/<root>/<block>`. */
142
+ export function resourceCardId(answer, root, resourceId) {
143
+ const r = answer && Array.isArray(answer.roots) ? answer.roots.find((x) => x.name === root || x.path === root) : null;
144
+ if (!r) return null;
145
+ const rest = resourceId.startsWith(`${r.name}/`) ? resourceId.slice(r.name.length + 1) : resourceId;
146
+ return `${r.member}/${r.name}/${rest}`;
147
+ }
148
+
149
+ /**
150
+ * What the popover says about one root's card (Roots by wave): its
151
+ * resources, each "changes", "no change" or "known after apply", and how
152
+ * many change. `resources` is the components zoom's resources facet for the
153
+ * card (`[{ kind, id }]`).
154
+ */
155
+ export function rootPopover(answer, root, resources) {
156
+ const list = Array.isArray(resources) ? resources : [];
157
+ const rows = list.map((res) => {
158
+ const id = answer && !answer.error ? resourceCardId(answer, root, res.id) : null;
159
+ const mark = id && answer.cards ? planMark(answer.cards[id]) : null;
160
+ const address = res.id.startsWith(`${root}/`) ? res.id.slice(root.length + 1).split("/").join(".") : res.id;
161
+ if (!mark) return { address, state: "same", words: WORDS.same2 };
162
+ const unknown = (mark.values || []).some((v) => v.unknown);
163
+ return { address, state: unknown ? "unknown" : "change", words: unknown ? WORDS.unknown2 : WORDS.changes };
164
+ });
165
+ const changed = rows.filter((r) => r.state !== "same").length;
166
+ const head = !rows.length ? WORDS.noResources : changed === 0 ? WORDS.noRootChange : `${changed} of ${rows.length} ${rows.length === 1 ? "resource changes" : "resources change"}.`;
167
+ const shown = rows.slice(0, MAX_LINES + 2);
168
+ return { root, head, rows: shown, more: rows.length - shown.length, changed };
169
+ }
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // Open, pinned, closed. Nothing shows until someone points: a hover shows the
173
+ // card's popover while the pointer is on it; a click pins it, and it stays
174
+ // until a click elsewhere, Escape, or PIN_HOLD_MS after the pin.
175
+
176
+ /** The popover's state before anyone points: nothing shown. */
177
+ export function popState() {
178
+ return { shown: null, pinned: false, at: 0 };
179
+ }
180
+
181
+ /**
182
+ * The next state after an event: `{ type: "enter" | "leave" | "click", id }`,
183
+ * `{ type: "outside" }` (a click anywhere but the shown card or the popover),
184
+ * `{ type: "escape" }`, or `{ type: "tick" }`. `now` in ms.
185
+ */
186
+ export function popReduce(s, ev, now) {
187
+ switch (ev.type) {
188
+ case "enter":
189
+ return s.pinned ? s : { shown: ev.id, pinned: false, at: 0 };
190
+ case "leave":
191
+ return s.pinned || s.shown !== ev.id ? s : popState();
192
+ case "click":
193
+ return { shown: ev.id, pinned: true, at: now };
194
+ case "outside":
195
+ case "escape":
196
+ return popState();
197
+ case "tick":
198
+ return s.pinned && now - s.at >= PIN_HOLD_MS ? popState() : s;
199
+ default:
200
+ return s;
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Where the popover goes: beside the card, never over it, inside the frame.
206
+ * Right of the card when it fits, else left, else below, else above; along
207
+ * that side, as near the card's top (or left) as the frame allows. When no
208
+ * side fits, the side with the most room, kept inside the frame. All boxes
209
+ * are `{ left, top, width, height }` in the frame's pixels.
210
+ */
211
+ export function placePopover(card, pop, frame, { gap = 12, margin = 8 } = {}) {
212
+ const clamp = (v, lo, hi) => Math.max(lo, Math.min(v, Math.max(lo, hi)));
213
+ const right = card.left + card.width;
214
+ const bottom = card.top + card.height;
215
+ const room = {
216
+ right: frame.width - margin - (right + gap),
217
+ left: card.left - gap - margin,
218
+ below: frame.height - margin - (bottom + gap),
219
+ above: card.top - gap - margin,
220
+ };
221
+ const at = (side) => {
222
+ if (side === "right") return { left: right + gap, top: clamp(card.top, margin, frame.height - margin - pop.height), side };
223
+ if (side === "left") return { left: card.left - gap - pop.width, top: clamp(card.top, margin, frame.height - margin - pop.height), side };
224
+ if (side === "below") return { left: clamp(card.left, margin, frame.width - margin - pop.width), top: bottom + gap, side };
225
+ return { left: clamp(card.left, margin, frame.width - margin - pop.width), top: card.top - gap - pop.height, side };
226
+ };
227
+ if (room.right >= pop.width) return at("right");
228
+ if (room.left >= pop.width) return at("left");
229
+ if (room.below >= pop.height) return at("below");
230
+ if (room.above >= pop.height) return at("above");
231
+ const best = Object.entries({ right: room.right / pop.width, left: room.left / pop.width, below: room.below / pop.height, above: room.above / pop.height }).sort((a, b) => b[1] - a[1])[0][0];
232
+ const p = at(best);
233
+ return { left: clamp(p.left, margin, frame.width - margin - pop.width), top: clamp(p.top, margin, frame.height - margin - pop.height), side: best };
234
+ }
@@ -0,0 +1,180 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { MAX_LINES, PIN_HOLD_MS, WORDS, attributeLine, placePopover, popReduce, popState, resourcePopover, rootPopover, valueWords } from "./kiosk-popover.js";
3
+
4
+ // behold#571: the shape /api/terragucci answers (src/terragucci-overlay.ts):
5
+ // the checkout's roots, the marks on each card with each attribute's values,
6
+ // and what the newest applied wave wrote.
7
+ const run = (o = {}) => ({ stage: "tf-plan", finished: "2026-10-20T10:01:40.000Z", commit: "a1b2c3d4", pull_request: "41", report: "r.html", ...o });
8
+ const answer = (o = {}) => ({
9
+ roots: [
10
+ { member: "repo", name: "network", path: "network", plan: { changes: 1 } },
11
+ { member: "repo", name: "app", path: "app", plan: { changes: 1 } },
12
+ { member: "repo", name: "other", path: "other" },
13
+ ],
14
+ pull_request: { roots: ["network", "app"] },
15
+ cards: {
16
+ "repo/network/terraform_data.http_proxy_port": [
17
+ {
18
+ verdict: "plan",
19
+ action: "update",
20
+ words: "would update",
21
+ addresses: ["terraform_data.http_proxy_port"],
22
+ attributes: ["input"],
23
+ values: [{ address: "terraform_data.http_proxy_port", path: "input", before: 8080, after: 9090 }],
24
+ run: run(),
25
+ },
26
+ ],
27
+ "repo/app/terraform_data.listener_port": [
28
+ {
29
+ verdict: "plan",
30
+ action: "update",
31
+ words: "would update",
32
+ addresses: ["terraform_data.listener_port"],
33
+ attributes: ["input"],
34
+ values: [{ address: "terraform_data.listener_port", path: "input", before: 8070, unknown: true }],
35
+ run: run(),
36
+ },
37
+ ],
38
+ },
39
+ applied: {},
40
+ ...o,
41
+ });
42
+
43
+ describe("what the popover says about a resource (behold#571)", () => {
44
+ it("says a change one attribute a line, before -> after, and that the left is what is applied now", () => {
45
+ const p = resourcePopover(answer(), "repo/network/terraform_data.http_proxy_port");
46
+ expect(p).toMatchObject({ address: "terraform_data.http_proxy_port", root: "network", head: "The plan changes this resource.", change: true, unknown: false, more: 0 });
47
+ expect(p.lines).toEqual([{ text: "input: 8080 -> 9090", tone: null }]);
48
+ expect(p.note).toBe(WORDS.leftIsApplied);
49
+ expect(p.applied).toBeNull();
50
+ });
51
+
52
+ it("says an attribute known only after apply in the unknown tone, and that no review shows it", () => {
53
+ const p = resourcePopover(answer(), "repo/app/terraform_data.listener_port");
54
+ expect(p.unknown).toBe(true);
55
+ expect(p.lines).toEqual([{ text: "input: 8070 -> known after apply, so no review shows this value", tone: "unknown" }]);
56
+ expect(attributeLine({ path: "input", unknown: true })).toEqual({ text: "input: known after apply, so no review shows this value", tone: "unknown" });
57
+ });
58
+
59
+ it("says no change for a card the plan leaves alone, and that a root the plan does not hold is untouched", () => {
60
+ const a = answer();
61
+ expect(resourcePopover(a, "repo/network/terraform_data.release")).toMatchObject({ address: "terraform_data.release", head: WORDS.same, lines: [], change: false });
62
+ expect(resourcePopover(a, "repo/other/terraform_data.backup_schedule")).toMatchObject({ root: "other", head: WORDS.untouched, change: false });
63
+ expect(resourcePopover({ roots: a.roots, cards: {} }, "repo/other/terraform_data.backup_schedule").head).toBe(WORDS.noPlan);
64
+ expect(resourcePopover(a, "elsewhere/x")).toBeNull();
65
+ expect(resourcePopover({ error: "no" }, "repo/network/terraform_data.release")).toBeNull();
66
+ });
67
+
68
+ it("caps the lines at six and counts the rest", () => {
69
+ const values = Array.from({ length: 9 }, (_, i) => ({ address: "terraform_data.http_proxy_port", path: `a${i}`, before: i, after: i + 1 }));
70
+ const a = answer();
71
+ a.cards["repo/network/terraform_data.http_proxy_port"][0].values = values;
72
+ const p = resourcePopover(a, "repo/network/terraform_data.http_proxy_port");
73
+ expect(p.lines).toHaveLength(MAX_LINES);
74
+ expect(p.more).toBe(3);
75
+ });
76
+
77
+ it("shows what the newest applied wave wrote for the attributes that change, when it applied after the plan", () => {
78
+ const applied = { "repo/network/terraform_data.http_proxy_port": { values: [{ address: "terraform_data.http_proxy_port", path: "input", before: 8080, after: 9090 }, { address: "x", path: "unrelated", after: 1 }], run: run({ stage: "tf-apply", wave: 1, finished: "2026-10-20T10:04:10.000Z" }) } };
79
+ const p = resourcePopover(answer({ applied }), "repo/network/terraform_data.http_proxy_port", new Date("2026-10-20T11:00:00Z"));
80
+ expect(p.applied).toEqual({ when: "The applied state holds these values now, from wave 1 at 10:04 UTC.", lines: [{ text: "input: 9090", tone: null }] });
81
+ expect(p.note).toBeNull();
82
+ // An apply older than the plan says nothing of now.
83
+ const old = { "repo/network/terraform_data.http_proxy_port": { ...applied["repo/network/terraform_data.http_proxy_port"], run: run({ stage: "tf-apply", wave: 1, finished: "2026-10-19T00:00:00.000Z" }) } };
84
+ expect(resourcePopover(answer({ applied: old }), "repo/network/terraform_data.http_proxy_port").applied).toBeNull();
85
+ });
86
+
87
+ it("writes values plainly: a string bare, the rest as JSON, cut short", () => {
88
+ expect(valueWords("http://app:9080")).toBe("http://app:9080");
89
+ expect(valueWords("")).toBe('""');
90
+ expect(valueWords(null)).toBe("null");
91
+ expect(valueWords({ a: "x".repeat(60) })).toHaveLength(40);
92
+ expect(attributeLine({ path: "input", after: 1 }).text).toBe("input: none yet -> 1");
93
+ expect(attributeLine({ path: "input", before: 1 }).text).toBe("input: 1 -> gone");
94
+ expect(attributeLine({ path: "policy", long: true }).text).toBe("policy: too long to show here");
95
+ });
96
+ });
97
+
98
+ describe("what the popover says about a root, in Roots by wave (behold#571)", () => {
99
+ it("lists its resources, each changes, no change or known after apply, and counts the changes", () => {
100
+ const resources = [
101
+ { kind: "terraform_remote_state", id: "app/data.terraform_remote_state.network" },
102
+ { kind: "terraform_data", id: "app/terraform_data.listener_port" },
103
+ ];
104
+ const p = rootPopover(answer(), "app", resources);
105
+ expect(p.head).toBe("1 of 2 resources change.");
106
+ expect(p.rows).toEqual([
107
+ { address: "data.terraform_remote_state.network", state: "same", words: "no change" },
108
+ { address: "terraform_data.listener_port", state: "unknown", words: "known after apply" },
109
+ ]);
110
+ const net = rootPopover(answer(), "network", [{ kind: "terraform_data", id: "network/terraform_data.http_proxy_port" }]);
111
+ expect(net.rows[0]).toMatchObject({ state: "change", words: "changes" });
112
+ expect(net.head).toBe("1 of 1 resource changes.");
113
+ expect(rootPopover(answer(), "other", [{ kind: "terraform_data", id: "other/terraform_data.backup_schedule" }]).head).toBe(WORDS.noRootChange);
114
+ expect(rootPopover(answer(), "other", []).head).toBe(WORDS.noResources);
115
+ });
116
+ });
117
+
118
+ describe("when the popover shows (behold#571)", () => {
119
+ it("shows nothing until someone points, follows the pointer, and hides when it leaves", () => {
120
+ let s = popState();
121
+ expect(s.shown).toBeNull();
122
+ s = popReduce(s, { type: "enter", id: "a" }, 0);
123
+ expect(s).toEqual({ shown: "a", pinned: false, at: 0 });
124
+ s = popReduce(s, { type: "enter", id: "b" }, 0);
125
+ expect(s.shown).toBe("b");
126
+ expect(popReduce(s, { type: "leave", id: "a" }, 0)).toBe(s);
127
+ expect(popReduce(s, { type: "leave", id: "b" }, 0).shown).toBeNull();
128
+ });
129
+
130
+ it("pins on a click, keeps the pinned card while the pointer moves, and closes on a click elsewhere or Escape", () => {
131
+ let s = popReduce(popState(), { type: "click", id: "a" }, 1000);
132
+ expect(s).toEqual({ shown: "a", pinned: true, at: 1000 });
133
+ expect(popReduce(s, { type: "enter", id: "b" }, 2000)).toBe(s);
134
+ expect(popReduce(s, { type: "leave", id: "a" }, 2000)).toBe(s);
135
+ expect(popReduce(s, { type: "outside" }, 2000)).toEqual(popState());
136
+ expect(popReduce(s, { type: "escape" }, 2000)).toEqual(popState());
137
+ // Another card's click moves the pin to it.
138
+ s = popReduce(s, { type: "click", id: "b" }, 3000);
139
+ expect(s).toEqual({ shown: "b", pinned: true, at: 3000 });
140
+ });
141
+
142
+ it("lets a pin go 90 seconds after the click, as the view toggle's hold does", () => {
143
+ expect(PIN_HOLD_MS).toBe(90_000);
144
+ const s = popReduce(popState(), { type: "click", id: "a" }, 1000);
145
+ expect(popReduce(s, { type: "tick" }, 1000 + PIN_HOLD_MS - 1)).toBe(s);
146
+ expect(popReduce(s, { type: "tick" }, 1000 + PIN_HOLD_MS)).toEqual(popState());
147
+ const hovered = popReduce(popState(), { type: "enter", id: "a" }, 0);
148
+ expect(popReduce(hovered, { type: "tick" }, 10 * PIN_HOLD_MS)).toBe(hovered);
149
+ });
150
+ });
151
+
152
+ describe("where the popover goes (behold#571)", () => {
153
+ const frame = { width: 900, height: 700 };
154
+ const pop = { width: 300, height: 160 };
155
+ const overlaps = (a, b) => !(a.left + a.width <= b.left || a.left >= b.left + b.width || a.top + a.height <= b.top || a.top >= b.top + b.height);
156
+ const inside = (p) => p.left >= 0 && p.top >= 0 && p.left + pop.width <= frame.width && p.top + pop.height <= frame.height;
157
+
158
+ it("sits right of the card when it fits, else left, else below, else above, never over the card", () => {
159
+ for (const card of [
160
+ { left: 100, top: 100, width: 200, height: 60 },
161
+ { left: 560, top: 100, width: 200, height: 60 },
162
+ { left: 300, top: 100, width: 320, height: 60 },
163
+ { left: 300, top: 600, width: 320, height: 60 },
164
+ { left: 10, top: 640, width: 200, height: 50 },
165
+ ]) {
166
+ const p = placePopover(card, pop, frame);
167
+ expect(overlaps({ ...p, ...pop }, card)).toBe(false);
168
+ expect(inside(p)).toBe(true);
169
+ }
170
+ expect(placePopover({ left: 100, top: 100, width: 200, height: 60 }, pop, frame).side).toBe("right");
171
+ expect(placePopover({ left: 560, top: 100, width: 200, height: 60 }, pop, frame).side).toBe("left");
172
+ expect(placePopover({ left: 300, top: 100, width: 320, height: 60 }, pop, frame).side).toBe("below");
173
+ expect(placePopover({ left: 300, top: 500, width: 320, height: 160 }, pop, frame).side).toBe("above");
174
+ });
175
+
176
+ it("stays inside the frame when no side has room", () => {
177
+ const p = placePopover({ left: 100, top: 100, width: 700, height: 500 }, pop, frame);
178
+ expect(inside(p)).toBe(true);
179
+ });
180
+ });
@@ -0,0 +1,191 @@
1
+ // behold#571: one colour per Terraform root across the kiosk's panes, and the
2
+ // hover links between them. Pure logic, plus two small DOM helpers.
3
+ //
4
+ // A root's colour is its identity, never a state: the state colours (green
5
+ // applied, amber waiting or unknown, red failed, blue planned, teal running)
6
+ // already mean something, so a root shows its colour only as a small dot by
7
+ // its name, never as a text or fill colour. The hues below are none of those,
8
+ // and each passes 4.5:1 on its theme's background and panel (checked against
9
+ // #1e1e2e, #2e2f40, #11101f and #0f141d for dark; #ffffff and #f6f8fa for
10
+ // light).
11
+ //
12
+ // The colour follows the root's place in the sorted list of declared roots
13
+ // (terragucci.components.json), so it is the same on every cycle and on every
14
+ // screen showing the same repo.
15
+
16
+ /** The root hues, by theme: violet, orange, orchid, silver, tan. */
17
+ export const ROOT_HUES = {
18
+ dark: ["#b392f0", "#ff9f5a", "#e66ce8", "#e4e4e4", "#c89b6d"],
19
+ light: ["#7c3aed", "#c2410c", "#a21caf", "#334155", "#8b5a2b"],
20
+ };
21
+
22
+ /** The declared roots, sorted and without repeats: the order colours follow. */
23
+ export function rootOrder(roots) {
24
+ return [...new Set((roots || []).filter((r) => typeof r === "string" && r))].sort();
25
+ }
26
+
27
+ /** A root's colour, or null for a name that is not a declared root. */
28
+ export function rootColour(name, roots, { light = false } = {}) {
29
+ const order = rootOrder(roots);
30
+ const i = order.indexOf(name);
31
+ if (i < 0) return null;
32
+ const hues = light ? ROOT_HUES.light : ROOT_HUES.dark;
33
+ return hues[i % hues.length];
34
+ }
35
+
36
+ // A token holding any of these is an address, a path, a hash or a key=value:
37
+ // a root name inside it is not a mention of the root.
38
+ const NOT_PROSE = /:\/\/|\/|=|^[0-9a-f]{7,}$|^jcs\d|sha\d+:/i;
39
+ const WORD = /[\w-]/;
40
+
41
+ /**
42
+ * Split a line into runs of plain text and whole-word root names, for the
43
+ * kiosk to put a dot by each name. A name counts only as a word of its own:
44
+ * not inside a URL, a path, a hash or an address such as
45
+ * `terraform_data.app_x` or `app:9080`, and not as part of a longer word
46
+ * (`apply-wave-2`, `appliance`). "network's" and "(app)" count.
47
+ * Returns `[{ text }, { text, root }, …]`; joined, the texts are the line.
48
+ */
49
+ export function rootSegments(text, roots) {
50
+ const s = typeof text === "string" ? text : "";
51
+ const names = rootOrder(roots).sort((a, b) => b.length - a.length);
52
+ if (!s || !names.length) return s ? [{ text: s }] : [];
53
+ const out = [];
54
+ let plain = "";
55
+ const flush = () => {
56
+ if (plain) out.push({ text: plain });
57
+ plain = "";
58
+ };
59
+ // Whitespace runs and tokens, in order.
60
+ for (const part of s.split(/(\s+)/)) {
61
+ if (!part) continue;
62
+ if (/^\s+$/.test(part) || NOT_PROSE.test(part)) {
63
+ plain += part;
64
+ continue;
65
+ }
66
+ let i = 0;
67
+ while (i < part.length) {
68
+ const name = names.find((n) => part.startsWith(n, i) && wholeWord(part, i, n.length));
69
+ if (!name) {
70
+ plain += part[i];
71
+ i += 1;
72
+ continue;
73
+ }
74
+ flush();
75
+ out.push({ text: name, root: name });
76
+ i += name.length;
77
+ }
78
+ }
79
+ flush();
80
+ return out;
81
+ }
82
+
83
+ function wholeWord(token, at, len) {
84
+ const before = at > 0 ? token[at - 1] : "";
85
+ const after = token[at + len] || "";
86
+ const rest = token.slice(at + len + 1);
87
+ if (before && (WORD.test(before) || before === "." || before === ":" || before === "@" || before === "#")) return false;
88
+ if (!after) return true;
89
+ if (WORD.test(after)) return false;
90
+ // `terraform_data.app.x`, `app:9080`: an address or a host, not the root.
91
+ if ((after === "." || after === ":") && rest && /[\w"[]/.test(rest[0])) return false;
92
+ return true;
93
+ }
94
+
95
+ /** The wave a pipeline job applies: from the declared jobs, else from its name (`apply-wave-2`). */
96
+ export function waveOfJob(job, waves = []) {
97
+ const w = (waves || []).find((x) => x.job === job);
98
+ if (w) return w.wave;
99
+ const m = /^apply-wave-(\d+)$/.exec(job || "");
100
+ return m ? Number(m[1]) : null;
101
+ }
102
+
103
+ /** The roots a wave applies, from the declared jobs. */
104
+ export function rootsOfWave(wave, waves = []) {
105
+ const w = (waves || []).find((x) => x.wave === wave);
106
+ return w ? [...w.roots] : [];
107
+ }
108
+
109
+ /**
110
+ * What a hover lights across the panes. A root lights its own mentions and
111
+ * box or card, and the job row and box of the wave that applies it; a wave
112
+ * (a job row, a wave box) lights its own row and box, and the roots it
113
+ * applies. Null lights nothing.
114
+ */
115
+ export function hoverLinks(hover, waves = []) {
116
+ const out = { roots: [], waves: [] };
117
+ if (!hover) return out;
118
+ if (typeof hover.root === "string" && hover.root) {
119
+ out.roots.push(hover.root);
120
+ for (const w of waves || []) if (w.roots.includes(hover.root) && !out.waves.includes(w.wave)) out.waves.push(w.wave);
121
+ } else if (typeof hover.wave === "number") {
122
+ out.waves.push(hover.wave);
123
+ for (const r of rootsOfWave(hover.wave, waves)) if (!out.roots.includes(r)) out.roots.push(r);
124
+ }
125
+ return out;
126
+ }
127
+
128
+ /** The `behold:hover` message both sides send: a root, a wave, or null for neither. */
129
+ export function hoverMessage(hover) {
130
+ return { type: "behold:hover", root: hover && typeof hover.root === "string" ? hover.root : null, wave: hover && typeof hover.wave === "number" ? hover.wave : null };
131
+ }
132
+
133
+ /** A `behold:hover` message reduced to a hover, or undefined when it is not one. */
134
+ export function hoverFromMessage(data) {
135
+ if (!data || typeof data !== "object" || data.type !== "behold:hover") return undefined;
136
+ if (typeof data.root === "string" && data.root) return { root: data.root };
137
+ if (typeof data.wave === "number" && Number.isInteger(data.wave)) return { wave: data.wave };
138
+ return null;
139
+ }
140
+
141
+ /** Whether a background colour (`#rrggbb`) is light. */
142
+ export function isLightBg(hex) {
143
+ const m = /^#?([0-9a-f]{6})$/i.exec((hex || "").trim());
144
+ if (!m) return false;
145
+ const n = parseInt(m[1], 16);
146
+ return (0.2126 * (n >> 16) + 0.7152 * ((n >> 8) & 255) + 0.0722 * (n & 255)) / 255 > 0.5;
147
+ }
148
+
149
+ /** A dot in a root's colour, for beside its name. */
150
+ export function rootDot(doc, name, colour) {
151
+ const dot = doc.createElement("span");
152
+ dot.className = "k-root-dot";
153
+ dot.setAttribute("aria-hidden", "true");
154
+ dot.style.setProperty("--k-root", colour);
155
+ dot.dataset.kRoot = name;
156
+ return dot;
157
+ }
158
+
159
+ /** A line as nodes: plain text, and each root name in a span with its dot. */
160
+ export function rootText(doc, text, roots, { light = false } = {}) {
161
+ const frag = doc.createDocumentFragment();
162
+ for (const seg of rootSegments(text, roots)) {
163
+ if (!seg.root) {
164
+ frag.appendChild(doc.createTextNode(seg.text));
165
+ continue;
166
+ }
167
+ const span = doc.createElement("span");
168
+ span.className = "k-root";
169
+ span.dataset.kRoot = seg.root;
170
+ span.style.setProperty("--k-root", rootColour(seg.root, roots, { light }));
171
+ span.textContent = seg.text;
172
+ frag.appendChild(span);
173
+ }
174
+ return frag;
175
+ }
176
+
177
+ /**
178
+ * The CSS that lights what a hover links, as one rule set: a style element
179
+ * holding it survives the panes' redraws, which replace their nodes every
180
+ * second. `sel` builds the selectors for a root name and for a wave
181
+ * number; quote a name with cssString.
182
+ */
183
+ export function hoverCss(links, sel, rule) {
184
+ const parts = [...links.roots.map((r) => sel.root(r)), ...links.waves.map((w) => sel.wave(w))].filter(Boolean);
185
+ return parts.length ? `${parts.join(",\n")} { ${rule} }` : "";
186
+ }
187
+
188
+ /** A value quoted for an attribute selector. */
189
+ export function cssString(s) {
190
+ return `"${String(s).replace(/["\\]/g, "\\$&").replace(/\n/g, "\\a ")}"`;
191
+ }