@intentius/behold 0.29.6 → 0.29.8
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/AGENTS.md +6 -1
- package/README.md +7 -0
- package/dist/cli.js +1263 -770
- package/package.json +1 -1
- package/web/app.js +320 -0
- package/web/index.html +24 -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 +235 -0
- package/web/kiosk-popover.test.js +180 -0
- package/web/kiosk-repo.js +25 -0
- package/web/kiosk-repo.test.js +43 -0
- package/web/kiosk-roots.js +199 -0
- package/web/kiosk-roots.test.js +95 -0
- package/web/kiosk-runs.js +365 -23
- package/web/kiosk-runs.test.js +77 -1
- package/web/kiosk-vendor.js +380 -0
- package/web/kiosk-vendor.test.js +265 -0
- package/web/kiosk.html +160 -17
- package/web/kiosk.js +536 -59
- package/web/kiosk.test.js +76 -11
- 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
|
@@ -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,25 @@
|
|
|
1
|
+
// behold#571: which repo the kiosk's graph reads, on a behold that serves
|
|
2
|
+
// several (`behold serve --repos`, one checkout per demo repo). The kiosk
|
|
3
|
+
// knows the repo it is about (demo.json's `repo`, or a tool's latest cycle's)
|
|
4
|
+
// and asks for that checkout by name with `?repo=`; the server answers from
|
|
5
|
+
// it, or refuses a name it doesn't serve.
|
|
6
|
+
|
|
7
|
+
/** The reads that depend on the served checkout: the project, the reports' marks, the run view, and (behold#571's approval panel) the gate ledger read from its chant/lifecycle. */
|
|
8
|
+
const REPO_READS = new Set(["/api/project", "/api/terragucci", "/api/terragucci/progress", "/api/terragucci/lifecycle", "/api/lifecycle/history"]);
|
|
9
|
+
|
|
10
|
+
/** The checkout name for `repo` ("<owner>/<name>") when the server serves it; null otherwise. */
|
|
11
|
+
export function repoParam(repo, served) {
|
|
12
|
+
if (typeof repo !== "string" || !repo || !Array.isArray(served)) return null;
|
|
13
|
+
const name = repo.replace(/\.git$/, "").slice(repo.lastIndexOf("/") + 1);
|
|
14
|
+
return served.includes(name) ? name : null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** `url` asking for `repo`, when it is one of the reads that depend on the checkout. */
|
|
18
|
+
export function repoUrl(url, repo) {
|
|
19
|
+
if (!repo || typeof url !== "string") return url;
|
|
20
|
+
const [path, query = ""] = url.split("?");
|
|
21
|
+
if (!REPO_READS.has(path)) return url;
|
|
22
|
+
const q = new URLSearchParams(query);
|
|
23
|
+
q.set("repo", repo);
|
|
24
|
+
return `${path}?${q.toString()}`;
|
|
25
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// behold#571: the kiosk asks for its repo's checkout by name, on a behold that serves several.
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { repoParam, repoUrl } from "./kiosk-repo.js";
|
|
4
|
+
import { frameSrc } from "./kiosk.js";
|
|
5
|
+
import { pageRepo, withRepo } from "./view-repo.js";
|
|
6
|
+
|
|
7
|
+
describe("repoParam", () => {
|
|
8
|
+
it("is the checkout name when the server serves it, else null", () => {
|
|
9
|
+
expect(repoParam("terragucci-admin/demo-terragrunt", ["demo", "demo-terragrunt"])).toBe("demo-terragrunt");
|
|
10
|
+
expect(repoParam("terragucci-admin/demo-cdk", ["demo", "demo-terragrunt"])).toBeNull();
|
|
11
|
+
expect(repoParam("terragucci-admin/demo", null)).toBeNull();
|
|
12
|
+
expect(repoParam(null, ["demo"])).toBeNull();
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("repoUrl", () => {
|
|
17
|
+
it("adds the repo to the reads about the checkout only", () => {
|
|
18
|
+
expect(repoUrl("/api/project", "demo-terragrunt")).toBe("/api/project?repo=demo-terragrunt");
|
|
19
|
+
expect(repoUrl("/api/terragucci?fresh=1", "demo-terragrunt")).toBe("/api/terragucci?fresh=1&repo=demo-terragrunt");
|
|
20
|
+
expect(repoUrl("/api/terragucci/progress?commit=abc", "demo")).toBe("/api/terragucci/progress?commit=abc&repo=demo");
|
|
21
|
+
expect(repoUrl("/api/terragucci/lifecycle", "demo")).toBe("/api/terragucci/lifecycle?repo=demo");
|
|
22
|
+
expect(repoUrl("/api/lifecycle/history?limit=200", "demo")).toBe("/api/lifecycle/history?limit=200&repo=demo");
|
|
23
|
+
expect(repoUrl("/api/terragucci/demo", "demo")).toBe("/api/terragucci/demo");
|
|
24
|
+
expect(repoUrl("/api/terragucci/runs/4", "demo")).toBe("/api/terragucci/runs/4");
|
|
25
|
+
expect(repoUrl("/api/project", null)).toBe("/api/project");
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("the frame", () => {
|
|
30
|
+
it("names the repo it draws, and nothing without one", () => {
|
|
31
|
+
expect(frameSrc("http://h", "resources")).toBe("/?embed=1&bare=1&zoom=resources&host=http%3A%2F%2Fh");
|
|
32
|
+
expect(frameSrc("http://h", "components", "demo-terragrunt")).toBe("/?embed=1&bare=1&zoom=components&host=http%3A%2F%2Fh&repo=demo-terragrunt");
|
|
33
|
+
});
|
|
34
|
+
it("carries the page's repo on each of its own /api/ reads", () => {
|
|
35
|
+
expect(pageRepo("?embed=1&repo=demo-terragrunt")).toBe("demo-terragrunt");
|
|
36
|
+
expect(pageRepo("?embed=1")).toBeNull();
|
|
37
|
+
expect(withRepo("/api/graph?zoom=resources", "demo-terragrunt", "http://h")).toBe("/api/graph?zoom=resources&repo=demo-terragrunt");
|
|
38
|
+
expect(withRepo("http://h/api/project", "demo-terragrunt", "http://h")).toBe("http://h/api/project?repo=demo-terragrunt");
|
|
39
|
+
expect(withRepo("/api/project?repo=demo", "demo-terragrunt", "http://h")).toBe("/api/project?repo=demo");
|
|
40
|
+
expect(withRepo("./manifest.json", "demo-terragrunt", "http://h")).toBe("./manifest.json");
|
|
41
|
+
expect(withRepo("https://elsewhere/api/x", "demo-terragrunt", "http://h")).toBe("https://elsewhere/api/x");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,199 @@
|
|
|
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). theme.js pushes each, per theme, to 3:1 on every kiosk surface
|
|
11
|
+
// (--root-0 … --root-4), and the page paints a dot with that, this as its fallback.
|
|
12
|
+
//
|
|
13
|
+
// The colour follows the root's place in the sorted list of declared roots
|
|
14
|
+
// (terragucci.components.json), so it is the same on every cycle and on every
|
|
15
|
+
// screen showing the same repo.
|
|
16
|
+
|
|
17
|
+
import { ROOT_HUES } from "./theme.js";
|
|
18
|
+
|
|
19
|
+
/** The root hues, by theme: violet, orange, orchid, silver, tan. theme.js holds them, and derives each theme's own (--root-0 …). */
|
|
20
|
+
export { ROOT_HUES };
|
|
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 root's paint: the theme's own root colour (--root-<i>), with rootColour's as its fallback; null for a name that is not a root. */
|
|
37
|
+
export function rootPaint(name, roots, { light = false } = {}) {
|
|
38
|
+
const c = rootColour(name, roots, { light });
|
|
39
|
+
if (!c) return null;
|
|
40
|
+
const hues = light ? ROOT_HUES.light : ROOT_HUES.dark;
|
|
41
|
+
return `var(--root-${rootOrder(roots).indexOf(name) % hues.length}, ${c})`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// A token holding any of these is an address, a path, a hash or a key=value:
|
|
45
|
+
// a root name inside it is not a mention of the root.
|
|
46
|
+
const NOT_PROSE = /:\/\/|\/|=|^[0-9a-f]{7,}$|^jcs\d|sha\d+:/i;
|
|
47
|
+
const WORD = /[\w-]/;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Split a line into runs of plain text and whole-word root names, for the
|
|
51
|
+
* kiosk to put a dot by each name. A name counts only as a word of its own:
|
|
52
|
+
* not inside a URL, a path, a hash or an address such as
|
|
53
|
+
* `terraform_data.app_x` or `app:9080`, and not as part of a longer word
|
|
54
|
+
* (`apply-wave-2`, `appliance`). "network's" and "(app)" count.
|
|
55
|
+
* Returns `[{ text }, { text, root }, …]`; joined, the texts are the line.
|
|
56
|
+
*/
|
|
57
|
+
export function rootSegments(text, roots) {
|
|
58
|
+
const s = typeof text === "string" ? text : "";
|
|
59
|
+
const names = rootOrder(roots).sort((a, b) => b.length - a.length);
|
|
60
|
+
if (!s || !names.length) return s ? [{ text: s }] : [];
|
|
61
|
+
const out = [];
|
|
62
|
+
let plain = "";
|
|
63
|
+
const flush = () => {
|
|
64
|
+
if (plain) out.push({ text: plain });
|
|
65
|
+
plain = "";
|
|
66
|
+
};
|
|
67
|
+
// Whitespace runs and tokens, in order.
|
|
68
|
+
for (const part of s.split(/(\s+)/)) {
|
|
69
|
+
if (!part) continue;
|
|
70
|
+
if (/^\s+$/.test(part) || NOT_PROSE.test(part)) {
|
|
71
|
+
plain += part;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
let i = 0;
|
|
75
|
+
while (i < part.length) {
|
|
76
|
+
const name = names.find((n) => part.startsWith(n, i) && wholeWord(part, i, n.length));
|
|
77
|
+
if (!name) {
|
|
78
|
+
plain += part[i];
|
|
79
|
+
i += 1;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
flush();
|
|
83
|
+
out.push({ text: name, root: name });
|
|
84
|
+
i += name.length;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
flush();
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function wholeWord(token, at, len) {
|
|
92
|
+
const before = at > 0 ? token[at - 1] : "";
|
|
93
|
+
const after = token[at + len] || "";
|
|
94
|
+
const rest = token.slice(at + len + 1);
|
|
95
|
+
if (before && (WORD.test(before) || before === "." || before === ":" || before === "@" || before === "#")) return false;
|
|
96
|
+
if (!after) return true;
|
|
97
|
+
if (WORD.test(after)) return false;
|
|
98
|
+
// `terraform_data.app.x`, `app:9080`: an address or a host, not the root.
|
|
99
|
+
if ((after === "." || after === ":") && rest && /[\w"[]/.test(rest[0])) return false;
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The wave a pipeline job applies: from the declared jobs, else from its name (`apply-wave-2`). */
|
|
104
|
+
export function waveOfJob(job, waves = []) {
|
|
105
|
+
const w = (waves || []).find((x) => x.job === job);
|
|
106
|
+
if (w) return w.wave;
|
|
107
|
+
const m = /^apply-wave-(\d+)$/.exec(job || "");
|
|
108
|
+
return m ? Number(m[1]) : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The roots a wave applies, from the declared jobs. */
|
|
112
|
+
export function rootsOfWave(wave, waves = []) {
|
|
113
|
+
const w = (waves || []).find((x) => x.wave === wave);
|
|
114
|
+
return w ? [...w.roots] : [];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* What a hover lights across the panes. A root lights its own mentions and
|
|
119
|
+
* box or card, and the job row and box of the wave that applies it; a wave
|
|
120
|
+
* (a job row, a wave box) lights its own row and box, and the roots it
|
|
121
|
+
* applies. Null lights nothing.
|
|
122
|
+
*/
|
|
123
|
+
export function hoverLinks(hover, waves = []) {
|
|
124
|
+
const out = { roots: [], waves: [] };
|
|
125
|
+
if (!hover) return out;
|
|
126
|
+
if (typeof hover.root === "string" && hover.root) {
|
|
127
|
+
out.roots.push(hover.root);
|
|
128
|
+
for (const w of waves || []) if (w.roots.includes(hover.root) && !out.waves.includes(w.wave)) out.waves.push(w.wave);
|
|
129
|
+
} else if (typeof hover.wave === "number") {
|
|
130
|
+
out.waves.push(hover.wave);
|
|
131
|
+
for (const r of rootsOfWave(hover.wave, waves)) if (!out.roots.includes(r)) out.roots.push(r);
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The `behold:hover` message both sides send: a root, a wave, or null for neither. */
|
|
137
|
+
export function hoverMessage(hover) {
|
|
138
|
+
return { type: "behold:hover", root: hover && typeof hover.root === "string" ? hover.root : null, wave: hover && typeof hover.wave === "number" ? hover.wave : null };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A `behold:hover` message reduced to a hover, or undefined when it is not one. */
|
|
142
|
+
export function hoverFromMessage(data) {
|
|
143
|
+
if (!data || typeof data !== "object" || data.type !== "behold:hover") return undefined;
|
|
144
|
+
if (typeof data.root === "string" && data.root) return { root: data.root };
|
|
145
|
+
if (typeof data.wave === "number" && Number.isInteger(data.wave)) return { wave: data.wave };
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Whether a background colour (`#rrggbb`) is light. */
|
|
150
|
+
export function isLightBg(hex) {
|
|
151
|
+
const m = /^#?([0-9a-f]{6})$/i.exec((hex || "").trim());
|
|
152
|
+
if (!m) return false;
|
|
153
|
+
const n = parseInt(m[1], 16);
|
|
154
|
+
return (0.2126 * (n >> 16) + 0.7152 * ((n >> 8) & 255) + 0.0722 * (n & 255)) / 255 > 0.5;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** A dot in a root's colour, for beside its name. */
|
|
158
|
+
export function rootDot(doc, name, colour) {
|
|
159
|
+
const dot = doc.createElement("span");
|
|
160
|
+
dot.className = "k-root-dot";
|
|
161
|
+
dot.setAttribute("aria-hidden", "true");
|
|
162
|
+
dot.style.setProperty("--k-root", colour);
|
|
163
|
+
dot.dataset.kRoot = name;
|
|
164
|
+
return dot;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** A line as nodes: plain text, and each root name in a span with its dot. */
|
|
168
|
+
export function rootText(doc, text, roots, { light = false } = {}) {
|
|
169
|
+
const frag = doc.createDocumentFragment();
|
|
170
|
+
for (const seg of rootSegments(text, roots)) {
|
|
171
|
+
if (!seg.root) {
|
|
172
|
+
frag.appendChild(doc.createTextNode(seg.text));
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
const span = doc.createElement("span");
|
|
176
|
+
span.className = "k-root";
|
|
177
|
+
span.dataset.kRoot = seg.root;
|
|
178
|
+
span.style.setProperty("--k-root", rootPaint(seg.root, roots, { light }));
|
|
179
|
+
span.textContent = seg.text;
|
|
180
|
+
frag.appendChild(span);
|
|
181
|
+
}
|
|
182
|
+
return frag;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The CSS that lights what a hover links, as one rule set: a style element
|
|
187
|
+
* holding it survives the panes' redraws, which replace their nodes every
|
|
188
|
+
* second. `sel` builds the selectors for a root name and for a wave
|
|
189
|
+
* number; quote a name with cssString.
|
|
190
|
+
*/
|
|
191
|
+
export function hoverCss(links, sel, rule) {
|
|
192
|
+
const parts = [...links.roots.map((r) => sel.root(r)), ...links.waves.map((w) => sel.wave(w))].filter(Boolean);
|
|
193
|
+
return parts.length ? `${parts.join(",\n")} { ${rule} }` : "";
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** A value quoted for an attribute selector. */
|
|
197
|
+
export function cssString(s) {
|
|
198
|
+
return `"${String(s).replace(/["\\]/g, "\\$&").replace(/\n/g, "\\a ")}"`;
|
|
199
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { ROOT_HUES, cssString, hoverCss, hoverFromMessage, hoverLinks, hoverMessage, isLightBg, rootColour, rootOrder, rootSegments, rootsOfWave, waveOfJob } from "./kiosk-roots.js";
|
|
3
|
+
|
|
4
|
+
// behold#571: the demo repo's roots and the wave jobs of its terragucci.components.json.
|
|
5
|
+
const ROOTS = ["network", "other", "app", "edge"];
|
|
6
|
+
const WAVES = [
|
|
7
|
+
{ job: "apply-wave-1", wave: 1, roots: ["network", "other"] },
|
|
8
|
+
{ job: "apply-wave-2", wave: 2, roots: ["app"] },
|
|
9
|
+
{ job: "apply-wave-3", wave: 3, roots: ["edge"] },
|
|
10
|
+
];
|
|
11
|
+
const names = (text) => rootSegments(text, ROOTS).filter((s) => s.root).map((s) => s.root);
|
|
12
|
+
|
|
13
|
+
describe("a colour per root (behold#571)", () => {
|
|
14
|
+
it("follows the sorted root list, so it is the same on every cycle and in any order", () => {
|
|
15
|
+
expect(rootOrder(ROOTS)).toEqual(["app", "edge", "network", "other"]);
|
|
16
|
+
const a = Object.fromEntries(ROOTS.map((r) => [r, rootColour(r, ROOTS)]));
|
|
17
|
+
const b = Object.fromEntries(ROOTS.map((r) => [r, rootColour(r, [...ROOTS].reverse())]));
|
|
18
|
+
expect(a).toEqual(b);
|
|
19
|
+
expect(a.app).toBe(ROOT_HUES.dark[0]);
|
|
20
|
+
expect(new Set(Object.values(a)).size).toBe(4);
|
|
21
|
+
expect(rootColour("app", ROOTS, { light: true })).toBe(ROOT_HUES.light[0]);
|
|
22
|
+
expect(rootColour("nope", ROOTS)).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("uses no state colour: not the green, amber, red, blue or teal of the theme", () => {
|
|
26
|
+
const state = ["#a6e3a1", "#f9e2af", "#f38ba8", "#89b4fa", "#94e2d5", "#6d7187"];
|
|
27
|
+
for (const hue of [...ROOT_HUES.dark, ...ROOT_HUES.light]) expect(state).not.toContain(hue.toLowerCase());
|
|
28
|
+
expect(new Set(ROOT_HUES.dark).size).toBe(ROOT_HUES.dark.length);
|
|
29
|
+
expect(new Set(ROOT_HUES.light).size).toBe(ROOT_HUES.light.length);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("tells a light background from a dark one", () => {
|
|
33
|
+
expect(isLightBg("#ffffff")).toBe(true);
|
|
34
|
+
expect(isLightBg(" #1e1e2e")).toBe(false);
|
|
35
|
+
expect(isLightBg("")).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("root names in a line (behold#571)", () => {
|
|
40
|
+
it("finds whole-word names, possessives and names in brackets, and keeps the line's text", () => {
|
|
41
|
+
const line = "app reads subnet_id from network, known only after network applies: the review does not show the value app gets.";
|
|
42
|
+
expect(names(line)).toEqual(["app", "network", "network", "app"]);
|
|
43
|
+
expect(rootSegments(line, ROOTS).map((s) => s.text).join("")).toBe(line);
|
|
44
|
+
expect(names("approves wave 2 (app) with network's key")).toEqual(["app", "network"]);
|
|
45
|
+
expect(names("app: Plan: 0 to add, 1 to change, 0 to destroy.")).toEqual(["app"]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("leaves names inside addresses, hosts, URLs, paths, hashes and longer words alone", () => {
|
|
49
|
+
expect(names("terraform_data.app_x and data.terraform_remote_state.network")).toEqual([]);
|
|
50
|
+
expect(names("upstream http://app:9080 and app:8070")).toEqual([]);
|
|
51
|
+
expect(names("report copied to demo/forgejo:3000/terragucci-admin/demo/2026/10/ef95/tf-apply-wave-1")).toEqual([]);
|
|
52
|
+
expect(names("set digest jcs1-sha256:0accc105ba06 and commit a1b2c3d4e5")).toEqual([]);
|
|
53
|
+
expect(names("apply-wave-2 appliance networks edge-proxy network.tfstate")).toEqual([]);
|
|
54
|
+
expect(names("key=network")).toEqual([]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("says nothing without roots", () => {
|
|
58
|
+
expect(rootSegments("network", [])).toEqual([{ text: "network" }]);
|
|
59
|
+
expect(rootSegments("", ROOTS)).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("hover links between the panes (behold#571)", () => {
|
|
64
|
+
it("ties a wave's job to its roots, from the declared jobs or the job's name", () => {
|
|
65
|
+
expect(waveOfJob("apply-wave-2", WAVES)).toBe(2);
|
|
66
|
+
expect(waveOfJob("apply-wave-7", [])).toBe(7);
|
|
67
|
+
expect(waveOfJob("check", WAVES)).toBeNull();
|
|
68
|
+
expect(rootsOfWave(1, WAVES)).toEqual(["network", "other"]);
|
|
69
|
+
expect(rootsOfWave(9, WAVES)).toEqual([]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("lights a root's mentions and its wave, and a wave's job and its roots", () => {
|
|
73
|
+
expect(hoverLinks({ root: "other" }, WAVES)).toEqual({ roots: ["other"], waves: [1] });
|
|
74
|
+
expect(hoverLinks({ wave: 1 }, WAVES)).toEqual({ roots: ["network", "other"], waves: [1] });
|
|
75
|
+
expect(hoverLinks({ root: "lonely" }, WAVES)).toEqual({ roots: ["lonely"], waves: [] });
|
|
76
|
+
expect(hoverLinks(null, WAVES)).toEqual({ roots: [], waves: [] });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("carries a hover between the frame and the kiosk as a message, and takes only that message", () => {
|
|
80
|
+
expect(hoverMessage({ root: "app" })).toEqual({ type: "behold:hover", root: "app", wave: null });
|
|
81
|
+
expect(hoverMessage(null)).toEqual({ type: "behold:hover", root: null, wave: null });
|
|
82
|
+
expect(hoverFromMessage(hoverMessage({ wave: 2 }))).toEqual({ wave: 2 });
|
|
83
|
+
expect(hoverFromMessage(hoverMessage({ root: "app" }))).toEqual({ root: "app" });
|
|
84
|
+
expect(hoverFromMessage(hoverMessage(null))).toBeNull();
|
|
85
|
+
expect(hoverFromMessage({ type: "behold:view" })).toBeUndefined();
|
|
86
|
+
expect(hoverFromMessage("behold:hover")).toBeUndefined();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("writes one rule for what a hover lights, quoting names", () => {
|
|
90
|
+
const css = hoverCss(hoverLinks({ wave: 2 }, WAVES), { root: (r) => `.k-root[data-k-root=${cssString(r)}]`, wave: (w) => `.r-job[data-k-wave="${w}"]` }, "outline: 1px solid");
|
|
91
|
+
expect(css).toBe('.k-root[data-k-root="app"],\n.r-job[data-k-wave="2"] { outline: 1px solid }');
|
|
92
|
+
expect(hoverCss({ roots: [], waves: [] }, { root: () => "x", wave: () => "y" }, "z")).toBe("");
|
|
93
|
+
expect(cssString('a"b')).toBe('"a\\"b"');
|
|
94
|
+
});
|
|
95
|
+
});
|