@intentius/behold 0.13.1 → 0.15.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 +78 -7
- package/dist/cli.js +2310 -1874
- package/example-argo-estate/app-a/package.json +2 -2
- package/example-argo-estate/app-b/package.json +2 -2
- package/example-argo-estate/control-plane/package.json +2 -2
- package/example-argo-estate/package-lock.json +144 -52
- package/example-carve/app/package-lock.json +9 -9
- package/example-carve/app/package.json +2 -2
- package/example-flux-estate/app-a/package.json +2 -2
- package/example-flux-estate/app-b/package.json +2 -2
- package/example-flux-estate/control-plane/package.json +2 -2
- package/example-flux-estate/package-lock.json +144 -52
- package/example-k8s/package-lock.json +18 -18
- package/example-k8s/package.json +3 -3
- package/example-writes/package-lock.json +14 -14
- package/example-writes/package.json +3 -3
- package/package.json +3 -3
- package/web/app.js +124 -5
- package/web/index.html +24 -2
- package/web/operator.js +247 -14
- package/web/operator.test.js +188 -4
- package/web/release.js +66 -0
- package/web/release.test.js +52 -0
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
"": {
|
|
7
7
|
"name": "behold-example-writes",
|
|
8
8
|
"dependencies": {
|
|
9
|
-
"@intentius/chant": "^0.
|
|
10
|
-
"@intentius/chant-lexicon-aws": "^0.
|
|
11
|
-
"@intentius/chant-lexicon-temporal": "^0.
|
|
9
|
+
"@intentius/chant": "^0.53.1",
|
|
10
|
+
"@intentius/chant-lexicon-aws": "^0.53.1",
|
|
11
|
+
"@intentius/chant-lexicon-temporal": "^0.53.1"
|
|
12
12
|
}
|
|
13
13
|
},
|
|
14
14
|
"node_modules/@dagrejs/dagre": {
|
|
@@ -443,9 +443,9 @@
|
|
|
443
443
|
}
|
|
444
444
|
},
|
|
445
445
|
"node_modules/@intentius/chant": {
|
|
446
|
-
"version": "0.
|
|
447
|
-
"resolved": "https://registry.npmjs.org/@intentius/chant/-/chant-0.
|
|
448
|
-
"integrity": "sha512-
|
|
446
|
+
"version": "0.53.1",
|
|
447
|
+
"resolved": "https://registry.npmjs.org/@intentius/chant/-/chant-0.53.1.tgz",
|
|
448
|
+
"integrity": "sha512-AIVy6/NQxnplHPip+oYhbgr1qe8QCzUc7heqiXoV9OZynn6x+/4sflrFha98dOotJ7Fraj2/QjfGQ+q1DpXK8w==",
|
|
449
449
|
"license": "Apache-2.0",
|
|
450
450
|
"dependencies": {
|
|
451
451
|
"@dagrejs/dagre": "^3.0.0",
|
|
@@ -461,30 +461,30 @@
|
|
|
461
461
|
}
|
|
462
462
|
},
|
|
463
463
|
"node_modules/@intentius/chant-lexicon-aws": {
|
|
464
|
-
"version": "0.
|
|
465
|
-
"resolved": "https://registry.npmjs.org/@intentius/chant-lexicon-aws/-/chant-lexicon-aws-0.
|
|
466
|
-
"integrity": "sha512-
|
|
464
|
+
"version": "0.53.1",
|
|
465
|
+
"resolved": "https://registry.npmjs.org/@intentius/chant-lexicon-aws/-/chant-lexicon-aws-0.53.1.tgz",
|
|
466
|
+
"integrity": "sha512-LyI7IMib1m69k+oNovrBw/RNJqIM17vSj9ew+h5kviGf3KHfyU0VxPX5n6mxqZCNK5LAoMtRlW/cdzN2caJAWQ==",
|
|
467
467
|
"license": "Apache-2.0",
|
|
468
468
|
"dependencies": {
|
|
469
469
|
"fflate": "^0.8.2",
|
|
470
470
|
"js-yaml": "^4.3.1"
|
|
471
471
|
},
|
|
472
472
|
"peerDependencies": {
|
|
473
|
-
"@intentius/chant": "^0.
|
|
473
|
+
"@intentius/chant": "^0.53.1",
|
|
474
474
|
"typescript": "^5.9.3"
|
|
475
475
|
}
|
|
476
476
|
},
|
|
477
477
|
"node_modules/@intentius/chant-lexicon-temporal": {
|
|
478
|
-
"version": "0.
|
|
479
|
-
"resolved": "https://registry.npmjs.org/@intentius/chant-lexicon-temporal/-/chant-lexicon-temporal-0.
|
|
480
|
-
"integrity": "sha512-
|
|
478
|
+
"version": "0.53.1",
|
|
479
|
+
"resolved": "https://registry.npmjs.org/@intentius/chant-lexicon-temporal/-/chant-lexicon-temporal-0.53.1.tgz",
|
|
480
|
+
"integrity": "sha512-zcj1OzDgOXUG/c50Bd3FWW7arodD72Wm50CItar0XBcXEV/w+gZaB/ksXx/D5vrRJvccZP2oIMSAsdLaVRso4w==",
|
|
481
481
|
"license": "Apache-2.0",
|
|
482
482
|
"dependencies": {
|
|
483
483
|
"@temporalio/common": "^1.17.2",
|
|
484
484
|
"js-yaml": "^4.3.1"
|
|
485
485
|
},
|
|
486
486
|
"peerDependencies": {
|
|
487
|
-
"@intentius/chant": "^0.
|
|
487
|
+
"@intentius/chant": "^0.53.1",
|
|
488
488
|
"typescript": "^5.9.3",
|
|
489
489
|
"zod": "^4.3.6"
|
|
490
490
|
}
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
"build": "./node_modules/.bin/chant build src --lexicon aws -o template.json"
|
|
8
8
|
},
|
|
9
9
|
"dependencies": {
|
|
10
|
-
"@intentius/chant": "^0.
|
|
11
|
-
"@intentius/chant-lexicon-aws": "^0.
|
|
12
|
-
"@intentius/chant-lexicon-temporal": "^0.
|
|
10
|
+
"@intentius/chant": "^0.53.1",
|
|
11
|
+
"@intentius/chant-lexicon-aws": "^0.53.1",
|
|
12
|
+
"@intentius/chant-lexicon-temporal": "^0.53.1"
|
|
13
13
|
}
|
|
14
14
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/behold",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"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.",
|
|
6
6
|
"bin": {
|
|
@@ -35,8 +35,8 @@
|
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@hono/node-server": "^2.0.5",
|
|
38
|
-
"@intentius/chant": "^0.
|
|
39
|
-
"@intentius/chant-k8s-client": "^0.
|
|
38
|
+
"@intentius/chant": "^0.53.1",
|
|
39
|
+
"@intentius/chant-k8s-client": "^0.53.1",
|
|
40
40
|
"@intentius/pinhole": "^0.3.7",
|
|
41
41
|
"hono": "^4.12.34"
|
|
42
42
|
},
|
package/web/app.js
CHANGED
|
@@ -46,12 +46,16 @@ import { fetchDemos, demoLabel, demoTitle, demoProgress } from "./demos.js";
|
|
|
46
46
|
// #284 item 2: the run playhead's panel — the verdict wording, the pending gate
|
|
47
47
|
// card and the settled-step rows. Everything it DECIDES is pure in there; the
|
|
48
48
|
// fetches, the SSE subscription and the graph re-pull stay here.
|
|
49
|
-
import { hasPlayhead, renderPlayhead } from "./run-playhead.js";
|
|
49
|
+
import { hasPlayhead, renderPlayhead, waitingFor } from "./run-playhead.js";
|
|
50
50
|
// #234 joins 3 + 1: the operator strip (one honestly-dated tick per ConvergeOp,
|
|
51
51
|
// the lease, the pending gate count) and the converge gate card, whose Approve
|
|
52
52
|
// records a fact for the next tick rather than releasing anything. Same split as
|
|
53
53
|
// the playhead — the decisions are pure in there, the fetches stay here.
|
|
54
54
|
import { hasOperator, renderOperator, APPROVED_SEMANTICS } from "./operator.js";
|
|
55
|
+
// #165: the recorded release on a component — which run put it here, from
|
|
56
|
+
// which commit, approved by whom — and, when the ledger's run id cannot be
|
|
57
|
+
// followed, the reason in words rather than a guessed link.
|
|
58
|
+
import { releaseRows } from "./release.js";
|
|
55
59
|
initTheme();
|
|
56
60
|
initPanel();
|
|
57
61
|
mountThemePicker(document.getElementById("panel-theme"));
|
|
@@ -382,6 +386,11 @@ function inspect(node) {
|
|
|
382
386
|
// joinComponentStatus, so its presence picks the component-status label set
|
|
383
387
|
// over the entity overlay's managed/foreign/pending.
|
|
384
388
|
const liveStatus = node.attrs && node.attrs._liveStatus;
|
|
389
|
+
// chant#2027: what the operating loop's last tick said about this component
|
|
390
|
+
// (src/component-status.ts's joinTickVerdicts). Same component-status
|
|
391
|
+
// vocabulary as `_liveStatus`, one tier lower and dated — so it picks the same
|
|
392
|
+
// label set, and it may sit alongside a live verdict rather than replacing it.
|
|
393
|
+
const tickStatus = node.attrs && node.attrs._tickStatus;
|
|
385
394
|
// behold#146: a Helm chart's status is artifact presence, not management —
|
|
386
395
|
// the join sets `_artifact` on a match, and every Helm::Chart in an overlay
|
|
387
396
|
// went through it, so the kind alone is the reliable picker.
|
|
@@ -396,7 +405,7 @@ function inspect(node) {
|
|
|
396
405
|
"status",
|
|
397
406
|
driftLabel ||
|
|
398
407
|
(node.lexicon === "op" ? opStatusLabel(node) : undefined) ||
|
|
399
|
-
(isArtifact ? ARTIFACT_STATUS_LABEL[st] : liveStatus ? COMPONENT_STATUS_LABEL[st] : STATUS_LABEL[st]) ||
|
|
408
|
+
(isArtifact ? ARTIFACT_STATUS_LABEL[st] : liveStatus || tickStatus ? COMPONENT_STATUS_LABEL[st] : STATUS_LABEL[st]) ||
|
|
400
409
|
st,
|
|
401
410
|
);
|
|
402
411
|
if (node.attrs && node.attrs._artifact) {
|
|
@@ -514,8 +523,58 @@ function inspect(node) {
|
|
|
514
523
|
live("", `runtime child — owned by ${node.runtimeOwner || "its declared parent"}, not itself declared`);
|
|
515
524
|
}
|
|
516
525
|
|
|
526
|
+
// What the operating loop's last tick said about this component (chant#2027).
|
|
527
|
+
// Its own section, never folded into "live status": a tick is dated, and the
|
|
528
|
+
// date is the point. Shown alongside a live verdict when there is one — the
|
|
529
|
+
// two can disagree, and which is newer is the reader's to see, not behold's to
|
|
530
|
+
// resolve. The heading itself carries the age, and a tick too old to have
|
|
531
|
+
// painted says so in words rather than leaving its verdict to be read as now.
|
|
532
|
+
if (tickStatus) {
|
|
533
|
+
const ago = tickStatus.at ? waitingFor(tickStatus.at) : null;
|
|
534
|
+
const tick = section(`converge tick${ago ? ` · ${ago} ago` : ""}${tickStatus.stale ? " · not painted" : ""}`);
|
|
535
|
+
tick("reconciliation", tickStatus.reconciliation);
|
|
536
|
+
if (tickStatus.detail) tick("detail", tickStatus.detail);
|
|
537
|
+
if (tickStatus.live !== undefined) tick("observed live", String(tickStatus.live));
|
|
538
|
+
if (tickStatus.unobserved) {
|
|
539
|
+
tick("unobserved", tickStatus.unobserved.detail ? `${tickStatus.unobserved.reason} — ${tickStatus.unobserved.detail}` : tickStatus.unobserved.reason);
|
|
540
|
+
}
|
|
541
|
+
tick("recorded by", tickStatus.env ? `${tickStatus.op} (${tickStatus.env})` : tickStatus.op);
|
|
542
|
+
tick("at", tickStatus.at);
|
|
543
|
+
if (tickStatus.tickId) tick("tick", tickStatus.tickId);
|
|
544
|
+
if (tickStatus.stale) {
|
|
545
|
+
tick("", "older than the operator's own cadence allows for — this verdict is history, so no colour was taken from it");
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// The recorded release (#165, #61's second clause): the ledger row behold
|
|
550
|
+
// reads on `chant components status` and, until now, read past. Its own
|
|
551
|
+
// section beside "live status", because the two answer different questions
|
|
552
|
+
// — what is running versus what was recorded putting it there — and the
|
|
553
|
+
// reconciliation verdict above is exactly their comparison. The run is a link
|
|
554
|
+
// only when the record itself carries an address (chant#2045); a bare id
|
|
555
|
+
// gets its reason, never a guessed forge (web/release.js).
|
|
556
|
+
const release = node.attrs && node.attrs._release;
|
|
557
|
+
if (release) {
|
|
558
|
+
const rel = section("release");
|
|
559
|
+
for (const [k, v] of releaseRows(release)) {
|
|
560
|
+
if (v && typeof v === "object" && v.href) {
|
|
561
|
+
const a = document.createElement("a");
|
|
562
|
+
a.href = v.href;
|
|
563
|
+
a.textContent = v.text;
|
|
564
|
+
a.target = "_blank";
|
|
565
|
+
a.rel = "noreferrer noopener";
|
|
566
|
+
a.title = `${v.href} — the address the release record itself carries (chant#2045). behold never invents this link.`;
|
|
567
|
+
rel(k, a);
|
|
568
|
+
} else {
|
|
569
|
+
rel(k, v);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
517
574
|
// Declared attributes — the source-of-truth values / cross-resource refs.
|
|
518
|
-
|
|
575
|
+
// The `release` chip is the card's abbreviation of the section above, not a
|
|
576
|
+
// declared value, so it stays out of this list when the section rendered.
|
|
577
|
+
const attrKeys = Object.keys(node.attrs || {}).filter((k) => !k.startsWith("_") && !(k === "release" && release));
|
|
519
578
|
if (attrKeys.length) {
|
|
520
579
|
const decl = section("declared");
|
|
521
580
|
for (const k of attrKeys) decl(k, fmtValue(node.attrs[k]));
|
|
@@ -589,7 +648,7 @@ function inspect(node) {
|
|
|
589
648
|
// `observed` map already carries a runtime child (chant's describeResources
|
|
590
649
|
// reports it the same as any other resource it found), and its own
|
|
591
650
|
// `fieldDrift` may apply too on a substrate with per-field ownership.
|
|
592
|
-
const observed = st === "good" || st === "warn" || st === "runtime" || (st === "neutral" && !liveStatus);
|
|
651
|
+
const observed = st === "good" || st === "warn" || st === "runtime" || (st === "neutral" && !liveStatus && !tickStatus);
|
|
593
652
|
if (view.env && observed) {
|
|
594
653
|
const forId = node.id;
|
|
595
654
|
const loading = document.createElement("p");
|
|
@@ -1867,6 +1926,11 @@ let runStatusInFlight = false;
|
|
|
1867
1926
|
// playhead's reason: a loop belongs to its project, not to a picked env/tier.
|
|
1868
1927
|
let operatorState = null;
|
|
1869
1928
|
let operatorInFlight = false;
|
|
1929
|
+
// #234, chant#2029 — the converge timeline behind the strip. Closed until a
|
|
1930
|
+
// human opens it, read once when they do, and never on a timer: the strip is the
|
|
1931
|
+
// at-a-glance line that ages by itself, the history is a ledger's past and does
|
|
1932
|
+
// not change while you read it. `data` is src/operator.ts's OperatorHistory.
|
|
1933
|
+
let operatorHistory = { open: false, loading: false, error: null, data: null };
|
|
1870
1934
|
let componentChoices = []; // component names for the apply picker — loaded lazily (independent of whether the graph pane is currently in components mode)
|
|
1871
1935
|
let componentStatusById = {}; // id -> _status ("good"|"accent"|"warn"|"neutral"), populated alongside componentChoices — lets the picker show which stacks are already applied
|
|
1872
1936
|
|
|
@@ -2067,7 +2131,17 @@ function mountOperator(host) {
|
|
|
2067
2131
|
if (!view.ops || !hasOperator(operatorState)) return;
|
|
2068
2132
|
const box = document.createElement("div");
|
|
2069
2133
|
box.className = "operator-strip";
|
|
2070
|
-
renderOperator(
|
|
2134
|
+
renderOperator(
|
|
2135
|
+
box,
|
|
2136
|
+
operatorState,
|
|
2137
|
+
{
|
|
2138
|
+
approve: (op, gate) => approveConvergeGate(op, gate),
|
|
2139
|
+
// A static export has no server to ask, so it gets no history affordance
|
|
2140
|
+
// at all rather than a button that can only fail.
|
|
2141
|
+
...(staticMode ? {} : { history: () => toggleOperatorHistory() }),
|
|
2142
|
+
},
|
|
2143
|
+
operatorHistory,
|
|
2144
|
+
);
|
|
2071
2145
|
host.appendChild(box);
|
|
2072
2146
|
}
|
|
2073
2147
|
|
|
@@ -3816,6 +3890,48 @@ function pollOperatorStatus() {
|
|
|
3816
3890
|
});
|
|
3817
3891
|
}
|
|
3818
3892
|
|
|
3893
|
+
/**
|
|
3894
|
+
* Open or close the converge history (#234, chant#2029), reading it the first
|
|
3895
|
+
* time it is opened.
|
|
3896
|
+
*
|
|
3897
|
+
* This is the whole poll discipline for the timeline: a click. `chant operator
|
|
3898
|
+
* log` walks the project's `chant/lifecycle` ledger, and an interval against it
|
|
3899
|
+
* would spend a process per tick of the clock to be handed a past that hasn't
|
|
3900
|
+
* moved. Re-opening re-reads, so a human who wants the newest ticks closes and
|
|
3901
|
+
* opens — an explicit ask, which is what this surface is.
|
|
3902
|
+
*/
|
|
3903
|
+
function toggleOperatorHistory() {
|
|
3904
|
+
operatorHistory = { ...operatorHistory, open: !operatorHistory.open };
|
|
3905
|
+
if (operatorHistory.open) loadOperatorHistory();
|
|
3906
|
+
renderDial();
|
|
3907
|
+
}
|
|
3908
|
+
|
|
3909
|
+
/**
|
|
3910
|
+
* Read one bounded window of the converge timeline.
|
|
3911
|
+
*
|
|
3912
|
+
* The server clamps and reports the window it used (src/operator.ts's
|
|
3913
|
+
* OPERATOR_LOG_MAX_LIMIT), so the panel states the bound rather than implying it
|
|
3914
|
+
* is showing everything. A refusal — a chant older than the `operator log`
|
|
3915
|
+
* subcommand, an unreadable answer — lands on the panel as its own sentence: an
|
|
3916
|
+
* empty timeline must never stand in for "behold couldn't ask".
|
|
3917
|
+
*/
|
|
3918
|
+
function loadOperatorHistory() {
|
|
3919
|
+
if (staticMode || operatorHistory.loading) return;
|
|
3920
|
+
operatorHistory = { ...operatorHistory, loading: true, error: null };
|
|
3921
|
+
apiFetch("/api/operator/log")
|
|
3922
|
+
.then((r) => r.json())
|
|
3923
|
+
.then((j) => {
|
|
3924
|
+
operatorHistory = j && j.history
|
|
3925
|
+
? { open: true, loading: false, error: null, data: j.history }
|
|
3926
|
+
: { open: true, loading: false, error: (j && j.error) || "the server answered with no timeline", data: null };
|
|
3927
|
+
renderDial();
|
|
3928
|
+
})
|
|
3929
|
+
.catch((e) => {
|
|
3930
|
+
operatorHistory = { open: true, loading: false, error: String(e), data: null };
|
|
3931
|
+
renderDial();
|
|
3932
|
+
});
|
|
3933
|
+
}
|
|
3934
|
+
|
|
3819
3935
|
/**
|
|
3820
3936
|
* Record a converge gate's resolution — `chant approve <op> <gate>`, delegated
|
|
3821
3937
|
* exactly as the run gate's signal is.
|
|
@@ -3837,6 +3953,9 @@ function approveConvergeGate(op, gate) {
|
|
|
3837
3953
|
showToast(`✓ ${gate} — ${APPROVED_SEMANTICS}`, true);
|
|
3838
3954
|
}
|
|
3839
3955
|
pollOperatorStatus();
|
|
3956
|
+
// A resolution just became a ledger record. Re-read the timeline only if
|
|
3957
|
+
// it is already open — still a human's ask, not a background refresh.
|
|
3958
|
+
if (operatorHistory.open) loadOperatorHistory();
|
|
3840
3959
|
});
|
|
3841
3960
|
}
|
|
3842
3961
|
|
package/web/index.html
CHANGED
|
@@ -390,9 +390,31 @@
|
|
|
390
390
|
.operator-meta { font: var(--t-caption)/1.5 var(--font-mono); color: var(--muted);
|
|
391
391
|
overflow-wrap: anywhere; }
|
|
392
392
|
.operator-gates { font: var(--t-caption)/1.6 var(--font-mono); color: var(--pending); flex: none; }
|
|
393
|
-
/* Why one line is all
|
|
394
|
-
the same voice .run-lost uses for what
|
|
393
|
+
/* Why one line is all the strip has, and where the rest is (chant#2029).
|
|
394
|
+
A statement, not an alarm — the same voice .run-lost uses for what
|
|
395
|
+
behold does not know. */
|
|
395
396
|
.operator-caveat { font-size: var(--t-caption); color: var(--muted); }
|
|
397
|
+
/* The converge history (chant#2029) — the timeline the strip is NOT,
|
|
398
|
+
reached from it. Closed by default and read on the click that opens it,
|
|
399
|
+
so this whole block is inert until a human asks for it. */
|
|
400
|
+
.operator-history { display: flex; flex-direction: column; gap: 4px; }
|
|
401
|
+
.operator-history-toggle { align-self: flex-start; background: none; color: var(--pending);
|
|
402
|
+
border: none; padding: 0; font-size: var(--t-caption); cursor: pointer; }
|
|
403
|
+
.operator-history-head { font: var(--t-caption)/1.5 var(--font-mono); color: var(--muted); }
|
|
404
|
+
/* Ticks and gate resolutions, oldest first — chant's own order. The rule
|
|
405
|
+
down the left is the lane: one column of time, read forward. */
|
|
406
|
+
.operator-timeline { display: flex; flex-direction: column; gap: 6px;
|
|
407
|
+
border-left: 1px solid var(--rule); padding-left: 8px; max-height: 320px; overflow-y: auto; }
|
|
408
|
+
.operator-entry { display: flex; flex-direction: column; gap: 1px; }
|
|
409
|
+
/* A resolution is the human's half of the loop, so it wears the gate's own
|
|
410
|
+
colour rather than the tick's. */
|
|
411
|
+
.operator-entry-gate-resolution .operator-meta { color: var(--managed); }
|
|
412
|
+
.operator-gated { font: var(--t-caption)/1.5 var(--font-mono); color: var(--foreign);
|
|
413
|
+
overflow-wrap: anywhere; }
|
|
414
|
+
/* chant#2028's address. A link only ever appears where a fact carried one
|
|
415
|
+
— there is no placeholder state for this rule to style. */
|
|
416
|
+
.operator-url { display: block; font: var(--t-caption)/1.5 var(--font-mono); color: var(--pending);
|
|
417
|
+
overflow-wrap: anywhere; }
|
|
396
418
|
/* The converge gate's own two lines, inside the shared .run-gate card:
|
|
397
419
|
which loop this is, and what approving actually does. */
|
|
398
420
|
.operator-loop { font: var(--t-caption)/1.5 var(--font-mono); color: var(--foreign); }
|