@intentius/behold 0.29.16 → 0.29.17

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,235 @@
1
+ // behold#571: the forge's mark on each wave box of the Components view, so the
2
+ // CI projection says which forge runs each wave. One mark is an icon and the
3
+ // forge's name beside it, in the box's title row, linked to the wave's job
4
+ // when behold has that job's URL. app.js draws it; nothing here touches the DOM.
5
+ //
6
+ // Which forge, in this order, from what behold already reads:
7
+ //
8
+ // 1. a forge the pipeline names: terragucci.components.json's
9
+ // `pipeline.forge`, or the forge chant's own CI projection was generated
10
+ // for (`/api/ci`'s `forge`);
11
+ // 2. the shape of a run's URLs in the reports (`job_url`, `commit_url`,
12
+ // `pull_request_url`): a known host, or GitLab's `/-/jobs/`, GitHub's
13
+ // `/actions/runs/<n>/job/<n>`, Forgejo's `/actions/runs/<n>/jobs/<n>`;
14
+ // 3. the pipeline file's path (`pipeline.path`), or the one workflow
15
+ // directory the repo has (`.forgejo/workflows`, `.github/workflows`,
16
+ // `.gitlab-ci.yml`), as the server found it (src/terragucci-components.ts).
17
+ //
18
+ // None of them, or URLs that disagree, is no forge, and no mark is drawn.
19
+ // The link is always a URL a report carried; behold never builds one.
20
+
21
+ /**
22
+ * The marks. Each is drawn in a `size` x `size` square from its own viewBox.
23
+ *
24
+ * Forgejo: the official logo by Caesar Schinas, CC BY-SA 4.0, from
25
+ * https://codeberg.org/forgejo/meta/src/branch/readme/branding (logo/forgejo.svg),
26
+ * its geometry and colours as published.
27
+ *
28
+ * GitHub: Octicons `mark-github` (16px), MIT, Copyright (c) GitHub Inc., from
29
+ * https://github.com/primer/octicons/blob/ea8e6bb79894cc7e85564ee9b53d86b418738d04/icons/mark-github-16.svg,
30
+ * filled in currentColor so it reads on either theme.
31
+ *
32
+ * GitLab: the tanuki behold has drawn since #58, one filled silhouette in GitLab's orange.
33
+ */
34
+ export const FORGES = {
35
+ forgejo: {
36
+ label: "Forgejo",
37
+ viewBox: "0 0 212 212",
38
+ transform: "translate(6,6)",
39
+ shapes: [
40
+ { tag: "path", d: "M58 168 v-98 a50 50 0 0 1 50-50 h20", fill: "none", stroke: "#ff6600", "stroke-width": 25 },
41
+ { tag: "path", d: "M58 168 v-30 a50 50 0 0 1 50-50 h20", fill: "none", stroke: "#d40000", "stroke-width": 25 },
42
+ { tag: "circle", cx: 142, cy: 20, r: 18, fill: "none", stroke: "#ff6600", "stroke-width": 15 },
43
+ { tag: "circle", cx: 142, cy: 88, r: 18, fill: "none", stroke: "#d40000", "stroke-width": 15 },
44
+ { tag: "circle", cx: 58, cy: 180, r: 18, fill: "none", stroke: "#d40000", "stroke-width": 15 },
45
+ ],
46
+ },
47
+ github: {
48
+ label: "GitHub",
49
+ viewBox: "0 0 16 16",
50
+ shapes: [
51
+ {
52
+ tag: "path",
53
+ fill: "currentColor",
54
+ d: "M6.766 11.328c-2.063-.25-3.516-1.734-3.516-3.656 0-.781.281-1.625.75-2.188-.203-.515-.172-1.609.063-2.062.625-.078 1.468.25 1.968.703.594-.187 1.219-.281 1.985-.281.765 0 1.39.094 1.953.265.484-.437 1.344-.765 1.969-.687.218.422.25 1.515.046 2.047.5.593.766 1.39.766 2.203 0 1.922-1.453 3.375-3.547 3.64.531.344.89 1.094.89 1.954v1.625c0 .468.391.734.86.547C13.781 14.359 16 11.53 16 8.03 16 3.61 12.406 0 7.984 0 3.563 0 0 3.61 0 8.031a7.88 7.88 0 0 0 5.172 7.422c.422.156.828-.125.828-.547v-1.25c-.219.094-.5.156-.75.156-1.031 0-1.64-.562-2.078-1.609-.172-.422-.36-.672-.719-.719-.187-.015-.25-.093-.25-.187 0-.188.313-.328.625-.328.453 0 .844.281 1.25.86.313.452.64.655 1.031.655s.641-.14 1-.5c.266-.265.47-.5.657-.656",
55
+ },
56
+ ],
57
+ },
58
+ gitlab: {
59
+ label: "GitLab",
60
+ viewBox: "0 0 24 24",
61
+ shapes: [
62
+ {
63
+ tag: "path",
64
+ fill: "#FC6D26",
65
+ d: "M23.6004 9.5927l-.0337-.0862L20.3.9814a.851.851 0 00-.3362-.405.8748.8748 0 00-.9997.0539.8748.8748 0 00-.29.4399l-2.2055 6.748H7.5375l-2.2055-6.748a.8573.8573 0 00-.29-.4412.8748.8748 0 00-.9997-.0537.8585.8585 0 00-.3362.405L.4332 9.5065l-.0325.0862a6.0657 6.0657 0 002.0119 7.0105l.0113.0087.03.0213 4.976 3.7264 2.462 1.8633 1.4995 1.1321a1.0085 1.0085 0 001.2197 0l1.4995-1.1321 2.462-1.8633 5.006-3.7489.0125-.01a6.0682 6.0682 0 002.0094-7.003z",
66
+ },
67
+ ],
68
+ },
69
+ };
70
+
71
+ /** The icon's size and the label's, in the svg's units: about the tanuki's 17px. */
72
+ export const MARK = { size: 17, font: 11, gap: 4, pad: 12 };
73
+
74
+ /** A forge named outright ("forgejo", "GitHub Actions", "gitlab-ci"), or null. */
75
+ export function forgeOfName(name) {
76
+ const s = String(name || "").trim().toLowerCase();
77
+ if (!s) return null;
78
+ if (s.startsWith("forgejo") || s === "codeberg") return "forgejo";
79
+ if (s.startsWith("github")) return "github";
80
+ if (s.startsWith("gitlab")) return "gitlab";
81
+ return null;
82
+ }
83
+
84
+ /** The forge a pipeline file's path belongs to, or null. */
85
+ export function forgeOfPath(path) {
86
+ const p = String(path || "").replace(/\\/g, "/").replace(/^\.\/+/, "");
87
+ if (/(^|\/)\.forgejo\/workflows(\/|$)/.test(p)) return "forgejo";
88
+ if (/(^|\/)\.github\/workflows(\/|$)/.test(p)) return "github";
89
+ if (/(^|\/)\.gitlab-ci\.ya?ml$/.test(p)) return "gitlab";
90
+ return null;
91
+ }
92
+
93
+ /** The forge a run's URL is on, from its host or its path, or null. */
94
+ export function forgeOfUrl(url) {
95
+ let u;
96
+ try {
97
+ u = new URL(String(url || ""));
98
+ } catch {
99
+ return null;
100
+ }
101
+ if (u.protocol !== "https:" && u.protocol !== "http:") return null;
102
+ const host = u.hostname.toLowerCase();
103
+ if (host === "github.com" || host.endsWith(".github.com")) return "github";
104
+ if (host === "gitlab.com" || host.endsWith(".gitlab.com")) return "gitlab";
105
+ if (host === "codeberg.org" || host.endsWith(".codeberg.org")) return "forgejo";
106
+ const p = u.pathname;
107
+ if (/\/-\/(jobs|pipelines|merge_requests|commit)\//.test(p)) return "gitlab";
108
+ if (/\/actions\/runs\/\d+\/job\/\d+/.test(p)) return "github";
109
+ if (/\/actions\/runs\/\d+\/jobs\/\d+/.test(p)) return "forgejo";
110
+ return null;
111
+ }
112
+
113
+ /**
114
+ * The forge that runs the pipeline, or null when none of what behold reads
115
+ * says. `declared` is a forge the pipeline names, `urls` are run URLs from the
116
+ * reports, `path` is the pipeline file's path, and `found` a forge the server
117
+ * found from the repo's one workflow directory.
118
+ */
119
+ export function detectForge({ declared = null, urls = [], path = null, found = null } = {}) {
120
+ const named = forgeOfName(declared);
121
+ if (named) return named;
122
+ const fromUrls = new Set(urls.map(forgeOfUrl).filter(Boolean));
123
+ if (fromUrls.size === 1) return [...fromUrls][0];
124
+ if (fromUrls.size > 1) return null;
125
+ return forgeOfPath(path) || forgeOfName(found);
126
+ }
127
+
128
+ /**
129
+ * The forge of a terragucci repo's components graph: `facets` is its
130
+ * `meta.terragucciComponents` (`forge` and what said so, `forgeFrom`:
131
+ * `declared` and `config` name it, `pipeline-path` and `workflow-dir` found it),
132
+ * `answer` the terragucci read whose run URLs come second.
133
+ */
134
+ export function componentsForge(facets, answer) {
135
+ if (!facets) return null;
136
+ const from = facets.forgeFrom;
137
+ return detectForge({
138
+ declared: from === "declared" || from === "config" ? facets.forge : null,
139
+ urls: answerUrls(answer),
140
+ path: facets.pipeline || null,
141
+ found: from === "pipeline-path" || from === "workflow-dir" ? facets.forge : null,
142
+ });
143
+ }
144
+
145
+ /** Every run URL a terragucci read carries, for {@link detectForge}. */
146
+ export function answerUrls(answer) {
147
+ if (!answer || answer.error) return [];
148
+ const out = [];
149
+ const add = (r) => {
150
+ if (!r) return;
151
+ for (const k of ["job_url", "commit_url", "pull_request_url"]) if (typeof r[k] === "string" && r[k]) out.push(r[k]);
152
+ };
153
+ for (const r of Array.isArray(answer.roots) ? answer.roots : []) for (const s of ["plan", "drift", "apply"]) add(r[s]);
154
+ for (const w of Array.isArray(answer.waiting) ? answer.waiting : []) add(w);
155
+ if (answer.pull_request) add(answer.pull_request.run);
156
+ return out;
157
+ }
158
+
159
+ const sameSha = (a, b) => !!a && !!b && (a.startsWith(b) || b.startsWith(a));
160
+
161
+ /**
162
+ * The URL of the job that applied wave `n`, as a report carried it: the
163
+ * wave's waiting gate, or a root's newest apply in that wave. With a
164
+ * `commit`, only a run on that commit counts. Null when no report has one.
165
+ */
166
+ export function waveJobUrl(answer, n, commit = null) {
167
+ if (!answer || answer.error) return null;
168
+ const found = [];
169
+ for (const w of Array.isArray(answer.waiting) ? answer.waiting : []) {
170
+ if (w.wave === n && w.job_url) found.push({ url: w.job_url, at: w.since || "", commit: w.commit });
171
+ }
172
+ for (const r of Array.isArray(answer.roots) ? answer.roots : []) {
173
+ const a = r && r.apply;
174
+ if (a && a.wave === n && a.job_url) found.push({ url: a.job_url, at: a.finished || "", commit: a.commit });
175
+ }
176
+ const runs = commit ? found.filter((f) => sameSha(f.commit, commit)) : found;
177
+ runs.sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0));
178
+ return runs.length ? runs[0].url : null;
179
+ }
180
+
181
+ /** The URL the forge's mark links to: a report's job URL, unless its own shape names another forge. */
182
+ export function markLink(url, forge) {
183
+ if (!url) return null;
184
+ const on = forgeOfUrl(url);
185
+ return on && on !== forge ? null : url;
186
+ }
187
+
188
+ /** The mark's tooltip: "apply-wave-2 on Forgejo". */
189
+ export function markTitle(job, forge) {
190
+ const f = FORGES[forge];
191
+ if (!f) return "";
192
+ return job ? `${job} on ${f.label}` : `Runs on ${f.label}`;
193
+ }
194
+
195
+ const SVGNS = "http://www.w3.org/2000/svg";
196
+
197
+ /**
198
+ * A mark's icon as an `<svg>` element of `size` x `size`, from a spec of
199
+ * `{ viewBox, transform?, fill?: { dark, light }, attrs?, shapes }` or
200
+ * `{ image }` (a PNG behold ships), for a dark or a `light` ground. It
201
+ * nests in the graph's svg and stands alone in HTML alike. `doc` is the
202
+ * document to make it in.
203
+ */
204
+ export function iconSvg(doc, spec, size, { x = 0, y = 0, light = false } = {}) {
205
+ const svg = doc.createElementNS(SVGNS, "svg");
206
+ const set = (n, k, v) => n.setAttribute(k, String(v));
207
+ set(svg, "width", size);
208
+ set(svg, "height", size);
209
+ set(svg, "x", x);
210
+ set(svg, "y", y);
211
+ set(svg, "aria-hidden", "true");
212
+ set(svg, "focusable", "false");
213
+ if (spec.image) {
214
+ set(svg, "viewBox", "0 0 1 1");
215
+ const img = doc.createElementNS(SVGNS, "image");
216
+ for (const [k, v] of Object.entries({ href: spec.image, x: 0, y: 0, width: 1, height: 1 })) set(img, k, v);
217
+ svg.appendChild(img);
218
+ return svg;
219
+ }
220
+ set(svg, "viewBox", spec.viewBox);
221
+ for (const [k, v] of Object.entries(spec.attrs || {})) set(svg, k, v);
222
+ // A mark drawn in one colour per theme (`fill: { dark, light }`) takes it on a group.
223
+ const host = spec.transform || spec.fill ? doc.createElementNS(SVGNS, "g") : svg;
224
+ if (host !== svg) {
225
+ if (spec.transform) set(host, "transform", spec.transform);
226
+ if (spec.fill) set(host, "fill", light ? spec.fill.light : spec.fill.dark);
227
+ svg.appendChild(host);
228
+ }
229
+ for (const { tag, ...attrs } of spec.shapes) {
230
+ const n = doc.createElementNS(SVGNS, tag);
231
+ for (const [k, v] of Object.entries(attrs)) set(n, k, v);
232
+ host.appendChild(n);
233
+ }
234
+ return svg;
235
+ }
@@ -0,0 +1,112 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { FORGES, answerUrls, markLink, componentsForge, detectForge, forgeOfName, forgeOfPath, forgeOfUrl, markTitle, waveJobUrl } from "./forge-mark.js";
3
+
4
+ // behold#571: which forge runs a wave, from what behold already reads.
5
+ describe("forge detection", () => {
6
+ it("reads a forge named outright", () => {
7
+ expect(forgeOfName("forgejo")).toBe("forgejo");
8
+ expect(forgeOfName("GitHub Actions")).toBe("github");
9
+ expect(forgeOfName("gitlab-ci")).toBe("gitlab");
10
+ expect(forgeOfName("woodpecker")).toBeNull();
11
+ expect(forgeOfName(undefined)).toBeNull();
12
+ });
13
+
14
+ it("reads a pipeline file's path", () => {
15
+ expect(forgeOfPath(".forgejo/workflows/terragucci.yml")).toBe("forgejo");
16
+ expect(forgeOfPath("./.github/workflows/deploy.yml")).toBe("github");
17
+ expect(forgeOfPath(".gitlab-ci.yml")).toBe("gitlab");
18
+ expect(forgeOfPath("ci/pipeline.yml")).toBeNull();
19
+ });
20
+
21
+ it("reads a run URL's host and path shape", () => {
22
+ expect(forgeOfUrl("https://github.com/acme/shop/actions/runs/9/job/31")).toBe("github");
23
+ expect(forgeOfUrl("https://gitlab.com/acme/shop/-/jobs/12")).toBe("gitlab");
24
+ expect(forgeOfUrl("https://git.acme.dev/acme/shop/-/jobs/12")).toBe("gitlab");
25
+ expect(forgeOfUrl("https://codeberg.org/acme/shop/actions/runs/4/jobs/1")).toBe("forgejo");
26
+ expect(forgeOfUrl("http://forgejo:3000/admin/demo-terragrunt/actions/runs/448/jobs/3")).toBe("forgejo");
27
+ expect(forgeOfUrl("https://ghe.acme.dev/acme/shop/actions/runs/9/job/31")).toBe("github");
28
+ expect(forgeOfUrl("https://ci/9131")).toBeNull();
29
+ expect(forgeOfUrl("not a url")).toBeNull();
30
+ expect(forgeOfUrl("javascript:alert(1)")).toBeNull();
31
+ });
32
+
33
+ it("takes a named forge first, then the run URLs, then the path", () => {
34
+ const gh = ["https://github.com/a/b/actions/runs/1/job/2"];
35
+ expect(detectForge({ declared: "forgejo", urls: gh })).toBe("forgejo");
36
+ expect(detectForge({ urls: gh, path: ".forgejo/workflows/x.yml" })).toBe("github");
37
+ expect(detectForge({ path: ".forgejo/workflows/x.yml" })).toBe("forgejo");
38
+ expect(detectForge({ found: "gitlab" })).toBe("gitlab");
39
+ });
40
+
41
+ it("says no forge when nothing says, or the run URLs disagree", () => {
42
+ expect(detectForge({})).toBeNull();
43
+ expect(detectForge({ urls: ["https://ci/1"], path: "ci.yml" })).toBeNull();
44
+ expect(detectForge({ urls: ["https://github.com/a/b/actions/runs/1/job/2", "https://gitlab.com/a/b/-/jobs/3"], path: ".forgejo/workflows/x.yml" })).toBeNull();
45
+ });
46
+
47
+ it("reads a components graph's facets and the reports' URLs", () => {
48
+ const answer = { roots: [{ path: "app", apply: { wave: 2, job_url: "https://codeberg.org/a/b/actions/runs/4/jobs/1" } }], waiting: [] };
49
+ expect(componentsForge({ forge: "forgejo", forgeFrom: "declared" }, null)).toBe("forgejo");
50
+ expect(componentsForge({ forge: "github", forgeFrom: "config" }, answer)).toBe("github");
51
+ // A workflow directory is the weakest word: the reports' URLs outrank it.
52
+ expect(componentsForge({ forge: "github", forgeFrom: "workflow-dir" }, answer)).toBe("forgejo");
53
+ expect(componentsForge({ forge: "github", forgeFrom: "workflow-dir" }, null)).toBe("github");
54
+ expect(componentsForge({ pipeline: ".gitlab-ci.yml" }, null)).toBe("gitlab");
55
+ expect(componentsForge({ jobs: [] }, null)).toBeNull();
56
+ expect(componentsForge(null, answer)).toBeNull();
57
+ expect(answerUrls({ error: "x" })).toEqual([]);
58
+ });
59
+ });
60
+
61
+ describe("a wave's job URL", () => {
62
+ const answer = {
63
+ roots: [
64
+ { path: "network", apply: { wave: 1, commit: "aaa111", finished: "2026-10-15T04:10:00Z", job_url: "https://f/r/actions/runs/7/jobs/2" } },
65
+ { path: "app", apply: { wave: 2, commit: "aaa111", finished: "2026-10-15T04:12:00Z", job_url: "https://f/r/actions/runs/7/jobs/3" } },
66
+ { path: "ledger", apply: { wave: 2, commit: "bbb222", finished: "2026-10-15T04:20:00Z", job_url: "https://f/r/actions/runs/9/jobs/3" } },
67
+ { path: "edge", plan: { job_url: "https://f/r/actions/runs/6/jobs/1" } },
68
+ ],
69
+ waiting: [{ wave: 3, commit: "bbb222", since: "2026-10-15T04:21:00Z", job_url: "https://f/r/actions/runs/9/jobs/4" }],
70
+ };
71
+
72
+ it("is the URL a report carried for that wave, on the drawn commit", () => {
73
+ expect(waveJobUrl(answer, 2, "aaa111")).toBe("https://f/r/actions/runs/7/jobs/3");
74
+ expect(waveJobUrl(answer, 2, "bbb2")).toBe("https://f/r/actions/runs/9/jobs/3");
75
+ expect(waveJobUrl(answer, 3, "bbb222")).toBe("https://f/r/actions/runs/9/jobs/4");
76
+ });
77
+
78
+ it("is the newest with no commit drawn", () => {
79
+ expect(waveJobUrl(answer, 2)).toBe("https://f/r/actions/runs/9/jobs/3");
80
+ });
81
+
82
+ it("is null when no report has one", () => {
83
+ expect(waveJobUrl(answer, 4)).toBeNull();
84
+ expect(waveJobUrl(answer, 1, "bbb222")).toBeNull();
85
+ expect(waveJobUrl(null, 1)).toBeNull();
86
+ });
87
+ });
88
+
89
+ describe("the mark's link", () => {
90
+ it("is the report's URL, unless that URL is another forge's", () => {
91
+ expect(markLink("https://github.com/a/b/actions/runs/9/job/1", "github")).toBe("https://github.com/a/b/actions/runs/9/job/1");
92
+ expect(markLink("https://ci.acme.dev/jobs/9", "forgejo")).toBe("https://ci.acme.dev/jobs/9");
93
+ expect(markLink("https://github.com/a/b/actions/runs/9", "forgejo")).toBeNull();
94
+ expect(markLink(null, "github")).toBeNull();
95
+ });
96
+ });
97
+
98
+ describe("the mark", () => {
99
+ it("says the job and the forge", () => {
100
+ expect(markTitle("apply-wave-2", "forgejo")).toBe("apply-wave-2 on Forgejo");
101
+ expect(markTitle(null, "github")).toBe("Runs on GitHub");
102
+ expect(markTitle("x", "bitbucket")).toBe("");
103
+ });
104
+
105
+ it("has every forge's name and an inline icon, with no external URL", () => {
106
+ for (const f of Object.values(FORGES)) {
107
+ expect(f.label).toMatch(/^(Forgejo|GitHub|GitLab)$/);
108
+ expect(JSON.stringify(f)).not.toMatch(/https?:/);
109
+ }
110
+ });
111
+
112
+ });
package/web/kiosk-runs.js CHANGED
@@ -12,6 +12,8 @@
12
12
 
