@intentius/behold 0.24.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +100 -0
- package/dist/cli.js +2088 -473
- package/package.json +1 -1
- package/web/app.js +101 -2
- package/web/history-picker.js +85 -0
- package/web/history-picker.test.js +77 -0
- package/web/index.html +35 -0
- package/web/terragucci-estate.js +328 -0
- package/web/terragucci-estate.test.js +114 -0
- package/web/terragucci-lifecycle.js +143 -0
- package/web/terragucci-lifecycle.test.js +60 -0
- package/web/terragucci-progress.js +127 -0
- package/web/terragucci-progress.test.js +59 -0
- package/web/terragucci-timeline.js +118 -0
- package/web/terragucci-timeline.test.js +56 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/behold",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "behold — a live control plane on chant. See your whole estate (every substrate in one graph), coloured by drift; act through delegated, gated Ops.",
|
package/web/app.js
CHANGED
|
@@ -13,8 +13,13 @@ import { readViewQuery, writeViewQuery, settleView, settlePlace, refusalLine } f
|
|
|
13
13
|
import { readEmbed, selectMessage, viewFromMessage } from "./embed.js";
|
|
14
14
|
import { gateCards } from "./workspace-gates.js";
|
|
15
15
|
import { cornerOf, estateLine, hrefOf as tgHref, legend as tgLegend, markLine, rootRows as tgRootRows, waveCards } from "./terragucci.js";
|
|
16
|
+
import { laneElement as tgLane, rootOfCard as tgRootOfCard } from "./terragucci-timeline.js";
|
|
17
|
+
import { renderLane } from "./terragucci-lifecycle.js";
|
|
18
|
+
import { initTerragucciEstate } from "./terragucci-estate.js";
|
|
19
|
+
import { cardProgress as tgCardProgress, progressLines as tgProgressLines, progressSection as tgProgressSection, stampProgress as tgStampProgress } from "./terragucci-progress.js";
|
|
16
20
|
import { seconds, whyQuery, whyView } from "./why.js";
|
|
17
21
|
import { readCostLine } from "./read-cost.js";
|
|
22
|
+
import { mountHistoryPicker } from "./history-picker.js";
|
|
18
23
|
import { applyMemberFrame, pendingLine, stillPending } from "./pending.js";
|
|
19
24
|
import { initTheme, setTheme, mountThemePicker, readableOn, colorForCategory, onThemeChange, getTokens, getTheme, pinTokensFor } from "./theme.js";
|
|
20
25
|
// #399 M2 / #401 M4 of #397: the colour-by modes' arithmetic and the
|
|
@@ -903,6 +908,27 @@ function inspect(node) {
|
|
|
903
908
|
panel.appendChild(p);
|
|
904
909
|
}
|
|
905
910
|
}
|
|
911
|
+
// #511: the newest apply's progress on this card, dated by the records read.
|
|
912
|
+
const tgProg = tgCardProgress(tgProgress && tgProgress.cards && tgProgress.cards[node.id]);
|
|
913
|
+
if (tgProg) {
|
|
914
|
+
const h = document.createElement("h3");
|
|
915
|
+
h.textContent = "terragucci apply progress (run view, not live)";
|
|
916
|
+
panel.appendChild(h);
|
|
917
|
+
for (const line of tgProgressLines(tgProg, new Date())) {
|
|
918
|
+
const p = document.createElement("p");
|
|
919
|
+
p.className = "tg-inspect";
|
|
920
|
+
p.textContent = line;
|
|
921
|
+
panel.appendChild(p);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
// #506: the audit record's entries for this card's root (the record names
|
|
925
|
+
// roots, never a resource), newest first.
|
|
926
|
+
const tgRoot = tgTimeline && tgRootOfCard(tgAnswer, node.id);
|
|
927
|
+
if (tgRoot) {
|
|
928
|
+
const h = document.createElement("h3");
|
|
929
|
+
h.textContent = "terragucci timeline (audit record)";
|
|
930
|
+
panel.append(h, tgLane(tgTimeline, { root: tgRoot }));
|
|
931
|
+
}
|
|
906
932
|
|
|
907
933
|
// Containment hierarchy: a resource shows its parent chain UP (composite →
|
|
908
934
|
// component); a collapsed composite (detail 1: `attrs.members` is a count)
|
|
@@ -3223,26 +3249,83 @@ function markDriftedCards(ir) {
|
|
|
3223
3249
|
// of this is live. A waiting wave offers its approve line to copy, no button.
|
|
3224
3250
|
let tgAnswer = null;
|
|
3225
3251
|
let tgHost = null;
|
|
3252
|
+
let tgTimeline = null; // #506: /api/terragucci/timeline, the audit record's lane
|
|
3226
3253
|
async function initTerragucci() {
|
|
3227
3254
|
const project = await apiFetch("/api/project").then((r) => r.json()).catch(() => ({}));
|
|
3228
3255
|
if (!project.terragucciReports) return;
|
|
3229
3256
|
await loadTerragucci();
|
|
3257
|
+
// #505: the chant/lifecycle lane, and what moved while this page is open.
|
|
3258
|
+
if (staticMode) return;
|
|
3259
|
+
await loadTerragucciLane();
|
|
3260
|
+
watchTerragucci();
|
|
3261
|
+
}
|
|
3262
|
+
let tgLifecycle = null;
|
|
3263
|
+
let tgPolled = null;
|
|
3264
|
+
async function loadTerragucciLane({ fresh = false } = {}) {
|
|
3265
|
+
tgLifecycle = await apiFetch(`/api/terragucci/lifecycle${fresh ? "?fresh=1" : ""}`)
|
|
3266
|
+
.then((r) => r.json())
|
|
3267
|
+
.catch((e) => ({ error: String(e) }));
|
|
3268
|
+
renderTerragucciPanel();
|
|
3269
|
+
}
|
|
3270
|
+
// The server polls only while this stream is open, and stops when the page goes.
|
|
3271
|
+
function watchTerragucci() {
|
|
3272
|
+
if (typeof EventSource === "undefined") return;
|
|
3273
|
+
const es = new EventSource("/api/terragucci/events");
|
|
3274
|
+
es.addEventListener("reports", () => loadTerragucci({ fresh: true }));
|
|
3275
|
+
// #511: the run view moved while a wave applies.
|
|
3276
|
+
es.addEventListener("progress", (e) => {
|
|
3277
|
+
tgProgress = JSON.parse(e.data);
|
|
3278
|
+
paintTgProgress();
|
|
3279
|
+
});
|
|
3280
|
+
es.addEventListener("lifecycle", (e) => {
|
|
3281
|
+
tgLifecycle = JSON.parse(e.data);
|
|
3282
|
+
renderTerragucciPanel();
|
|
3283
|
+
});
|
|
3284
|
+
es.addEventListener("polled", (e) => {
|
|
3285
|
+
tgPolled = JSON.parse(e.data);
|
|
3286
|
+
renderTerragucciPanel();
|
|
3287
|
+
});
|
|
3288
|
+
}
|
|
3289
|
+
function renderTerragucciLane(host, now) {
|
|
3290
|
+
if (staticMode || (!tgLifecycle && !tgPolled)) return;
|
|
3291
|
+
renderLane(host, tgLifecycle, tgPolled, now, { button, copy: copyToClipboard, link: tgLink });
|
|
3230
3292
|
}
|
|
3231
3293
|
async function loadTerragucci({ fresh = false } = {}) {
|
|
3232
3294
|
const answer = await apiFetch(`/api/terragucci${fresh ? "?fresh=1" : ""}`)
|
|
3233
3295
|
.then((r) => r.json())
|
|
3234
3296
|
.catch((e) => ({ error: String(e), code: "terragucci-report", remedy: "" }));
|
|
3235
3297
|
tgAnswer = answer;
|
|
3298
|
+
// #506: the timeline, whole; the inspect pane filters it by a card's root.
|
|
3299
|
+
tgTimeline = await apiFetch(`/api/terragucci/timeline${fresh ? "?fresh=1" : ""}`)
|
|
3300
|
+
.then((r) => r.json())
|
|
3301
|
+
.catch((e) => ({ error: String(e), code: "terragucci-report", remedy: "" }));
|
|
3236
3302
|
renderTerragucciPanel();
|
|
3237
3303
|
markTerragucciCards();
|
|
3304
|
+
if (!staticMode) await loadTgProgress({ fresh });
|
|
3305
|
+
}
|
|
3306
|
+
// #511: /api/terragucci/progress, the newest apply's run view on the cards.
|
|
3307
|
+
let tgProgress = null;
|
|
3308
|
+
async function loadTgProgress({ fresh = false } = {}) {
|
|
3309
|
+
tgProgress = await apiFetch(`/api/terragucci/progress${fresh ? "?fresh=1" : ""}`)
|
|
3310
|
+
.then((r) => r.json())
|
|
3311
|
+
.catch((e) => ({ error: String(e) }));
|
|
3312
|
+
paintTgProgress();
|
|
3313
|
+
}
|
|
3314
|
+
function paintTgProgress() {
|
|
3315
|
+
tgStampProgress(document.querySelector("#graph svg"), tgProgress, new Date());
|
|
3316
|
+
renderTerragucciPanel();
|
|
3238
3317
|
}
|
|
3239
3318
|
function markTerragucciCards() {
|
|
3240
3319
|
const svg = document.querySelector("#graph svg");
|
|
3320
|
+
tgStampProgress(svg, tgProgress, new Date()); // #511
|
|
3241
3321
|
if (!svg || !tgAnswer || !tgAnswer.cards) return;
|
|
3242
3322
|
const now = new Date();
|
|
3323
|
+
// #505: a pushed read replaces the marks, so the old ones go first.
|
|
3324
|
+
for (const old of svg.querySelectorAll("[data-tg-mark]")) old.remove();
|
|
3325
|
+
for (const g of svg.querySelectorAll(".tg-marked")) g.classList.remove("tg-marked", "tg-drift", "tg-wave", "tg-plan");
|
|
3243
3326
|
for (const [id, marks] of Object.entries(tgAnswer.cards)) {
|
|
3244
3327
|
const g = svg.querySelector('[data-node-id="' + CSS.escape(id) + '"]');
|
|
3245
|
-
if (!g
|
|
3328
|
+
if (!g) continue;
|
|
3246
3329
|
const corner = cornerOf(marks);
|
|
3247
3330
|
g.classList.add("tg-marked", `tg-${corner.tone}`);
|
|
3248
3331
|
const rect = g.querySelector("rect");
|
|
@@ -3257,6 +3340,7 @@ function markTerragucciCards() {
|
|
|
3257
3340
|
tag.setAttribute("fill", corner.tone === "drift" ? "var(--degraded)" : corner.tone === "wave" ? "var(--pending)" : "var(--muted)");
|
|
3258
3341
|
tag.textContent = `${corner.text} · ${markAge(marks)}`;
|
|
3259
3342
|
const title = document.createElementNS("http://www.w3.org/2000/svg", "title");
|
|
3343
|
+
title.setAttribute("data-tg-mark", "title");
|
|
3260
3344
|
title.textContent = marks.map((m) => markLine(m, now)).join("\n") + "\n(from terragucci reports, not live: the Terragucci tab links each run)";
|
|
3261
3345
|
g.append(title, tag);
|
|
3262
3346
|
}
|
|
@@ -3292,6 +3376,7 @@ function renderTerragucciPanel() {
|
|
|
3292
3376
|
fix.className = "tg-legend";
|
|
3293
3377
|
fix.textContent = (a && a.remedy) || "";
|
|
3294
3378
|
host.append(err, fix);
|
|
3379
|
+
renderTerragucciLane(host, new Date());
|
|
3295
3380
|
return;
|
|
3296
3381
|
}
|
|
3297
3382
|
const now = new Date();
|
|
@@ -3308,6 +3393,8 @@ function renderTerragucciPanel() {
|
|
|
3308
3393
|
line.appendChild(tgLink(est.text, est.href));
|
|
3309
3394
|
host.appendChild(line);
|
|
3310
3395
|
}
|
|
3396
|
+
renderTerragucciLane(host, now);
|
|
3397
|
+
if (tgProgress) host.appendChild(tgProgressSection(tgProgress, now, tgLink)); // #511
|
|
3311
3398
|
for (const w of waveCards(a, now)) {
|
|
3312
3399
|
const box = document.createElement("div");
|
|
3313
3400
|
box.className = "ws-gate tg-wave";
|
|
@@ -3369,12 +3456,23 @@ function renderTerragucciPanel() {
|
|
|
3369
3456
|
].filter(Boolean).join(" · ");
|
|
3370
3457
|
host.appendChild(um);
|
|
3371
3458
|
}
|
|
3459
|
+
if (tgTimeline) {
|
|
3460
|
+
const h = document.createElement("div");
|
|
3461
|
+
h.className = "tg-timeline-title";
|
|
3462
|
+
h.textContent = "Timeline";
|
|
3463
|
+
host.append(h, tgLane(tgTimeline));
|
|
3464
|
+
}
|
|
3372
3465
|
if (!staticMode) {
|
|
3373
|
-
const again = button("Read the reports again", "", () =>
|
|
3466
|
+
const again = button("Read the reports again", "", () => {
|
|
3467
|
+
loadTerragucci({ fresh: true });
|
|
3468
|
+
loadTerragucciLane({ fresh: true }); // #505: and chant/lifecycle
|
|
3469
|
+
});
|
|
3374
3470
|
host.appendChild(again);
|
|
3375
3471
|
}
|
|
3376
3472
|
}
|
|
3377
3473
|
initTerragucci();
|
|
3474
|
+
// #509: a control repo draws its projects from estate.json and their run views.
|
|
3475
|
+
initTerragucciEstate({ fetchUrl: apiFetch, copy: copyToClipboard });
|
|
3378
3476
|
|
|
3379
3477
|
function renderPanelCarve() {
|
|
3380
3478
|
if (!carveMode()) return;
|
|
@@ -6110,6 +6208,7 @@ async function initActions() {
|
|
|
6110
6208
|
pill.title = "An exported, read-only snapshot — no live observe or deploy.";
|
|
6111
6209
|
pill.style.cssText = "align-self:center;font:var(--t-caption)/1.4 var(--font-mono);color:var(--muted);border:1px solid var(--line);border-radius:var(--r-ctl);padding:2px 8px";
|
|
6112
6210
|
bar.appendChild(pill);
|
|
6211
|
+
mountHistoryPicker(bar); // #510: other commits' views, when ../history.json sits beside this one
|
|
6113
6212
|
previewMode = true;
|
|
6114
6213
|
return; // nothing else in the bar is a read
|
|
6115
6214
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// #510: a published view lives at views/<name>/<commit>/, with
|
|
2
|
+
// views/<name>/history.json beside the commits (newest first, {commit, at,
|
|
3
|
+
// project, generated}). Inside one, this picker reads ../history.json and
|
|
4
|
+
// opens another commit's view. It only reads: switching is a navigation, and
|
|
5
|
+
// a bundle served anywhere without a history.json beside it shows no picker.
|
|
6
|
+
|
|
7
|
+
const COMMIT_SHAPE = /^[0-9a-f]{7,64}$/;
|
|
8
|
+
|
|
9
|
+
/** The rows of a history.json document worth offering, in its order. */
|
|
10
|
+
export function historyRows(doc) {
|
|
11
|
+
if (!Array.isArray(doc)) return [];
|
|
12
|
+
return doc.filter((e) => e && typeof e === "object" && typeof e.commit === "string" && COMMIT_SHAPE.test(e.commit));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Which row the page at `pathname` is: the commit named by one of its path segments, or null. */
|
|
16
|
+
export function commitOfPath(pathname, rows) {
|
|
17
|
+
const segs = String(pathname || "").split("/").filter(Boolean);
|
|
18
|
+
for (let i = segs.length - 1; i >= 0; i--) {
|
|
19
|
+
const hit = rows.find((r) => r.commit === segs[i]);
|
|
20
|
+
if (hit) return hit.commit;
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const stamp = (iso) => (typeof iso === "string" && iso.length >= 16 ? iso.slice(0, 16).replace("T", " ") : "");
|
|
26
|
+
|
|
27
|
+
/** One option's words: the commit, and when its view was exported. */
|
|
28
|
+
export function historyLabel(row) {
|
|
29
|
+
const when = stamp(row.generated) || stamp(row.at);
|
|
30
|
+
return `${row.commit.slice(0, 12)}${when ? ` · exported ${when}` : ""}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** One option's tooltip. */
|
|
34
|
+
export function historyTitle(row) {
|
|
35
|
+
return [`commit ${row.commit}`, row.at ? `committed ${row.at}` : "", row.generated ? `exported ${row.generated}` : "", row.project ? `project ${row.project}` : ""].filter(Boolean).join("\n");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Where a pick goes: the sibling commit's view, keeping the lens in the query and the fragment. */
|
|
39
|
+
export function commitHref(commit, search = "", hash = "") {
|
|
40
|
+
return `../${commit}/index.html${search || ""}${hash || ""}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Read ../history.json and put a commit picker into `host`. Resolves to the
|
|
45
|
+
* <select>, or null when there is no history to pick from.
|
|
46
|
+
*/
|
|
47
|
+
export async function mountHistoryPicker(host, env = {}) {
|
|
48
|
+
const fetchFn = env.fetch || ((u, o) => fetch(u, o));
|
|
49
|
+
const loc = env.location || location;
|
|
50
|
+
const doc = env.document || document;
|
|
51
|
+
let rows;
|
|
52
|
+
try {
|
|
53
|
+
const r = await fetchFn("../history.json", { cache: "no-cache" });
|
|
54
|
+
if (!r.ok) return null;
|
|
55
|
+
rows = historyRows(await r.json());
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
if (!rows.length) return null;
|
|
60
|
+
const current = commitOfPath(loc.pathname, rows);
|
|
61
|
+
const select = doc.createElement("select");
|
|
62
|
+
select.title = "Published views, newest first: open the view exported at another commit";
|
|
63
|
+
select.setAttribute("aria-label", "Published view history");
|
|
64
|
+
select.style.cssText = "align-self:center;font:var(--t-caption)/1.4 var(--font-mono);color:var(--fg);background:var(--panel);border:1px solid var(--line);border-radius:var(--r-ctl);padding:1px 4px;margin-left:6px;max-width:16em";
|
|
65
|
+
if (!current) {
|
|
66
|
+
const here = doc.createElement("option");
|
|
67
|
+
here.value = "";
|
|
68
|
+
here.textContent = "this view (not in history)";
|
|
69
|
+
here.selected = true;
|
|
70
|
+
select.appendChild(here);
|
|
71
|
+
}
|
|
72
|
+
for (const row of rows) {
|
|
73
|
+
const o = doc.createElement("option");
|
|
74
|
+
o.value = row.commit;
|
|
75
|
+
o.textContent = historyLabel(row);
|
|
76
|
+
o.title = historyTitle(row);
|
|
77
|
+
if (row.commit === current) o.selected = true;
|
|
78
|
+
select.appendChild(o);
|
|
79
|
+
}
|
|
80
|
+
select.addEventListener("change", () => {
|
|
81
|
+
if (select.value && select.value !== current) loc.assign(commitHref(select.value, loc.search, loc.hash));
|
|
82
|
+
});
|
|
83
|
+
host.appendChild(select);
|
|
84
|
+
return select;
|
|
85
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { commitHref, commitOfPath, historyLabel, historyRows, mountHistoryPicker } from "./history-picker.js";
|
|
3
|
+
|
|
4
|
+
// #510: the picker inside a published commit's view, reading ../history.json.
|
|
5
|
+
const A = "a".repeat(40);
|
|
6
|
+
const B = "b".repeat(40);
|
|
7
|
+
const history = [
|
|
8
|
+
{ commit: A, at: "2026-10-09T08:00:00.000Z", project: "shop", generated: "2026-10-10T12:00:00.000Z" },
|
|
9
|
+
{ commit: B, at: "2026-10-08T08:00:00.000Z", project: "shop", generated: "2026-10-08T09:30:00.000Z" },
|
|
10
|
+
{ commit: "../x", at: "", project: "", generated: "" },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
/** Just enough of a document for the picker. */
|
|
14
|
+
function fakeDocument() {
|
|
15
|
+
const make = (tag) => {
|
|
16
|
+
const el = {
|
|
17
|
+
tag,
|
|
18
|
+
children: [],
|
|
19
|
+
style: {},
|
|
20
|
+
attrs: {},
|
|
21
|
+
listeners: {},
|
|
22
|
+
appendChild: (c) => (el.children.push(c), c),
|
|
23
|
+
setAttribute: (k, v) => (el.attrs[k] = v),
|
|
24
|
+
addEventListener: (k, f) => (el.listeners[k] = f),
|
|
25
|
+
};
|
|
26
|
+
return el;
|
|
27
|
+
};
|
|
28
|
+
return { createElement: make, host: make("div") };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("the history picker (#510)", () => {
|
|
32
|
+
it("offers the rows with a commit, newest first, labeled with the commit and when it was exported", () => {
|
|
33
|
+
const rows = historyRows(history);
|
|
34
|
+
expect(rows.map((r) => r.commit)).toEqual([A, B]);
|
|
35
|
+
expect(historyLabel(rows[0])).toBe(`${A.slice(0, 12)} · exported 2026-10-10 12:00`);
|
|
36
|
+
expect(historyRows({})).toEqual([]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("knows which commit the page is from its path", () => {
|
|
40
|
+
const rows = historyRows(history);
|
|
41
|
+
expect(commitOfPath(`/r/views/behold/${B}/index.html`, rows)).toBe(B);
|
|
42
|
+
expect(commitOfPath(`/r/views/behold/${A}/`, rows)).toBe(A);
|
|
43
|
+
expect(commitOfPath("/somewhere/else/", rows)).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("reads ../history.json, selects this commit, and opens another in place, keeping the query and fragment", async () => {
|
|
47
|
+
const asked = [];
|
|
48
|
+
const went = [];
|
|
49
|
+
const doc = fakeDocument();
|
|
50
|
+
const location = { pathname: `/r/views/behold/${A}/index.html`, search: "?env=prod", hash: "#x", assign: (u) => went.push(u) };
|
|
51
|
+
const fetch = async (u, o) => (asked.push([u, o]), { ok: true, json: async () => history });
|
|
52
|
+
const select = await mountHistoryPicker(doc.host, { fetch, location, document: doc });
|
|
53
|
+
expect(asked).toEqual([["../history.json", { cache: "no-cache" }]]);
|
|
54
|
+
expect(doc.host.children).toEqual([select]);
|
|
55
|
+
expect(select.children.map((o) => [o.value, !!o.selected])).toEqual([[A, true], [B, false]]);
|
|
56
|
+
select.value = B;
|
|
57
|
+
select.listeners.change();
|
|
58
|
+
expect(went).toEqual([`../${B}/index.html?env=prod#x`]);
|
|
59
|
+
expect(commitHref(A)).toBe(`../${A}/index.html`);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("shows nothing when no history.json sits beside the view", async () => {
|
|
63
|
+
const doc = fakeDocument();
|
|
64
|
+
const location = { pathname: "/bundle/index.html", search: "", hash: "", assign: () => {} };
|
|
65
|
+
expect(await mountHistoryPicker(doc.host, { fetch: async () => ({ ok: false }), location, document: doc })).toBeNull();
|
|
66
|
+
expect(await mountHistoryPicker(doc.host, { fetch: async () => Promise.reject(new Error("offline")), location, document: doc })).toBeNull();
|
|
67
|
+
expect(doc.host.children).toEqual([]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("marks a view that is not in the history rather than claiming to be another commit", async () => {
|
|
71
|
+
const doc = fakeDocument();
|
|
72
|
+
const location = { pathname: `/r/views/behold/${"c".repeat(40)}/index.html`, search: "", hash: "", assign: () => {} };
|
|
73
|
+
const select = await mountHistoryPicker(doc.host, { fetch: async () => ({ ok: true, json: async () => history }), location, document: doc });
|
|
74
|
+
expect(select.children[0]).toMatchObject({ value: "", selected: true });
|
|
75
|
+
expect(select.children).toHaveLength(3);
|
|
76
|
+
});
|
|
77
|
+
});
|
package/web/index.html
CHANGED
|
@@ -158,9 +158,44 @@
|
|
|
158
158
|
#tab-terragucci .tg-cells > span::before { content: attr(data-stage) ": "; color: var(--muted); }
|
|
159
159
|
#tab-terragucci .tg-drift, #tab-terragucci .tg-failed { color: var(--degraded); }
|
|
160
160
|
#tab-terragucci .tg-wave { color: var(--pending); }
|
|
161
|
+
/* #509: a control repo's projects, over the empty graph area. Dated, never live. */
|
|
162
|
+
#tg-estate { grid-row: 1; grid-column: 1; z-index: 2; overflow: auto; background: var(--bg); padding: 10px 14px; display: flex; flex-direction: column; gap: 10px; font-size: var(--t-caption); }
|
|
163
|
+
#tg-estate .tg-estate-head { display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: baseline; }
|
|
164
|
+
#tg-estate .tg-legend, #tg-estate .tg-none, #tg-estate .ws-gate-facts, #tg-estate .ws-gate-note { color: var(--muted); overflow-wrap: anywhere; }
|
|
165
|
+
#tg-estate .tg-refusal, #tg-estate .tg-bad { color: var(--degraded); font-weight: 600; }
|
|
166
|
+
#tg-estate a { color: inherit; }
|
|
167
|
+
#tg-estate .tg-estate-graph { overflow: auto; }
|
|
168
|
+
#tg-estate svg text { fill: var(--fg); font-size: 11px; }
|
|
169
|
+
#tg-estate .tg-project-box { fill: var(--panel); stroke: var(--rule); }
|
|
170
|
+
#tg-estate .tg-project-box.tg-no-run { stroke-dasharray: 4 3; }
|
|
171
|
+
#tg-estate .tg-project-name { font-weight: 600; font-size: 12px; }
|
|
172
|
+
#tg-estate svg .tg-project-counts, #tg-estate svg .tg-project-run.tg-none, #tg-estate svg .tg-wave-label { fill: var(--muted); }
|
|
173
|
+
#tg-estate svg .tg-project-run.tg-bad { fill: var(--degraded); }
|
|
174
|
+
#tg-estate svg .tg-wave-waiting, #tg-estate svg .tg-wave-applying { fill: var(--pending); }
|
|
175
|
+
#tg-estate svg .tg-wave-failed, #tg-estate svg .tg-wave-refused { fill: var(--degraded); }
|
|
176
|
+
#tg-estate .tg-root-node rect { fill: var(--bg); stroke: var(--edge); }
|
|
177
|
+
#tg-estate .tg-root-node text { font-family: var(--font-mono); }
|
|
178
|
+
#tg-estate .tg-edge { fill: none; stroke: var(--edge); stroke-width: 1.3; }
|
|
179
|
+
#tg-estate .tg-edge-cross { stroke-dasharray: 5 4; stroke: var(--pending); }
|
|
180
|
+
#tg-estate .tg-estate-projects { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 10px; }
|
|
181
|
+
#tg-estate .tg-estate-project { border: 1px solid var(--rule); border-radius: var(--r-ctl); padding: 6px 8px; display: flex; flex-direction: column; gap: 4px; }
|
|
182
|
+
#tg-estate .tg-estate-title { font-weight: 600; overflow-wrap: anywhere; }
|
|
183
|
+
#tg-estate .ws-gate { border: 1px solid var(--pending, var(--rule)); border-radius: var(--r-ctl); padding: 4px 6px; }
|
|
184
|
+
#tg-estate .tg-estate-cmd { display: flex; gap: 6px; align-items: center; }
|
|
185
|
+
#tg-estate .tg-command { font-family: var(--font-mono); overflow-wrap: anywhere; }
|
|
161
186
|
#tab-terragucci .tg-plan { color: var(--fg); }
|
|
162
187
|
#tab-terragucci .tg-none, #tab-terragucci .tg-ok { color: var(--muted); }
|
|
163
188
|
#tab-terragucci a { color: inherit; }
|
|
189
|
+
/* #506: the audit record's lane, in the tab and in a card's inspect pane. */
|
|
190
|
+
#tab-terragucci .tg-timeline-title { font-weight: 600; border-top: 1px solid var(--rule); padding-top: 6px; }
|
|
191
|
+
.tg-timeline { display: flex; flex-direction: column; gap: 3px; font-size: var(--t-caption); }
|
|
192
|
+
.tg-timeline .tg-legend { color: var(--muted); }
|
|
193
|
+
.tg-timeline .tg-refusal { color: var(--degraded); font-weight: 600; }
|
|
194
|
+
.tg-timeline .tg-entry { overflow-wrap: anywhere; }
|
|
195
|
+
.tg-timeline .tg-when { color: var(--muted); font-family: var(--font-mono); }
|
|
196
|
+
.tg-timeline .tg-failed, .tg-timeline .tg-drift { color: var(--degraded); }
|
|
197
|
+
.tg-timeline .tg-wave { color: var(--pending); }
|
|
198
|
+
.tg-timeline a { color: inherit; }
|
|
164
199
|
/* Fit reset + a zoom hint, floating top-right of the graph pane. */
|
|
165
200
|
#zoom-toggle { position: absolute; top: 8px; right: 8px; z-index: 2; background: var(--panel);
|
|
166
201
|
color: var(--muted); border: 1px solid var(--rule); border-radius: var(--r-ctl); padding: 4px 10px;
|