@intentius/behold 0.29.7 → 0.29.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/dist/cli.js +1221 -765
- package/package.json +1 -1
- package/web/app.js +60 -4
- package/web/index.html +2 -0
- package/web/kiosk-annotations.js +528 -0
- package/web/kiosk-annotations.test.js +83 -0
- package/web/kiosk-approval.js +241 -0
- package/web/kiosk-approval.test.js +122 -0
- package/web/kiosk-popover.js +1 -0
- package/web/kiosk-repo.js +25 -0
- package/web/kiosk-repo.test.js +43 -0
- package/web/kiosk-roots.js +15 -7
- package/web/kiosk-runs.js +360 -25
- package/web/kiosk-runs.test.js +104 -1
- package/web/kiosk-vendor.js +397 -0
- package/web/kiosk-vendor.test.js +281 -0
- package/web/kiosk.html +160 -17
- package/web/kiosk.js +465 -51
- package/web/kiosk.test.js +73 -8
- package/web/theme.js +112 -0
- package/web/theme.test.js +51 -1
- package/web/vendors/choudoufu-128.png +0 -0
- package/web/vendors/choudoufu-64.png +0 -0
- package/web/view-repo.js +44 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// behold#571: the per-tool pages' pure parts: live or replay, the replay's pace, the runs kept, and the branding.
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { VENDOR_TOOLS } from "../src/terragucci-demo.ts";
|
|
4
|
+
import { logRows } from "./kiosk-runs.js";
|
|
5
|
+
import { NO_GRAPH_WORDS, SCENARIOS, legendWords, scenarioHead } from "./kiosk.js";
|
|
6
|
+
import {
|
|
7
|
+
REPLAY_LAST_HOLD_MS,
|
|
8
|
+
RUNS_WITH,
|
|
9
|
+
VENDORS,
|
|
10
|
+
creditLines,
|
|
11
|
+
demoTool,
|
|
12
|
+
filterIndex,
|
|
13
|
+
keepRun,
|
|
14
|
+
liveEstimate,
|
|
15
|
+
liveFor,
|
|
16
|
+
linkSegments,
|
|
17
|
+
namesOther,
|
|
18
|
+
notRunRepo,
|
|
19
|
+
notRunWords,
|
|
20
|
+
otherTerms,
|
|
21
|
+
replayAt,
|
|
22
|
+
replayBanner,
|
|
23
|
+
replayIndex,
|
|
24
|
+
rootWord,
|
|
25
|
+
runAsOf,
|
|
26
|
+
scrubDemo,
|
|
27
|
+
servesRepo,
|
|
28
|
+
stepDurations,
|
|
29
|
+
toolCheckout,
|
|
30
|
+
} from "./kiosk-vendor.js";
|
|
31
|
+
|
|
32
|
+
const demo = (extra = {}) => ({ cycle: 3, scenario: "b-waves", title: "A new subnet", step: "waiting", label: "Wave 2 waits", started_at: "2026-10-20T10:00:00Z", next: { action: "approve wave-2", at: null }, pull: 41, commit: "abc", about: null, intro: null, name: "a new subnet", scenarios: 3, index: 2, ...extra });
|
|
33
|
+
|
|
34
|
+
describe("which tool a page is live for (behold#571)", () => {
|
|
35
|
+
it("is demo.json's tool, and Terraform's for a driver before it that ran a scenario the kiosk knows", () => {
|
|
36
|
+
expect(demoTool(demo({ tool: "terragrunt" }), SCENARIOS)).toBe("terragrunt");
|
|
37
|
+
expect(demoTool(demo(), SCENARIOS)).toBe("terraform");
|
|
38
|
+
expect(demoTool(demo({ scenario: "tg-new" }), SCENARIOS)).toBe(null);
|
|
39
|
+
});
|
|
40
|
+
it("is live only for the tool whose scenario runs, never on the base", () => {
|
|
41
|
+
expect(liveFor(demo({ tool: "terragrunt" }), "terragrunt", SCENARIOS)).toBe(true);
|
|
42
|
+
expect(liveFor(demo({ tool: "terragrunt" }), "terraform", SCENARIOS)).toBe(false);
|
|
43
|
+
expect(liveFor(demo({ tool: "terraform", scenario: "base" }), "terraform", SCENARIOS)).toBe(false);
|
|
44
|
+
expect(liveFor(null, "terraform", SCENARIOS)).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("the runs a tool's page keeps (behold#571)", () => {
|
|
49
|
+
const index = { doc: { schema: "demo.runs/v1", runs: [{ id: 3, repo: "admin/demo-terragrunt", tool: "terragrunt" }, { id: 2, repo: "admin/demo", tool: "terraform" }, { id: 1, tool: "terragrunt" }, { id: 0 }] } };
|
|
50
|
+
it("keeps the repo's runs, a run's tool without a repo, and a run with neither on Terraform's page alone", () => {
|
|
51
|
+
expect(filterIndex(index, { tool: "terragrunt", repo: "admin/demo-terragrunt" }).doc.runs.map((r) => r.id)).toEqual([3, 1]);
|
|
52
|
+
expect(filterIndex(index, { tool: "terraform", repo: "admin/demo" }).doc.runs.map((r) => r.id)).toEqual([2, 0]);
|
|
53
|
+
expect(keepRun({ id: 9, repo: "admin/demo" }, { tool: "terraform", repo: null })).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
it("leaves an answer that is no index as it came", () => {
|
|
56
|
+
const absent = { doc: null, absent: "no runs" };
|
|
57
|
+
expect(filterIndex(absent, { tool: "terraform", repo: "x" })).toBe(absent);
|
|
58
|
+
});
|
|
59
|
+
it("shows the graph only for the repo this behold serves", () => {
|
|
60
|
+
expect(servesRepo("forgejo:3000/admin/demo", "admin/demo")).toBe(true);
|
|
61
|
+
expect(servesRepo("forgejo:3000/admin/demo", "admin/demo-terragrunt")).toBe(false);
|
|
62
|
+
expect(servesRepo("forgejo:3000/xadmin/demo", "admin/demo")).toBe(false);
|
|
63
|
+
expect(servesRepo(null, "admin/demo")).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("the replay's pace (behold#571)", () => {
|
|
68
|
+
const steps = [
|
|
69
|
+
{ step: "pull-request", at: "2026-10-20T10:00:00Z" },
|
|
70
|
+
{ step: "planned", at: "2026-10-20T10:00:40Z" },
|
|
71
|
+
{ step: "merged", at: "2026-10-20T10:01:30Z" },
|
|
72
|
+
{ step: "reset", at: "2026-10-20T10:03:00Z" },
|
|
73
|
+
];
|
|
74
|
+
it("holds each step as long as it was recorded, and the last for the driver's hold", () => {
|
|
75
|
+
expect(stepDurations(steps)).toEqual([40_000, 50_000, 90_000, REPLAY_LAST_HOLD_MS]);
|
|
76
|
+
});
|
|
77
|
+
it("steps through at that pace, standing for the step's time plus the time into it, and loops", () => {
|
|
78
|
+
expect(replayAt(steps, 0).index).toBe(0);
|
|
79
|
+
expect(replayAt(steps, 39_999).index).toBe(0);
|
|
80
|
+
const p = replayAt(steps, 45_000);
|
|
81
|
+
expect(p.index).toBe(1);
|
|
82
|
+
expect(p.at.toISOString()).toBe("2026-10-20T10:00:45.000Z");
|
|
83
|
+
expect(replayAt(steps, 180_000).index).toBe(3);
|
|
84
|
+
const loop = 180_000 + REPLAY_LAST_HOLD_MS;
|
|
85
|
+
expect(replayAt(steps, 0).loop).toBe(loop);
|
|
86
|
+
expect(replayAt(steps, loop + 45_000).index).toBe(1);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("a run as it stood in the replay (behold#571)", () => {
|
|
91
|
+
const run = {
|
|
92
|
+
id: 7,
|
|
93
|
+
status: "success",
|
|
94
|
+
created_at: "2026-10-20T10:00:00Z",
|
|
95
|
+
started_at: "2026-10-20T10:00:00Z",
|
|
96
|
+
ended_at: "2026-10-20T10:01:40Z",
|
|
97
|
+
jobs: [
|
|
98
|
+
{ id: 1, name: "plan", needs: [], status: "success", started_at: "2026-10-20T10:00:00Z", ended_at: "2026-10-20T10:01:40Z", steps: [{ name: "Plan", status: "success", started_at: "2026-10-20T10:00:00Z", ended_at: "2026-10-20T10:01:40Z", log: ["1", "2", "3", "4"] }] },
|
|
99
|
+
{ id: 2, name: "fmt", needs: [], status: "skipped", started_at: null, ended_at: null, steps: [] },
|
|
100
|
+
],
|
|
101
|
+
};
|
|
102
|
+
const t = (iso) => Date.parse(iso);
|
|
103
|
+
it("is queued before it started, running until it ended with its log so far, then as it ended", () => {
|
|
104
|
+
expect(runAsOf(run, t("2026-10-20T09:59:00Z")).jobs[0].status).toBe("queued");
|
|
105
|
+
const mid = runAsOf(run, t("2026-10-20T10:00:50Z"));
|
|
106
|
+
expect([mid.status, mid.ended_at, mid.jobs[0].status, mid.jobs[0].steps[0].log]).toEqual(["running", null, "running", ["1", "2"]]);
|
|
107
|
+
expect(mid.jobs[1].status).toBe("skipped");
|
|
108
|
+
const end = runAsOf(run, t("2026-10-20T10:02:00Z"));
|
|
109
|
+
expect([end.status, end.jobs[0].steps[0].log.length]).toEqual(["success", 4]);
|
|
110
|
+
});
|
|
111
|
+
it("lists the cycle's runs created by then, newest first", () => {
|
|
112
|
+
const later = { ...run, id: 8, created_at: "2026-10-20T10:05:00Z", started_at: "2026-10-20T10:05:00Z", ended_at: "2026-10-20T10:06:00Z" };
|
|
113
|
+
expect(replayIndex([run, later], t("2026-10-20T10:01:00Z")).doc.runs.map((r) => r.id)).toEqual([7]);
|
|
114
|
+
expect(replayIndex([run, later], t("2026-10-20T10:07:00Z")).doc.runs.map((r) => r.id)).toEqual([8, 7]);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("the replay's banner (behold#571)", () => {
|
|
119
|
+
const cycle = { started: "2026-10-20T14:05:00Z", ended: "2026-10-20T14:09:00Z", steps: [{ index: 3, scenarios: 3, at: "2026-10-20T14:05:00Z" }] };
|
|
120
|
+
it("says when the replayed run was, and about when the tool is live again", () => {
|
|
121
|
+
expect(replayBanner(cycle, 6)).toBe("Replay of the run at 14:05 UTC · live again in about 6 min");
|
|
122
|
+
expect(replayBanner(cycle, null)).toBe("Replay of the run at 14:05 UTC");
|
|
123
|
+
});
|
|
124
|
+
it("estimates from the loop's order and the cycle's length, and not without them", () => {
|
|
125
|
+
const now = new Date("2026-10-20T15:01:00Z");
|
|
126
|
+
// Scenario 1 of 3 started a minute ago; scenario 2 runs before the tool's 3rd: 3 min left, then 4.
|
|
127
|
+
expect(liveEstimate({ index: 1, scenarios: 3, started_at: "2026-10-20T15:00:00Z" }, cycle, now)).toBe(7);
|
|
128
|
+
// Scenario 2 is live: the tool's is next.
|
|
129
|
+
expect(liveEstimate({ index: 2, scenarios: 3, started_at: "2026-10-20T15:00:00Z" }, cycle, now)).toBe(3);
|
|
130
|
+
expect(liveEstimate({ index: null, scenarios: 3, started_at: "2026-10-20T15:00:00Z" }, cycle, now)).toBe(null);
|
|
131
|
+
expect(liveEstimate({ index: 1, scenarios: 4, started_at: "2026-10-20T15:00:00Z" }, cycle, now)).toBe(null);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("the branding table (behold#571)", () => {
|
|
136
|
+
it("has a page for every tool the server routes, and nothing else", () => {
|
|
137
|
+
expect(Object.keys(VENDORS)).toEqual([...VENDOR_TOOLS]);
|
|
138
|
+
});
|
|
139
|
+
it("links every name to an official https site, and shows a logo for choudoufu alone", () => {
|
|
140
|
+
for (const v of Object.values(VENDORS)) expect(v.url).toMatch(/^https:\/\//);
|
|
141
|
+
expect(Object.entries(VENDORS).filter(([, v]) => v.logo).map(([id]) => id)).toEqual(["choudoufu"]);
|
|
142
|
+
expect(VENDORS["cdk-terrain"].url).toBe("https://cdktn.io/");
|
|
143
|
+
expect(VENDORS.terraform.credit).toContain("Terraform is a trademark of HashiCorp.");
|
|
144
|
+
});
|
|
145
|
+
it("says on each page who makes the tool and that they don't endorse the demo, naming no other tool or maker", () => {
|
|
146
|
+
for (const tool of VENDOR_TOOLS) {
|
|
147
|
+
const v = VENDORS[tool];
|
|
148
|
+
const words = [v.name, ...creditLines(tool).map((l) => l.map((s) => s.text).join("")), ...Object.values(notRunWords(v)), legendWords("components", tool), legendWords("resources", tool)];
|
|
149
|
+
for (const w of words) expect(namesOther(w, tool), `${tool}: ${w}`).toBe(false);
|
|
150
|
+
// choudoufu is ours: its credit says so instead.
|
|
151
|
+
if (tool !== "choudoufu") expect(v.credit).toMatch(/(does not|don't) endorse this demo\./);
|
|
152
|
+
}
|
|
153
|
+
expect(VENDORS.choudoufu.credit).toMatch(/AWS and Kubernetes/);
|
|
154
|
+
expect(VENDORS.choudoufu.credit).not.toMatch(/experimental/i);
|
|
155
|
+
});
|
|
156
|
+
it("links each name in the footer, the tool's to its site and OpenTofu's where its scenarios run on it", () => {
|
|
157
|
+
const tf = creditLines("terraform");
|
|
158
|
+
expect(tf.flat().filter((s) => s.url).map((s) => [s.text, s.url])).toEqual([
|
|
159
|
+
["Terraform", VENDORS.terraform.url],
|
|
160
|
+
["OpenTofu", RUNS_WITH.opentofu.url],
|
|
161
|
+
]);
|
|
162
|
+
expect(creditLines("terragrunt").flat().filter((s) => s.url).map((s) => s.text)).toEqual(["Terragrunt"]);
|
|
163
|
+
expect(linkSegments("a b a", [{ name: "a", url: "u" }])).toEqual([{ text: "a", url: "u" }, { text: " b a" }]);
|
|
164
|
+
});
|
|
165
|
+
it("counts as a competitor each other tool of the same kind: engines with engines, orchestrators with orchestrators", () => {
|
|
166
|
+
const matrix = Object.fromEntries(VENDOR_TOOLS.map((t) => [t, otherTerms(t)]));
|
|
167
|
+
expect(matrix).toEqual({
|
|
168
|
+
// OpenTofu is an engine too, but Terraform's scenarios really run on it.
|
|
169
|
+
terraform: ["choudoufu"],
|
|
170
|
+
terragrunt: ["CDK Terrain", "CDKTN", "Open Construct", "Atmos", "Cloud Posse", "Terramate"],
|
|
171
|
+
"cdk-terrain": ["Terragrunt", "Gruntwork", "Atmos", "Cloud Posse", "Terramate"],
|
|
172
|
+
choudoufu: ["Terraform", "HashiCorp", "OpenTofu"],
|
|
173
|
+
});
|
|
174
|
+
expect(otherTerms(null)).toEqual([]);
|
|
175
|
+
});
|
|
176
|
+
it("lets an orchestrator's page name the engines it runs on, and keeps an engine's page from naming another engine", () => {
|
|
177
|
+
expect(namesOther("Big Terraform setups run on OpenTofu", "terragrunt")).toBe(false);
|
|
178
|
+
expect(namesOther("Big Terraform setups", "cdk-terrain")).toBe(false);
|
|
179
|
+
expect(namesOther("compare with Atmos", "terragrunt")).toBe(true);
|
|
180
|
+
expect(namesOther("Terragrunt units", "cdk-terrain")).toBe(true);
|
|
181
|
+
expect(namesOther("Big Terraform setups", "choudoufu")).toBe(true);
|
|
182
|
+
expect(namesOther("Terraform.", "choudoufu")).toBe(true);
|
|
183
|
+
expect(namesOther("runs on choudoufu", "terraform")).toBe(true);
|
|
184
|
+
expect(namesOther("works with OpenTofu", "terraform")).toBe(false);
|
|
185
|
+
expect(rootWord("choudoufu")).toBe("a root");
|
|
186
|
+
expect(rootWord("terragrunt")).toBe("a Terraform root");
|
|
187
|
+
});
|
|
188
|
+
it("never counts a resource address, a bucket or a path as a brand", () => {
|
|
189
|
+
for (const t of ["terraform_data.app", "data.terraform_remote_state.network", "state in demo-terraform-state", "s3://demo-terraform-state/edge.tfstate", "developer.hashicorp.com/terraform", ".terraform.lock.hcl", "terragrunt-cache"]) {
|
|
190
|
+
expect(namesOther(t, "choudoufu"), t).toBe(false);
|
|
191
|
+
expect(namesOther(t, "cdk-terrain"), t).toBe(false);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
it("leaves out the driver's words that name a competitor, and the loop's count", () => {
|
|
195
|
+
const d = scrubDemo(demo({ intro: "Big Terraform setups split", about: { changes: "A unit changes", watch: "Terramate plans", shows: "It waits" }, label: "Atmos applies wave 2", title: "CDK Terrain roots" }), "terragrunt");
|
|
196
|
+
expect([d.intro, d.label, d.title, d.scenarios, d.index]).toEqual(["Big Terraform setups split", "", "a new subnet", null, null]);
|
|
197
|
+
expect(d.about).toEqual({ changes: "A unit changes", shows: "It waits" });
|
|
198
|
+
expect(scenarioHead(d, { vendor: "terragrunt" })).toEqual({ kicker: "Scenario · a new subnet", title: "a new subnet" });
|
|
199
|
+
expect(scenarioHead(demo(), { vendor: "terraform" }).kicker).not.toMatch(/of 3|\d/);
|
|
200
|
+
expect(scrubDemo(demo({ intro: "Big Terraform setups split" }), "choudoufu").intro).toBe(null);
|
|
201
|
+
});
|
|
202
|
+
it("leaves a log line naming a competitor out, says how many, and keeps the engine's lines on an orchestrator's page", () => {
|
|
203
|
+
const rows = logRows(["plan ok", "Terramate binary", "Terraform v1.9", "edge: s3://demo-terraform-state/edge.tfstate"], { hide: (l) => namesOther(l, "terragrunt") });
|
|
204
|
+
expect(rows).toEqual([{ n: 1, text: "plan ok" }, { n: 3, text: "Terraform v1.9" }, { n: 4, text: "edge: s3://demo-terraform-state/edge.tfstate" }, { hidden: 1 }]);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe("a tool's page, drawn (behold#571)", () => {
|
|
209
|
+
const fakeDocument = () => {
|
|
210
|
+
const make = (tag) => {
|
|
211
|
+
const n = { tagName: tag, className: "", textContent: "", children: [] };
|
|
212
|
+
n.appendChild = (c) => (n.children.push(c), c);
|
|
213
|
+
n.append = (...cs) => cs.forEach((c) => n.children.push(typeof c === "string" ? { tagName: "#text", textContent: c, children: [] } : c));
|
|
214
|
+
n.replaceChildren = () => (n.children = []);
|
|
215
|
+
return n;
|
|
216
|
+
};
|
|
217
|
+
return { createElement: make };
|
|
218
|
+
};
|
|
219
|
+
const text = (n) => (n.children && n.children.length ? n.children.map(text).join("") : n.textContent || "");
|
|
220
|
+
const all = (n) => [n, ...(n.children || []).flatMap(all)];
|
|
221
|
+
it("puts the tool's name before the scenario, the banner over it, and the credit in the foot, with no count", async () => {
|
|
222
|
+
const { renderKiosk } = await import("./kiosk.js");
|
|
223
|
+
const was = globalThis.document;
|
|
224
|
+
globalThis.document = fakeDocument();
|
|
225
|
+
try {
|
|
226
|
+
const bar = document.createElement("header");
|
|
227
|
+
const foot = document.createElement("footer");
|
|
228
|
+
const about = { changes: "A unit changes", watch: "Terramate would plan it too", shows: "It waits" };
|
|
229
|
+
const s = { vendor: "terragrunt", mode: "replay", replay: { banner: "Replay of the run at 14:05 UTC" }, noReports: true, demo: { demo: scrubDemo(demo({ tool: "terragrunt", intro: "Big Terraform setups", about }), "terragrunt") }, answer: null, progress: null };
|
|
230
|
+
renderKiosk(bar, s, new Date("2026-10-20T10:01:00Z"), foot);
|
|
231
|
+
expect(bar.children[0].className).toBe("k-replay");
|
|
232
|
+
const title = all(bar).find((n) => n.className === "k-title");
|
|
233
|
+
expect(title.children.map((c) => c.className)).toEqual(["k-brand", "k-kicker", "k-title-text"]);
|
|
234
|
+
const brand = title.children[0];
|
|
235
|
+
expect([brand.href, brand.target, brand.rel, text(brand)]).toEqual([VENDORS.terragrunt.url, "_blank", "noopener", "Terragrunt"]);
|
|
236
|
+
expect(text(bar)).not.toMatch(/of 3|Terramate/);
|
|
237
|
+
// behold#571: no intro band at a live step; the intro fills the card's box at reset.
|
|
238
|
+
expect(text(bar)).not.toContain("Big Terraform setups");
|
|
239
|
+
// The engine Terragrunt runs on is no competitor: the shared intro stays.
|
|
240
|
+
const reset = { ...s, demo: { demo: { ...s.demo.demo, step: "reset" } } };
|
|
241
|
+
const bar2 = document.createElement("header");
|
|
242
|
+
renderKiosk(bar2, reset, new Date("2026-10-20T10:01:00Z"), document.createElement("footer"));
|
|
243
|
+
expect(text(bar2)).toContain("Big Terraform setups");
|
|
244
|
+
// No graph of this repo here: one line in the header says so.
|
|
245
|
+
expect(all(bar).filter((n) => n.className === "k-nograph-note").map(text)).toEqual([NO_GRAPH_WORDS]);
|
|
246
|
+
expect(all(foot).filter((n) => n.className === "k-credit").map(text)).toEqual([VENDORS.terragrunt.credit]);
|
|
247
|
+
} finally {
|
|
248
|
+
globalThis.document = was;
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
it("names the checkout of a tool's repo, and offers it before the first cycle only when it is served", () => {
|
|
252
|
+
expect(toolCheckout("terraform")).toBe("demo");
|
|
253
|
+
expect(toolCheckout("terragrunt")).toBe("demo-terragrunt");
|
|
254
|
+
expect(toolCheckout("nope")).toBe(null);
|
|
255
|
+
expect(notRunRepo("terragrunt", ["demo", "demo-terragrunt"])).toBe("demo-terragrunt");
|
|
256
|
+
expect(notRunRepo("terragrunt", ["demo"])).toBe(null);
|
|
257
|
+
expect(notRunRepo("terragrunt", null)).toBe(null);
|
|
258
|
+
expect(notRunRepo("nope", ["demo"])).toBe(null);
|
|
259
|
+
});
|
|
260
|
+
it("says the graph below is the repo on the base only when there is one", () => {
|
|
261
|
+
expect(notRunWords(VENDORS.terragrunt).text).not.toContain("Below");
|
|
262
|
+
expect(notRunWords(VENDORS.terragrunt, { graph: true }).text).toContain("Below, its repo as it stands on the base.");
|
|
263
|
+
expect(notRunWords(VENDORS.terragrunt, { graph: true }).title).toBe("This scenario hasn't run yet");
|
|
264
|
+
});
|
|
265
|
+
it("says calmly that a tool's scenario hasn't run yet, with its name and link", async () => {
|
|
266
|
+
const { renderKiosk } = await import("./kiosk.js");
|
|
267
|
+
const was = globalThis.document;
|
|
268
|
+
globalThis.document = fakeDocument();
|
|
269
|
+
try {
|
|
270
|
+
const bar = document.createElement("header");
|
|
271
|
+
const foot = document.createElement("footer");
|
|
272
|
+
renderKiosk(bar, { vendor: "choudoufu", mode: "none", demo: null }, new Date(), foot);
|
|
273
|
+
expect(text(bar)).toContain("This scenario hasn't run yet");
|
|
274
|
+
const brand = all(bar).find((n) => n.className === "k-brand");
|
|
275
|
+
expect(brand.href).toBe(VENDORS.choudoufu.url);
|
|
276
|
+
expect(all(brand).some((n) => n.className === "k-brand-logo" && n.src === VENDORS.choudoufu.logo.src)).toBe(true);
|
|
277
|
+
} finally {
|
|
278
|
+
globalThis.document = was;
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
});
|
package/web/kiosk.html
CHANGED
|
@@ -16,20 +16,29 @@
|
|
|
16
16
|
--edge: #56596d; --managed: #a6e3a1; --foreign: #f9e2af; --pending: #89b4fa;
|
|
17
17
|
--degraded: #f38ba8; --neutral: #6d7187; --runtime: #94e2d5;
|
|
18
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;
|
|
19
22
|
--font-ui: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
20
23
|
--font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
21
24
|
}
|
|
22
25
|
* { box-sizing: border-box; }
|
|
26
|
+
/* behold#571: the kiosk's text is the softened ink, its secondary text and state colours the ones held legible on every surface. */
|
|
27
|
+
body { --fg: var(--soft); --muted: var(--muted-ink); --managed: var(--managed-ink); --foreign: var(--foreign-ink); --pending: var(--pending-ink); --degraded: var(--degraded-ink); --runtime: var(--runtime-ink); --zebra-row: color-mix(in srgb, var(--zebra) 3.5%, transparent); }
|
|
23
28
|
/* #569 step 3: one screen. The bar is the narration, the frame is
|
|
24
29
|
behold's own view of the change, the foot is the plan's facts. Sizes
|
|
25
30
|
follow the screen's width, so a TV reads it from across a room. */
|
|
26
31
|
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--fg); font: clamp(13px, 0.82vw, 20px)/1.4 var(--font-ui); overflow: hidden; }
|
|
27
32
|
body { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; }
|
|
33
|
+
#k-head { position: relative; }
|
|
28
34
|
/* #569: the pipeline's runs (left), the selected run's jobs, steps and
|
|
29
35
|
log (middle), behold's view of the change (right). */
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#runs-
|
|
36
|
+
/* behold#571: each pane its own surface, a gap of the page between them. */
|
|
37
|
+
#stage { display: grid; grid-template-columns: minmax(15em, 18%) minmax(26em, 34%) minmax(0, 1fr); gap: 5px; padding: 0 5px; min-height: 0; background: var(--bg); }
|
|
38
|
+
#runs-side { overflow-y: auto; background: var(--surface-pane-b); border-radius: 6px 6px 0 0; }
|
|
39
|
+
#runs-main { overflow: hidden; display: flex; flex-direction: column; min-height: 0; background: var(--surface-pane-a); border-radius: 6px 6px 0 0; }
|
|
40
|
+
#k-runs-toggle { display: none; }
|
|
41
|
+
.r-run:nth-child(even of .r-run), .r-job:nth-child(even of .r-job), .r-log-line:nth-child(even of .r-log-line) { background: var(--zebra-row); }
|
|
33
42
|
.r-side-head { padding: 0.8em 1em 0.5em; }
|
|
34
43
|
.r-side-title { font-weight: 700; font-size: 1.05em; }
|
|
35
44
|
.r-side-note { color: var(--muted); font-size: 0.82em; margin-top: 0.15em; }
|
|
@@ -73,7 +82,7 @@
|
|
|
73
82
|
.r-caret { color: var(--muted); width: 0.8em; font-size: 0.8em; }
|
|
74
83
|
.r-step-name { font-size: 0.88em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
75
84
|
.r-open > .r-step-line { background: color-mix(in srgb, var(--active) 60%, transparent); }
|
|
76
|
-
.r-log { margin: 0.2em 0.6em 0.4em 1.6em; padding: 0.5em 0; background: var(--
|
|
85
|
+
.r-log { margin: 0.2em 0.6em 0.4em 1.6em; padding: 0.5em 0; background: var(--surface-pane-b); border-radius: 6px; font: 0.76em/1.45 var(--font-mono);
|
|
77
86
|
white-space: pre-wrap; overflow-wrap: anywhere; overflow-y: auto; max-height: 52vh; min-height: 6em; }
|
|
78
87
|
.r-log-line { display: grid; grid-template-columns: 3.2em 1fr; gap: 0.8em; padding: 0 0.8em 0 0; }
|
|
79
88
|
.r-ln { color: var(--muted); text-align: right; user-select: none; opacity: 0.7; }
|
|
@@ -86,26 +95,70 @@
|
|
|
86
95
|
@media (prefers-reduced-motion: no-preference) { .r-log-live { animation: r-blink 1s steps(2) infinite; } }
|
|
87
96
|
@keyframes r-blink { 50% { opacity: 0; } }
|
|
88
97
|
.r-note { color: var(--muted); padding: 0 1em; font-size: 0.88em; }
|
|
89
|
-
#
|
|
90
|
-
|
|
98
|
+
/* behold#571: a log line's feature: an outlined chip on its first line in the job, a faint dot after; its note opens under the line. */
|
|
99
|
+
.r-ann { font: 600 0.82em var(--font-ui); color: var(--fg); background: transparent; border: 1px solid var(--rule); border-radius: 999px; padding: 0 0.55em; margin-left: 0.3em; cursor: pointer; white-space: nowrap; vertical-align: 0.05em; }
|
|
100
|
+
.r-ann:hover, .r-ann:focus-visible { border-color: var(--focus); }
|
|
101
|
+
.r-ann-dot { display: inline-block; width: 0.55em; height: 0.55em; padding: 0; margin-left: 0.45em; border-radius: 50%; border: 0; background: var(--muted); opacity: 0.55; cursor: pointer; vertical-align: 0.1em; }
|
|
102
|
+
.r-ann-dot:hover { opacity: 1; }
|
|
103
|
+
.r-ann-terragucci { border-color: color-mix(in srgb, var(--pending) 70%, transparent); }
|
|
104
|
+
.r-ann-chant { border-color: color-mix(in srgb, var(--runtime) 70%, transparent); }
|
|
105
|
+
.r-ann-choudoufu, .r-ann-behold { border-color: color-mix(in srgb, var(--foreign) 70%, transparent); }
|
|
106
|
+
.r-ann-dot.r-ann-terragucci { background: var(--pending); } .r-ann-dot.r-ann-chant { background: var(--runtime); }
|
|
107
|
+
.r-ann-note { grid-column: 2; margin: 0.25em 0 0.45em; padding: 0.45em 0.7em; border-left: 3px solid var(--focus); background: var(--surface-box); border-radius: 0 6px 6px 0; font: 1.12em/1.4 var(--font-ui); white-space: normal; }
|
|
108
|
+
.r-ann-note-head { font: 600 0.8em var(--font-mono); text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); border: 0; }
|
|
109
|
+
.r-ann-note-text { margin: 0.15em 0 0.2em; color: var(--fg); }
|
|
110
|
+
.r-ann-more { color: var(--pending); }
|
|
111
|
+
.r-flash { background: color-mix(in srgb, var(--focus) 30%, transparent) !important; }
|
|
112
|
+
@media (prefers-reduced-motion: no-preference) { .r-flash { transition: background 0.6s; } }
|
|
113
|
+
/* behold#571: folded, whole lines only (fitFeatures); opened, it scrolls inside a bounded box. */
|
|
114
|
+
.r-feats { padding: 0.1em 1em 0.45em; border-bottom: 1px solid var(--rule); }
|
|
115
|
+
.r-feats-open .r-feats-list { max-height: 12em; overflow-y: auto; scrollbar-width: thin; }
|
|
116
|
+
.r-feats-label { padding: 0.3em 0 0.15em; }
|
|
117
|
+
.r-feats-list { display: flex; flex-wrap: wrap; gap: 0.3em 0.35em; align-items: center; }
|
|
118
|
+
.r-feats-list > [hidden] { display: none; }
|
|
119
|
+
.r-feats-product { font: 600 0.7em var(--font-mono); text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); border: 0; margin: 0 0.15em 0 0.4em; }
|
|
120
|
+
.r-feats-product:first-child { margin-left: 0; }
|
|
121
|
+
.r-feat, .r-feats-more { font: 0.8em var(--font-ui); line-height: 1.4; color: var(--fg); background: transparent; border: 1px solid var(--rule); border-radius: 999px; padding: 0.05em 0.6em; cursor: pointer; }
|
|
122
|
+
.r-feats-more { color: var(--pending); border-style: dashed; }
|
|
123
|
+
.r-feat:hover, .r-feat:focus-visible { border-color: var(--focus); background: var(--zebra-row); }
|
|
124
|
+
/*
|
|
125
|
+
* behold#571: the header in fixed boxes. The title, the card, the step
|
|
126
|
+
* line, and the hold's box each have a height that holds their text
|
|
127
|
+
* budget at 1600x1000 and 1920x1080 (terragucci#1061: the card's fields
|
|
128
|
+
* 120, 220 and 120 characters, the step 200); more scrolls inside the
|
|
129
|
+
* box, with a fade at the edge, so the panes below never move.
|
|
130
|
+
*/
|
|
131
|
+
#bar { position: relative; display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); gap: 0.4em 0.9em; align-items: stretch;
|
|
132
|
+
padding: 0.45em 1em 0.5em; background: var(--bg); }
|
|
91
133
|
#bar > .k-empty { grid-column: 1 / -1; }
|
|
92
|
-
|
|
93
|
-
|
|
134
|
+
.k-left { display: grid; grid-template-rows: var(--k-title-h) var(--k-card-h) var(--k-step-h); gap: 0.35em; min-width: 0; }
|
|
135
|
+
:root { --k-title-h: 1.8em; --k-card-h: 6.2em; --k-step-h: 3.35em; }
|
|
136
|
+
.k-box { min-height: 0; overflow-y: auto; scrollbar-width: thin; background: var(--surface-box); border-radius: 6px; padding: 0.35em 0.7em; }
|
|
137
|
+
.k-box.k-more-below { -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 1.3em), transparent); mask-image: linear-gradient(to bottom, #000 calc(100% - 1.3em), transparent); }
|
|
138
|
+
.k-box.k-more-above { -webkit-mask-image: linear-gradient(to top, #000 calc(100% - 1.3em), transparent); mask-image: linear-gradient(to top, #000 calc(100% - 1.3em), transparent); }
|
|
139
|
+
.k-box.k-more-above.k-more-below { -webkit-mask-image: linear-gradient(to bottom, transparent, #000 1.3em, #000 calc(100% - 1.3em), transparent); mask-image: linear-gradient(to bottom, transparent, #000 1.3em, #000 calc(100% - 1.3em), transparent); }
|
|
140
|
+
.k-box-title { background: transparent; padding: 0.1em 0.1em 0; overflow: hidden; }
|
|
141
|
+
/* behold#571: between scenarios the card's box carries what the demo is about. */
|
|
142
|
+
.k-intro { margin: 0; font-size: 1.12em; line-height: 1.4; color: var(--fg); }
|
|
143
|
+
.k-intro-head { display: block; font: 600 0.62em var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin-bottom: 0.15em; }
|
|
94
144
|
.k-kicker { font: 600 0.58em var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin-right: 0.9em; vertical-align: 0.2em; white-space: nowrap; }
|
|
95
145
|
.k-cycle { color: var(--muted); font: 0.78em var(--font-mono); white-space: nowrap; }
|
|
96
|
-
.k-title { margin: 0
|
|
146
|
+
.k-title { margin: 0; font-size: 1.3em; line-height: 1.25; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
97
147
|
/* behold#571: the scenario's card, three labelled rows under the title. */
|
|
98
|
-
.k-about { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 0.08em 0.9em; align-items: baseline; margin: 0
|
|
148
|
+
.k-about { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 0.08em 0.9em; align-items: baseline; margin: 0; font-size: 0.88em; line-height: 1.25; }
|
|
99
149
|
.k-about-label { font: 600 0.74em var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
|
|
100
150
|
.k-about-text { margin: 0; color: var(--fg); }
|
|
101
151
|
.k-muted { color: var(--muted); margin: 0.25em 0; }
|
|
102
|
-
.k-step { border-left: 4px solid var(--pending); padding: 0
|
|
152
|
+
.k-step { border-left: 4px solid var(--pending); padding: 0 0 0 0.7em; margin: 0; font-size: 0.95em; line-height: 1.3; }
|
|
103
153
|
.k-step-word { font: 600 0.72em var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin-right: 0.7em; }
|
|
104
154
|
.k-step-waiting { border-left-color: var(--foreign); }
|
|
105
155
|
.k-step-failed, .k-step-stalled { border-left-color: var(--degraded); }
|
|
106
156
|
.k-step-applied { border-left-color: var(--managed); }
|
|
107
157
|
.k-step-reset { border-left-color: var(--neutral); }
|
|
108
|
-
|
|
158
|
+
/* The right column takes the left's height and never sets it: its hold box scrolls instead. */
|
|
159
|
+
.k-right { display: grid; grid-template-rows: auto auto minmax(0, 1fr); gap: 0.35em; min-width: 0; min-height: 0; contain: size; }
|
|
160
|
+
.k-where { padding-right: 13em; flex-wrap: nowrap; }
|
|
161
|
+
.k-next .k-cycle { margin-left: auto; }
|
|
109
162
|
.k-where { display: flex; flex-wrap: wrap; gap: 0.35em 1em; align-items: center; }
|
|
110
163
|
.k-strip { list-style: none; display: flex; flex-wrap: wrap; gap: 0.35em; padding: 0; margin: 0; }
|
|
111
164
|
.k-stop { font: 0.82em var(--font-mono); padding: 0.25em 0.7em; border-radius: 999px; border: 1px solid var(--rule); color: var(--muted); }
|
|
@@ -117,6 +170,20 @@
|
|
|
117
170
|
.k-action { font-family: var(--font-mono); }
|
|
118
171
|
.k-when { color: var(--foreign); font-family: var(--font-mono); }
|
|
119
172
|
.k-approve { border-left: 4px solid var(--foreign); padding-left: 0.8em; }
|
|
173
|
+
/* behold#571: "How this hold ends": four rows that light from the records. */
|
|
174
|
+
.k-ap-head { font: 600 0.7em var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin: 0 0 0.1em; }
|
|
175
|
+
.k-ap-head .k-ap-plan { text-transform: none; letter-spacing: 0; color: var(--fg); font-weight: 400; }
|
|
176
|
+
.k-ap-rows { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.05em; }
|
|
177
|
+
.k-ap-row { display: grid; grid-template-columns: 1em 6.4em minmax(0, 1fr); gap: 0 0.35em; align-items: baseline; font-size: 0.84em; line-height: 1.28; }
|
|
178
|
+
.k-ap-mark { width: 0.75em; height: 0.75em; border-radius: 50%; border: 2px solid var(--muted); display: inline-block; align-self: start; margin-top: 0.25em; }
|
|
179
|
+
.k-ap-lit .k-ap-mark { background: var(--managed); border-color: var(--managed); }
|
|
180
|
+
.k-ap-going .k-ap-mark { border-color: var(--foreign); background: linear-gradient(90deg, var(--foreign) 50%, transparent 50%); }
|
|
181
|
+
.k-ap-label { font: 600 0.82em var(--font-mono); text-transform: uppercase; letter-spacing: 0.04em; color: var(--fg); }
|
|
182
|
+
.k-ap-dim .k-ap-label, .k-ap-dim .k-ap-text { color: var(--muted); }
|
|
183
|
+
.k-ap-facts { color: var(--muted); font-size: 0.92em; margin-left: 0.35em; }
|
|
184
|
+
.k-ap-bad { color: var(--degraded); }
|
|
185
|
+
.k-ap-row .k-command { grid-column: 3; margin: 0.1em 0; padding: 0.25em 0.6em; font-size: 0.92em; }
|
|
186
|
+
.k-ap .r-ann { font-size: 0.78em; }
|
|
120
187
|
.k-approve p { margin: 0.15em 0; }
|
|
121
188
|
.k-approve-gloss { color: var(--fg); font-size: 0.92em; }
|
|
122
189
|
.k-approve-then { color: var(--muted); font-size: 0.92em; }
|
|
@@ -127,7 +194,7 @@
|
|
|
127
194
|
#view-pane { display: flex; flex-direction: column; min-height: 0; min-width: 0; }
|
|
128
195
|
#view { display: block; width: 100%; flex: 1; min-height: 0; border: 0; background: var(--bg); }
|
|
129
196
|
/* #571: the toggle between the two views, over the frame by the view's caption. */
|
|
130
|
-
#views { display: flex; align-items: center; gap: 0.9em; padding: 0.45em 1em; border-bottom: 1px solid var(--rule); background: var(--
|
|
197
|
+
#views { display: flex; align-items: center; gap: 0.9em; padding: 0.45em 1em; border-bottom: 1px solid var(--rule); background: var(--surface-pane-a); border-radius: 6px 6px 0 0; }
|
|
131
198
|
#views[hidden] { display: none; }
|
|
132
199
|
.k-views { display: inline-flex; border: 1px solid var(--rule); border-radius: 999px; overflow: hidden; }
|
|
133
200
|
.k-view { font: 600 0.85em var(--font-ui); color: var(--muted); background: transparent; border: 0; padding: 0.3em 0.95em; cursor: pointer; }
|
|
@@ -136,17 +203,85 @@
|
|
|
136
203
|
.k-view-on { color: var(--bg); background: var(--pending); }
|
|
137
204
|
.k-view-on:hover { color: var(--bg); }
|
|
138
205
|
.k-view-note { color: var(--muted); font-size: 0.8em; }
|
|
139
|
-
#foot { display: flex; flex-wrap: wrap; gap: 0.2em 1.6em; padding: 0.
|
|
206
|
+
#foot { display: flex; flex-wrap: wrap; gap: 0.2em 1.6em; padding: 0.5em 1.6em; border-top: 1px solid var(--rule); background: var(--surface-box); font-size: 0.85em; }
|
|
140
207
|
.k-legend { color: var(--muted); flex-basis: 100%; }
|
|
208
|
+
/* behold#571: a tool's page. Its name (and logo where allowed) before the scenario, the replay's banner, the credit. */
|
|
209
|
+
.k-brand { display: inline-flex; align-items: center; gap: 0.35em; margin-right: 0.8em; color: var(--fg); text-decoration: none; font-weight: 700; vertical-align: 0.05em; }
|
|
210
|
+
.k-brand:hover .k-brand-name { text-decoration: underline; }
|
|
211
|
+
.k-brand-logo { width: 1.25em; height: 1.25em; }
|
|
212
|
+
.k-replay { grid-column: 1 / -1; margin: 0; padding: 0.2em 0.7em; padding-right: 16em; border-left: 4px solid var(--neutral); background: var(--surface-box); color: var(--fg); font-size: 0.92em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
213
|
+
/* behold#571: "What is this?" and the theme, at the header's top right, outside the boxes that redraw. */
|
|
214
|
+
#k-tools { position: absolute; top: 0.4em; right: 1em; z-index: 5; display: flex; gap: 0.7em; align-items: center; font-size: 0.8em; }
|
|
215
|
+
.k-what { font: inherit; color: var(--pending); background: transparent; border: 0; padding: 0.1em 0; cursor: pointer; text-decoration: underline; text-underline-offset: 2px; }
|
|
216
|
+
#k-what-pop { position: absolute; top: 1.8em; right: 0; width: min(34em, calc(100vw - 32px)); padding: 0.7em 0.9em; background: var(--surface-pane-b); color: var(--fg); border: 1px solid var(--focus);
|
|
217
|
+
border-radius: 8px; box-shadow: 0 6px 24px color-mix(in srgb, var(--shadow) 70%, transparent); font-size: 1.15em; line-height: 1.45; }
|
|
218
|
+
#k-what-pop[hidden] { display: none; }
|
|
219
|
+
#k-what-pop p { margin: 0; }
|
|
220
|
+
.k-menu summary { cursor: pointer; color: var(--muted); list-style: none; }
|
|
221
|
+
.k-menu summary::-webkit-details-marker { display: none; }
|
|
222
|
+
.k-menu summary::after { content: " ▾"; }
|
|
223
|
+
.k-menu[open] summary { color: var(--fg); }
|
|
224
|
+
.k-menu-body { position: absolute; right: 0; top: 1.8em; padding: 0.5em; background: var(--surface-pane-b); border: 1px solid var(--rule); border-radius: 8px; }
|
|
225
|
+
.k-credit { color: var(--muted); flex-basis: 100%; }
|
|
226
|
+
.k-credit-link { color: var(--fg); }
|
|
227
|
+
.k-nograph-note { grid-column: 1 / -1; margin: 0; color: var(--muted); font-size: 0.88em; }
|
|
228
|
+
/* With no graph to show, the graph pane folds away and the runs and the log take its width. */
|
|
229
|
+
body.k-nograph #stage { grid-template-columns: minmax(15em, 22%) minmax(0, 1fr); }
|
|
230
|
+
body.k-nograph #view-pane { display: none; }
|
|
231
|
+
/* behold#571: before the tool's first cycle, no runs and no log: those panes fold away, the repo's graph takes the width, or nothing shows below the header. */
|
|
232
|
+
body.k-notyet #runs-side, body.k-notyet #runs-main, body.k-notyet #k-runs-toggle { display: none !important; }
|
|
233
|
+
body.k-notyet #stage { grid-template-columns: minmax(0, 1fr); }
|
|
141
234
|
.k-fact { color: var(--fg); }
|
|
142
|
-
@media (max-width: 900px) { #bar { grid-template-columns: 1fr; } #stage { grid-template-columns: 1fr; } }
|
|
235
|
+
@media (max-width: 900px) and (min-width: 601px) { #bar { grid-template-columns: 1fr; } #stage, body.k-nograph #stage { grid-template-columns: 1fr; } }
|
|
236
|
+
/*
|
|
237
|
+
* behold#571: a phone. One column that scrolls as a page: the header
|
|
238
|
+
* (its boxes as tall as their text), the graph, then the runs and their
|
|
239
|
+
* logs behind "Runs and logs". Nothing wider than the screen.
|
|
240
|
+
*/
|
|
241
|
+
@media (max-width: 600px) {
|
|
242
|
+
html, body { height: auto; overflow-x: hidden; overflow-y: auto; font-size: 14px; }
|
|
243
|
+
body { display: block; }
|
|
244
|
+
#bar { display: block; padding: 0.5em 16px 0.6em; }
|
|
245
|
+
#bar > * + * { margin-top: 0.45em; }
|
|
246
|
+
.k-left, .k-right { display: block; contain: none; }
|
|
247
|
+
.k-left > * + *, .k-right > * + * { margin-top: 0.4em; }
|
|
248
|
+
.k-box { height: auto; max-height: none; overflow: visible; -webkit-mask-image: none !important; mask-image: none !important; }
|
|
249
|
+
.k-title { white-space: normal; }
|
|
250
|
+
.k-where { padding-right: 0; }
|
|
251
|
+
.k-replay { padding-right: 0.7em; white-space: normal; }
|
|
252
|
+
#k-tools { position: static; justify-content: flex-end; margin: 0; padding: 0.4em 16px 0; }
|
|
253
|
+
#k-what-pop { right: 16px; left: 16px; width: auto; top: 2.2em; }
|
|
254
|
+
.k-ap-row { grid-template-columns: 1.1em 5.6em minmax(0, 1fr); }
|
|
255
|
+
#stage, body.k-nograph #stage { display: flex; flex-direction: column; padding: 0; gap: 0; }
|
|
256
|
+
#view-pane { order: 1; height: 64vh; }
|
|
257
|
+
body.k-nograph #view-pane { display: none; }
|
|
258
|
+
#k-runs-toggle { order: 2; display: block; margin: 0.6em 16px; padding: 0.6em 1em; font: 600 1em var(--font-ui); color: var(--fg); background: var(--surface-pane-b); border: 1px solid var(--rule); border-radius: 8px; cursor: pointer; text-align: left; }
|
|
259
|
+
#k-runs-toggle::after { content: " ▸"; }
|
|
260
|
+
body.k-runs-open #k-runs-toggle::after { content: " ▾"; }
|
|
261
|
+
#runs-side { order: 3; display: none; max-height: 50vh; border-radius: 0; }
|
|
262
|
+
#runs-main { order: 4; display: none; height: 85vh; border-radius: 0; }
|
|
263
|
+
body.k-runs-open #runs-side, body.k-runs-open #runs-main { display: flex; flex-direction: column; }
|
|
264
|
+
body.k-runs-open #runs-side { display: block; }
|
|
265
|
+
.r-log { margin-left: 0.4em; max-height: 60vh; }
|
|
266
|
+
.r-log-line { grid-template-columns: 2.4em 1fr; gap: 0.5em; }
|
|
267
|
+
.r-ann-note { grid-column: 1 / -1; }
|
|
268
|
+
#foot { padding: 0.5em 16px; }
|
|
269
|
+
}
|
|
143
270
|
</style>
|
|
144
271
|
</head>
|
|
145
272
|
<body>
|
|
146
|
-
<
|
|
273
|
+
<div id="k-head">
|
|
274
|
+
<div id="k-tools">
|
|
275
|
+
<button type="button" class="k-what" aria-expanded="false" aria-controls="k-what-pop">What is this?</button>
|
|
276
|
+
<details class="k-menu"><summary>Theme</summary><div class="k-menu-body" id="k-theme"></div></details>
|
|
277
|
+
<div id="k-what-pop" role="dialog" aria-label="What is this?" hidden></div>
|
|
278
|
+
</div>
|
|
279
|
+
<header id="bar" aria-live="polite"></header>
|
|
280
|
+
</div>
|
|
147
281
|
<div id="stage">
|
|
148
282
|
<nav id="runs-side" aria-label="pipeline runs"></nav>
|
|
149
283
|
<section id="runs-main" aria-live="polite"></section>
|
|
284
|
+
<button type="button" id="k-runs-toggle" aria-expanded="false">Runs and logs</button>
|
|
150
285
|
<section id="view-pane">
|
|
151
286
|
<div id="views" hidden></div>
|
|
152
287
|
<iframe id="view" title="behold's view of the change"></iframe>
|
|
@@ -156,8 +291,16 @@
|
|
|
156
291
|
<script type="module">
|
|
157
292
|
import { initTheme } from "/theme.js";
|
|
158
293
|
import { startKiosk } from "/kiosk.js";
|
|
294
|
+
import { pageTool } from "/kiosk-vendor.js";
|
|
159
295
|
initTheme();
|
|
160
|
-
|
|
296
|
+
// behold#571: /<tool> serves this page with its tool in a meta tag; /kiosk has none.
|
|
297
|
+
startKiosk(
|
|
298
|
+
{
|
|
299
|
+
bar: document.getElementById("bar"), frame: document.getElementById("view"), foot: document.getElementById("foot"), side: document.getElementById("runs-side"), main: document.getElementById("runs-main"), views: document.getElementById("views"),
|
|
300
|
+
tools: { what: document.querySelector(".k-what"), pop: document.getElementById("k-what-pop"), theme: document.getElementById("k-theme"), runsToggle: document.getElementById("k-runs-toggle") },
|
|
301
|
+
},
|
|
302
|
+
{ tool: pageTool(document) },
|
|
303
|
+
);
|
|
161
304
|
</script>
|
|
162
305
|
</body>
|
|
163
306
|
</html>
|