13
13
  import { rootColour, rootPaint, rootSegments, rootText, rootsOfWave, waveOfJob } from "./kiosk-roots.js";
14
14
  import { TOUR, chipWords, docsUrl, jobMarks, runFeatures } from "./kiosk-annotations.js";
15
+ import { iconSvg } from "./forge-mark.js";
16
+ import { TOOLS, jobTools } from "./tool-mark.js";
15
17
 
16
18
  /** The run, job and step statuses, as a viewer reads them. */
17
19
  export const STATUS_WORDS = {
@@ -465,7 +467,37 @@ export function renderSidebar(host, index, selectedId, { now = new Date(), scena
465
467
  * null for the defaults. Returns what it drew, so the caller can keep a log's
466
468
  * scroll.
467
469
  */
468
- export function renderRun(host, answer, ui, { now = new Date(), pickJob = () => {}, toggleStep = () => {}, toggleSetup = () => {}, jump = () => {}, toggleFeats = () => {}, featsOpen = false, note = null, flash = null, entry = null, roots = [], waves = [], light = false, hideLine = null } = {}) {
470
+ /**
471
+ * behold#571: a job row's tool marks, `<span class="r-job-tools">` of one
472
+ * `r-tool` per tool (its icon, then its name), or null for none. `carried`
473
+ * are the tools the roots the job applies carry inside them. A tool a vendor
474
+ * page may not name (`hideLine`) is left out.
475
+ */
476
+ export function toolMarks(job, repoTools = [], { light = false, hideLine = null, doc = document, carried = [] } = {}) {
477
+ const ids = jobTools(job, repoTools, carried).filter((t) => !(hideLine && hideLine(TOOLS[t].name)));
478
+ if (!ids.length) return null;
479
+ const box = doc.createElement("span");
480
+ box.className = "r-job-tools";
481
+ for (const t of ids) {
482
+ const tool = TOOLS[t];
483
+ const m = doc.createElement("span");
484
+ m.className = `r-tool r-tool-${t}`;
485
+ m.title = `${job.name} runs ${tool.name}`;
486
+ if (tool.icon) {
487
+ const icon = iconSvg(doc, tool.icon, 11, { light });
488
+ icon.setAttribute("class", "r-tool-icon");
489
+ m.appendChild(icon);
490
+ }
491
+ const name = doc.createElement("span");
492
+ name.className = "r-tool-name";
493
+ name.textContent = tool.name;
494
+ m.appendChild(name);
495
+ box.appendChild(m);
496
+ }
497
+ return box;
498
+ }
499
+
500
+ export function renderRun(host, answer, ui, { now = new Date(), pickJob = () => {}, toggleStep = () => {}, toggleSetup = () => {}, jump = () => {}, toggleFeats = () => {}, featsOpen = false, note = null, flash = null, entry = null, roots = [], waves = [], tools = [], rootTools = {}, light = false, hideLine = null } = {}) {
469
501
  // behold#571: a root's name with its colour's dot, where the line names one.
470
502
  const textOf = (cls, text) => {
471
503
  if (!roots.length || !rootSegments(text, roots).some((x) => x.root)) return el("span", cls, text);
@@ -506,7 +538,16 @@ export function renderRun(host, answer, ui, { now = new Date(), pickJob = () =>
506
538
  const li = el("li", `r-job${job && j.name === job.name ? " r-selected" : ""}`);
507
539
  li.style.setProperty("--depth", String(j.depth));
508
540
  li.appendChild(statusIcon(j.status));
509
- li.appendChild(el("span", "r-job-name", j.name));
541
+ // behold#571: the tools the job runs, each its logo (where its owner allows) and its name, on a
542
+ // line of their own under the job's name, so the row's line keeps its width.
543
+ const jobWave = waveOfJob(j.name, waves);
544
+ const carried = jobWave !== null ? rootsOfWave(jobWave, waves).flatMap((r) => rootTools[r] || []) : [];
545
+ const marks = toolMarks(j, tools, { light, hideLine, carried });
546
+ if (marks) {
547
+ const head = el("span", "r-job-head");
548
+ head.append(el("span", "r-job-name", j.name), marks);
549
+ li.appendChild(head);
550
+ } else li.appendChild(el("span", "r-job-name", j.name));
510
551
  // behold#571: a wave's job row names the roots it applies, each with its dot, and lights its wave box on hover.
511
552
  const wave = waveOfJob(j.name, waves);
512
553
  const applies = wave !== null ? rootsOfWave(wave, waves).filter((r) => rootColour(r, roots, { light })) : [];
@@ -738,6 +779,8 @@ export function startRuns(
738
779
  onLive = () => {},
739
780
  roots = () => [],
740
781
  waves = () => [],
782
+ tools = () => [],
783
+ rootTools = () => ({}),
741
784
  light = () => false,
742
785
  source = () => null,
743
786
  filter = (index) => index,
@@ -798,6 +841,8 @@ export function startRuns(
798
841
  entry,
799
842
  roots: roots(),
800
843
  waves: waves(),
844
+ tools: tools(),
845
+ rootTools: rootTools(),
801
846
  light: light(),
802
847
  hideLine,
803
848
  note: s.note,
package/web/kiosk.html CHANGED
@@ -9,16 +9,16 @@
9
9
  href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='6' fill='none' stroke='%23a6adc8' stroke-width='1.6'/%3E%3Ccircle cx='8' cy='8' r='2.2' fill='%23a6adc8'/%3E%3C/svg%3E"
10
10
  />
11
11
  <style>
12
- /* #569 step 2. The pre-paint fallback is index.html's: the default
13
- theme's tokens, which initTheme() replaces with the viewer's theme. */
12
+ /* #569 step 2. The pre-paint fallback: the kiosk's default theme's
13
+ tokens (KIOSK_THEME in theme.js), which initTheme() replaces with the viewer's theme. */
14
14
  :root {
15
- --bg: #1e1e2e; --panel: #2e2f40; --line: #3b3c4a; --fg: #cdd6f4; --muted: #777a8c;
16
- --edge: #56596d; --managed: #a6e3a1; --foreign: #f9e2af; --pending: #89b4fa;
17
- --degraded: #f38ba8; --neutral: #6d7187; --runtime: #94e2d5;
18
- --rule: #4e4f64; --focus: #a3b4d0; --active: #3d4660; --well: #11101f; --shadow: #050507;
19
- /* behold#571: the kiosk's surfaces and ink (theme.js kioskSurfaces), the default theme's until initTheme() runs. */
20
- --surface-box: #252536; --surface-pane-a: #2b2b3c; --surface-pane-b: #313142; --zebra: #cdd6f4; --soft: #cdd6f4; --muted-ink: #808395;
21
- --managed-ink: #a6e3a1; --foreign-ink: #f9e2af; --pending-ink: #89b4fa; --degraded-ink: #f38ba8; --runtime-ink: #94e2d5;
15
+ --bg: #09141b; --panel: #1c2325; --line: #2c2e2d; --fg: #deb88d; --muted: #746a5c;
16
+ --edge: #4d483f; --managed: #027c9b; --foreign: #fca02f; --pending: #1e4950;
17
+ --degraded: #d15123; --neutral: #695e4e; --runtime: #50a3b5;
18
+ --rule: #3d4142; --focus: #364548; --active: #243235; --well: #01070d; --shadow: #010304;
19
+ /* behold#571: the kiosk's surfaces and ink (theme.js kioskSurfaces), the kiosk's default theme's (KIOSK_THEME, Sea Shells) until initTheme() runs. */
20
+ --surface-box: #101b22; --surface-pane-a: #152128; --surface-pane-b: #1b272e; --zebra: #deb88d; --soft: #deb88d; --muted-ink: #7c7365;
21
+ --managed-ink: #0e7f9e; --foreign-ink: #fca02f; --pending-ink: #567b82; --degraded-ink: #d15123; --runtime-ink: #50a3b5;
22
22
  --font-ui: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
23
23
  --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
24
24
  }
@@ -67,6 +67,12 @@
67
67
  .r-job.r-selected { background: var(--active); }
68
68
  .r-job-name { font: 600 0.92em var(--font-mono); }
69
69
  .r-job-needs { color: var(--muted); font-size: 0.78em; }
70
+ /* behold#571: the tools a job runs, each its logo and its name. Short of room, the names stay and the logos go. */
71
+ .r-job-head { display: inline-flex; flex-direction: column; flex: none; line-height: 1.15; }
72
+ .r-job-name { white-space: nowrap; flex: none; }
73
+ .r-job-tools { display: inline-flex; gap: 0.6em; font-size: 0.7em; color: var(--muted); white-space: nowrap; }
74
+ .r-tool { display: inline-flex; align-items: center; gap: 0.25em; }
75
+ .r-tool-icon { flex: none; }
70
76
  /* behold#571: each root's colour, as a dot by its name; never a text or fill colour, which mean a state. */
71
77
  .k-root::before { content: ""; display: inline-block; width: 0.6em; height: 0.6em; border-radius: 50%; background: var(--k-root); margin: 0 0.28em 0.1em 0.05em; vertical-align: middle; }
72
78
  .r-job-roots { display: inline-flex; gap: 0.75em; font-size: 0.82em; color: var(--fg); }
@@ -292,10 +298,10 @@
292
298
  </div>
293
299
  <footer id="foot"></footer>
294
300
  <script type="module">
295
- import { initTheme } from "/theme.js";
301
+ import { initTheme, KIOSK_THEME } from "/theme.js";
296
302
  import { startKiosk } from "/kiosk.js";
297
303
  import { pageTool } from "/kiosk-vendor.js";
298
- initTheme();
304
+ initTheme(KIOSK_THEME);
299
305
  // behold#571: /<tool> serves this page with its tool in a meta tag; /kiosk has none.
300
306
  startKiosk(
301
307
  {
package/web/kiosk.js CHANGED
@@ -1047,6 +1047,8 @@ export function startKiosk({ bar, frame, foot, side, main, views, tools = null }
1047
1047
  names: () => names,
1048
1048
  roots: rootList,
1049
1049
  waves: () => s.waves,
1050
+ tools: () => s.tools || [],
1051
+ rootTools: () => s.rootTools || {},
1050
1052
  light: () => s.light,
1051
1053
  source: runsSource,
1052
1054
  filter: vendor ? (index) => filterIndex(index, { tool, repo: s.repo }) : (index) => index,
@@ -1091,9 +1093,14 @@ export function startKiosk({ bar, frame, foot, side, main, views, tools = null }
1091
1093
  if (p && p.terragucciRoots) {
1092
1094
  s.roots = Array.isArray(p.terragucciRoots.roots) ? p.terragucciRoots.roots : [];
1093
1095
  s.waves = Array.isArray(p.terragucciRoots.waves) ? p.terragucciRoots.waves : [];
1096
+ // behold#571: the tools the repo's jobs run, for a job row whose log isn't read yet.
1097
+ s.tools = Array.isArray(p.terragucciRoots.tools) ? p.terragucciRoots.tools : [];
1098
+ s.rootTools = p.terragucciRoots.rootTools && typeof p.terragucciRoots.rootTools === "object" ? p.terragucciRoots.rootTools : {};
1094
1099
  } else if (s.aimed) {
1095
1100
  s.roots = [];
1096
1101
  s.waves = [];
1102
+ s.tools = [];
1103
+ s.rootTools = {};
1097
1104
  }
1098
1105
  openFrame();
1099
1106
  draw();
package/web/theme.js CHANGED
@@ -375,11 +375,15 @@ export function setTheme(nameOrObj, { persist = true } = {}) {
375
375
  return true;
376
376
  }
377
377
 
378
- // Restore the persisted theme (or default) and apply. Call once on boot.
379
- export function initTheme() {
378
+ /** behold#571: the kiosk's theme for a viewer who hasn't picked one; the app keeps DEFAULT_THEME. */
379
+ export const KIOSK_THEME = "Sea Shells";
380
+
381
+ // Restore the persisted theme (or `fallback`, else the default) and apply. Call once on boot.
382
+ export function initTheme(fallback = null) {
380
383
  let saved = null;
381
384
  try { saved = localStorage.getItem(STORE_KEY); } catch { /* private mode */ }
382
- setTheme(saved && THEMES[saved] ? saved : (_active ? _active.name : DEFAULT_THEME), { persist: false });
385
+ const first = fallback && THEMES[fallback] ? fallback : (_active ? _active.name : DEFAULT_THEME);
386
+ setTheme(saved && THEMES[saved] ? saved : first, { persist: false });
383
387
  return getThemeName();
384
388
  }
385
389