@intentius/behold 0.23.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.
@@ -4,7 +4,7 @@
4
4
  "pins": [
5
5
  {
6
6
  "package": "@intentius/chant-lexicon-terraform",
7
- "version": "0.102.0"
7
+ "version": "0.121.0"
8
8
  }
9
9
  ],
10
10
  "members": [
@@ -90,7 +90,7 @@ install them, so that every user who serves an ordinary chant project does not
90
90
  carry an HCL parser they will never run:
91
91
 
92
92
  ```sh
93
- npm install --no-save @intentius/chant-lexicon-terraform@^0.102.0 @cdktn/hcl2json@^0.24.0
93
+ npm install --no-save @intentius/chant-lexicon-terraform@^0.121.0 @cdktn/hcl2json@^0.24.0
94
94
  ```
95
95
 
96
96
  Without them the demo refuses before it serves, naming both packages, the
@@ -101,7 +101,7 @@ the packages `chant.workspace.json` pins, looked up from the workspace's own
101
101
  `node_modules` at exactly the pinned version. A demo copy has no
102
102
  `node_modules`, so `behold demo` links the lexicon behold resolved into the
103
103
  copy and sets the copy's pin to that version (#484). It says so as it loads:
104
- `linked @intentius/chant-lexicon-terraform 0.102.0 into the copy`. Serving
104
+ `linked @intentius/chant-lexicon-terraform 0.121.0 into the copy`. Serving
105
105
  your own declared workspace is different: install the pinned lexicon in its
106
106
  root, the way `chant workspace check` asks.
107
107
 
@@ -4,7 +4,7 @@
4
4
  "pins": [
5
5
  {
6
6
  "package": "@intentius/chant-lexicon-terraform",
7
- "version": "0.102.0"
7
+ "version": "0.121.0"
8
8
  }
9
9
  ],
10
10
  "members": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/behold",
3
- "version": "0.23.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.",
@@ -41,14 +41,14 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "@hono/node-server": "^2.0.5",
44
- "@intentius/chant": "^0.102.0",
45
- "@intentius/chant-k8s-client": "^0.102.0",
44
+ "@intentius/chant": "^0.121.1",
45
+ "@intentius/chant-k8s-client": "^0.121.1",
46
46
  "@intentius/pinhole": "^0.3.8",
47
47
  "ajv": "^8.20.0",
48
48
  "hono": "^4.12.34"
49
49
  },
50
50
  "peerDependencies": {
51
- "@intentius/chant-lexicon-terraform": "^0.102.0",
51
+ "@intentius/chant-lexicon-terraform": "^0.121.0",
52
52
  "@cdktn/hcl2json": "^0.24.0"
53
53
  },
54
54
  "peerDependenciesMeta": {
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 || g.querySelector('[data-tg-mark="1"]')) continue;
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", "", () => loadTerragucci({ fresh: true }));
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;