@intentius/behold 0.2.2

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/web/index.html ADDED
@@ -0,0 +1,248 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>behold</title>
7
+ <style>
8
+ :root {
9
+ --bg: #0d1117; --panel: #161b22; --line: #30363d; --fg: #e6edf3; --muted: #8b949e;
10
+ --managed: #3fb950; --foreign: #d29922; --pending: #58a6ff; --edge: #6e7681;
11
+ --degraded: #f85149;
12
+ }
13
+ * { box-sizing: border-box; }
14
+ html, body { margin: 0; height: 100%; background: var(--bg); color: var(--fg);
15
+ font: 14px/1.4 ui-sans-serif, system-ui, -apple-system, sans-serif; }
16
+ #app { display: grid; grid-template-columns: 1fr var(--inspect-w, 320px); grid-template-rows: auto auto auto 1fr auto;
17
+ height: 100%; }
18
+ #app.inspect-collapsed { grid-template-columns: 1fr 0; }
19
+ #app.inspect-collapsed #inspect { display: none; }
20
+ header { grid-column: 1 / 3; grid-row: 1; padding: 10px 16px; border-bottom: 1px solid var(--line);
21
+ display: flex; align-items: baseline; gap: 12px; }
22
+ header h1 { font-size: 15px; margin: 0; font-weight: 600; letter-spacing: .3px; }
23
+ header .meta { color: var(--muted); font-size: 12px; }
24
+ #graph { grid-row: 4; grid-column: 1; overflow: auto; position: relative; }
25
+ #inspect { grid-row: 4; grid-column: 2; border-left: 1px solid var(--line); background: var(--panel);
26
+ padding: 14px; overflow: auto; position: relative; }
27
+ #inspect h2 { font-size: 13px; margin: 0 0 8px; color: var(--muted); font-weight: 600;
28
+ text-transform: uppercase; letter-spacing: .5px; }
29
+ /* Declared/identity: key ABOVE value, value full-width — long ARNs wrap
30
+ readably instead of being crushed into a squeezed right column. */
31
+ #inspect dl { margin: 0; }
32
+ #inspect dt { color: var(--muted); font-size: 11px; margin-top: 9px; }
33
+ #inspect dd { margin: 1px 0 0; font-size: 12px; overflow-wrap: anywhere; }
34
+ /* Drag handle on the pane's left edge → resize; chevron → collapse. */
35
+ #inspect-resize { position: absolute; left: -3px; top: 0; bottom: 0; width: 7px; cursor: col-resize; z-index: 6; }
36
+ #inspect-resize:hover { background: var(--pending); opacity: .35; }
37
+ #inspect-collapse { position: absolute; top: 12px; right: 12px; z-index: 6; background: none;
38
+ border: none; color: var(--muted); font-size: 15px; cursor: pointer; line-height: 1; padding: 2px; }
39
+ #inspect-collapse:hover { color: var(--fg); }
40
+ #inspect-reopen { position: fixed; right: 0; top: 50%; transform: translateY(-50%); z-index: 30; display: none;
41
+ background: var(--panel); color: var(--muted); border: 1px solid var(--line); border-right: none;
42
+ border-radius: 6px 0 0 6px; padding: 10px 5px; cursor: pointer; writing-mode: vertical-rl; font-size: 11px; }
43
+ #inspect-reopen:hover { color: var(--fg); border-color: var(--pending); }
44
+ #app.inspect-collapsed #inspect-reopen { display: block; }
45
+ /* The graph SVG is pinhole's, self-styled. It fills the pane; behold drives
46
+ zoom/pan by mutating its viewBox (see setupPanZoom in app.js) — pinch or
47
+ ⌘/Ctrl+scroll to zoom at the cursor, drag to pan. It starts fit-to-pane;
48
+ the "⤢ fit" button resets. behold also adds a selection highlight. */
49
+ #graph svg { display: block; width: 100%; height: 100%; cursor: grab; touch-action: none; }
50
+ #graph svg.grabbing { cursor: grabbing; }
51
+ /* Edge hover-highlight — trace a single edge through an overlapping bundle.
52
+ pinhole already paints a fat transparent hit-path per edge (pointer-events
53
+ on the stroke), so hovering the edge OR either endpoint node lights the
54
+ visible line. The hovered edge is also raised to the top in JS so it isn't
55
+ buried under the bundle. */
56
+ #graph g[data-edge-from] .pin-edge-line { transition: stroke .08s ease, stroke-width .08s ease; }
57
+ #graph g[data-edge-from]:hover .pin-edge-line,
58
+ #graph g[data-edge-from].edge-hi .pin-edge-line { stroke: var(--pending); stroke-width: 3.2; }
59
+ /* Edge kind by reason (logical view): a data dependency reads as a dashed
60
+ "logical link", distinct from the solid network-traffic edges; hover
61
+ either for the full reason (native SVG <title>). */
62
+ #graph g[data-edge-via="data dependency"] .pin-edge-line { stroke-dasharray: 6 5; }
63
+ #graph g[data-edge-from][data-edge-via] { cursor: help; }
64
+ /* Fit reset + a zoom hint, floating top-right of the graph pane. */
65
+ #zoom-toggle { position: absolute; top: 8px; right: 8px; z-index: 2; background: var(--panel);
66
+ color: var(--muted); border: 1px solid var(--line); border-radius: 6px; padding: 3px 10px;
67
+ font-size: 12px; cursor: pointer; }
68
+ #zoom-toggle:hover { border-color: var(--pending); color: var(--fg); }
69
+ /* The way out of waves view, floating top-LEFT so it sits where the eye
70
+ already is when observe drops you into the wave-laned graph. */
71
+ #back-to-infra { position: absolute; top: 8px; left: 8px; z-index: 2; background: var(--panel);
72
+ color: var(--muted); border: 1px solid var(--line); border-radius: 6px; padding: 3px 10px;
73
+ font-size: 12px; cursor: pointer; }
74
+ #back-to-infra:hover { border-color: var(--pending); color: var(--fg); }
75
+ /* Blocking load scrim: covers everything (clicks can't pass through) while
76
+ a live view change is in flight. */
77
+ #loading-overlay { position: fixed; inset: 0; z-index: 100; display: flex;
78
+ flex-direction: column; align-items: center; justify-content: center; gap: 14px;
79
+ background: color-mix(in srgb, var(--bg) 72%, transparent); backdrop-filter: blur(1.5px);
80
+ cursor: progress; }
81
+ #loading-overlay[hidden] { display: none; }
82
+ #loading-overlay #loading-msg { font-size: 13px; color: var(--muted); }
83
+ #loading-overlay .spinner { width: 34px; height: 34px; border-radius: 50%;
84
+ border: 3px solid var(--line); border-top-color: var(--pending);
85
+ animation: spin 0.7s linear infinite; }
86
+ @keyframes spin { to { transform: rotate(360deg); } }
87
+ .sel rect, .sel path, .sel > rect { stroke: var(--pending) !important; stroke-width: 2.5 !important; }
88
+ .err { padding: 24px; color: var(--foreign); }
89
+ /* #72: a classified precondition failure (chant's lint gate, a project
90
+ that isn't installed/typegen'd, a tier that needs credentials) reads
91
+ as a calm entry/error card — not the alarming red .err, since these
92
+ are usually expected states on a real (imperfect) project, not bugs.
93
+ Generalizes the old M2 (#54) tier-only `.tier-note`. */
94
+ .precondition-error { padding: 24px; max-width: 640px; }
95
+ .precondition-error-title { color: var(--fg); font-size: 15px; font-weight: 600; margin-bottom: 10px; }
96
+ .precondition-error-message { color: var(--muted); white-space: pre-wrap; font: 12px/1.5 ui-monospace, monospace; }
97
+ .precondition-error-remedy { color: var(--pending); margin-top: 14px; }
98
+ .precondition-error-remedy::before { content: "→ "; }
99
+ #actions button, #inspect button { background: var(--panel); color: var(--fg); border: 1px solid var(--line);
100
+ border-radius: 6px; padding: 4px 12px; font-size: 12px; cursor: pointer; }
101
+ #actions button:hover, #inspect button:hover { border-color: var(--pending); }
102
+ #actions button.approve { border-color: var(--managed); color: var(--managed); }
103
+ #inspect button { border-color: var(--foreign); color: var(--foreign); }
104
+ #pickers select, #zoom-slot select { background: var(--panel); color: var(--fg); border: 1px solid var(--line);
105
+ border-radius: 6px; padding: 4px 8px; font-size: 12px; cursor: pointer; }
106
+ #pickers select:hover, #zoom-slot select:hover { border-color: var(--pending); }
107
+ /* The observe → reconcile → apply dial (M2, #54): where the selected
108
+ target sits on the lifecycle progression. Its own row below the
109
+ header — a target-level control, not a per-node inspect detail. */
110
+ #dial { grid-column: 1 / 3; grid-row: 3; display: none; align-items: center; gap: 10px;
111
+ padding: 6px 16px; border-bottom: 1px solid var(--line); font-size: 12px; }
112
+ .dial-track { display: flex; align-items: center; gap: 8px; }
113
+ .dial-arrow { color: var(--muted); }
114
+ .dial-step { background: var(--panel); color: var(--fg); border: 1px solid var(--line);
115
+ border-radius: 999px; padding: 3px 12px; font-size: 12px; cursor: pointer; }
116
+ .dial-step:hover:not(:disabled) { border-color: var(--pending); }
117
+ .dial-step.active { border-color: var(--pending); color: var(--pending); }
118
+ .dial-step:disabled { opacity: .5; cursor: not-allowed; }
119
+ .dial-detail { display: flex; flex-wrap: wrap; gap: 4px 14px; color: var(--muted); }
120
+ /* Substrate readiness strip (M5, #54): is each substrate the project needs
121
+ actually up (Floci, k3d, GitLab CI, Forgejo)? A slim row of status pills
122
+ under the header — pure state (#73: the pills' own "Bring up"/"Reset"
123
+ buttons moved into the ⌘K palette, see paletteCommands() in app.js). */
124
+ #substrates { grid-column: 1 / 3; grid-row: 2; display: none; align-items: center; gap: 8px;
125
+ padding: 5px 16px; border-bottom: 1px solid var(--line); font-size: 12px; }
126
+ #substrates .label { color: var(--muted); margin-right: 2px; }
127
+ .sub { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--line);
128
+ border-radius: 999px; padding: 2px 10px; }
129
+ .sub .dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
130
+ .sub.up .dot { background: var(--managed); }
131
+ .sub.down .dot { background: var(--degraded); }
132
+ .sub.on-demand .dot { background: var(--pending); }
133
+ .sub.unknown .dot { background: var(--muted); }
134
+ .sub.blocked { opacity: .6; }
135
+ .sub.blocked .dot { background: var(--muted); }
136
+ #nowline { grid-column: 1 / 3; grid-row: 5; margin: 0; max-height: 160px; overflow: auto; display: none;
137
+ background: #010409; border-top: 1px solid var(--line); padding: 8px 12px; font: 11px/1.5 ui-monospace, monospace;
138
+ color: var(--fg); white-space: pre-wrap; }
139
+ /* Persistent state strip (#73 "hide controls, never state"): current zoom
140
+ + env + stack (#76) + tier stay visible in the header even though the
141
+ pickers that used to sit here moved into the ⌘K palette — see
142
+ renderStatusbar() in app.js. #hintk is the palette's always-visible
143
+ entry point. */
144
+ #statusbar { color: var(--muted); border: 1px solid var(--line); border-radius: 6px;
145
+ padding: 2px 10px; font-size: 11px; white-space: nowrap; }
146
+ /* #131: why the current zoom rendered what it did. Same muted strip,
147
+ italic so it reads as a caption on the state rather than more state. */
148
+ .statusbar-note { font-style: italic; opacity: 0.85; }
149
+ #hintk { color: var(--muted); border: 1px solid var(--line); border-radius: 6px;
150
+ padding: 2px 8px; font-size: 11px; cursor: pointer; white-space: nowrap; }
151
+ #hintk:hover { border-color: var(--pending); color: var(--fg); }
152
+ /* Command palette (⌘K / Ctrl+K, #73) — ported from spicypath's FG-036
153
+ pattern (../spicypath/src/index.html), theme vars mapped onto behold's
154
+ own: --panel/--line/--fg/--bg already exist; --pending stands in for
155
+ spicypath's --accent (behold already uses it as the one accent hue);
156
+ there's no static --on-accent since --pending varies per Ghostty theme —
157
+ --pal-sel-fg is computed via theme.js's readableOn() instead (see
158
+ applyPaletteContrast() in app.js) so the selected row stays legible
159
+ across all 552 themes, light or dark. */
160
+ #palette { position: fixed; inset: 0; z-index: 110; display: none; align-items: flex-start;
161
+ justify-content: center; background: rgba(0,0,0,.45); }
162
+ #palette.on { display: flex; }
163
+ #palette .box { margin-top: 12vh; width: min(560px, 92vw); background: var(--panel);
164
+ border: 1px solid var(--line); border-radius: 10px; box-shadow: 0 16px 50px rgba(0,0,0,.55);
165
+ overflow: hidden; }
166
+ #pal-input { width: 100%; box-sizing: border-box; background: var(--bg); color: var(--fg);
167
+ border: 0; border-bottom: 1px solid var(--line); padding: 11px 14px; font: inherit; outline: none; }
168
+ #pal-list { max-height: 50vh; overflow: auto; padding: 5px; }
169
+ #pal-list .row { padding: 7px 11px; border-radius: 6px; color: var(--fg); font-size: 13px;
170
+ cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
171
+ #pal-list .row.sel { background: var(--pending); color: var(--pal-sel-fg, var(--fg)); }
172
+ #pal-list .empty { padding: 8px 11px; color: var(--muted); font-size: 12px; }
173
+ </style>
174
+ </head>
175
+ <body>
176
+ <div id="loading-overlay" hidden>
177
+ <div class="spinner"></div>
178
+ <span id="loading-msg">loading…</span>
179
+ </div>
180
+ <div id="app">
181
+ <header>
182
+ <h1>behold</h1>
183
+ <!-- Zoom sits here, first after the brand, rather than in #pickers on
184
+ the far right with the theme select. It is the control you reach
185
+ for constantly — the one thing worth the chrome #73 otherwise
186
+ thinned — and in the right-hand cluster it read as chrome and got
187
+ missed. The ⌘K palette keeps its entry; this is the same action,
188
+ discoverable without knowing the shortcut exists. -->
189
+ <span id="zoom-slot" style="display:flex;align-self:center"></span>
190
+ <span class="meta" id="meta">loading…</span>
191
+ <span id="legend" style="display:none;gap:12px;font-size:11px;align-self:center">
192
+ <span style="color:var(--managed)">● managed</span>
193
+ <span style="color:var(--foreign)">● foreign</span>
194
+ <span style="color:var(--pending)">● pending</span>
195
+ <!-- chant#1168 (#1089): a declared node chant couldn't read live
196
+ state for — its own state, not folded into managed/foreign/
197
+ pending. Reuses --muted, same convention as the component
198
+ legend's "not deployed" chip below. -->
199
+ <span style="color:var(--muted)">● unobserved</span>
200
+ <!-- chant#1180 (#1077, #86): a live, undeclared node whose owner
201
+ chain reaches a declared entity — expected runtime (a Pod its
202
+ Deployment created), never foreign and never drift. Its own
203
+ colour (--runtime) so it's never mistaken for foreign/pending. -->
204
+ <span style="color:var(--runtime)">● runtime</span>
205
+ </span>
206
+ <!-- M1.1 (#57), palette hardened M2 (#54): the component DAG's live
207
+ per-component AWS status — a different join than the entity
208
+ overlay above (component name -> CFN stack, not chant's
209
+ cross-substrate --live --overlay), so it's a separate legend
210
+ rather than reusing #legend's vocabulary. 4 states now (chant
211
+ 0.18.29's machine-readable live/stack): pinhole has no literal
212
+ amber token, so "in progress" reuses its blue accent paint —
213
+ see src/component-status.ts's doc comment for the mapping. -->
214
+ <span id="component-legend" style="display:none;gap:12px;font-size:11px;align-self:center">
215
+ <span style="color:var(--managed)">● healthy</span>
216
+ <span style="color:var(--pending)">● in progress</span>
217
+ <span style="color:var(--degraded)">● rollback / failed</span>
218
+ <span style="color:var(--muted)">● not deployed</span>
219
+ </span>
220
+ <!-- #73 "hide controls, never state": zoom/env/stack/tier stay visible
221
+ here even though the pickers that used to render into this slot
222
+ moved into the ⌘K palette below — see renderStatusbar() in app.js.
223
+ stack (#76) only ever appears for a project that declares
224
+ chant.config.ts's stacks[]. -->
225
+ <span id="statusbar" style="margin-left:auto;align-self:center" title="current zoom · env · stack · tier — press ⌘K to change"></span>
226
+ <span id="pickers" style="display:flex;gap:8px;align-self:center"></span>
227
+ <span id="actions" style="display:flex;gap:8px;align-self:center"></span>
228
+ <a href="/lanes" style="color:var(--pending);text-decoration:none;font-size:12px;align-self:center">lanes →</a>
229
+ <span id="hintk" title="Command palette — deploy, apply, reset, bring up, lens/zoom, env/stack/tier, export… (⌘K / Ctrl+K)">⌘K</span>
230
+ </header>
231
+ <div id="substrates"></div>
232
+ <div id="dial"></div>
233
+ <div id="graph"></div>
234
+ <aside id="inspect">
235
+ <div id="inspect-resize" title="Drag to resize"></div>
236
+ <button id="inspect-collapse" title="Collapse panel">›</button>
237
+ <div id="inspect-body"><h2>inspect</h2><p style="color:var(--muted)">click a node</p></div>
238
+ </aside>
239
+ <button id="inspect-reopen" title="Show inspect panel">inspect ‹</button>
240
+ <pre id="nowline"></pre>
241
+ </div>
242
+ <!-- Command palette (⌘K / Ctrl+K, #73) — the spicypath FG-036 shape
243
+ (../spicypath/src/index.html): input + filtered list, driven entirely
244
+ by app.js's paletteCommands()/palRender()/openPalette()/closePalette(). -->
245
+ <div id="palette"><div class="box"><input id="pal-input" placeholder="type a command… (Esc to close)" autocomplete="off"><div id="pal-list"></div></div></div>
246
+ <script type="module" src="./app.js"></script>
247
+ </body>
248
+ </html>
package/web/theme.js ADDED
@@ -0,0 +1,188 @@
1
+ // Theme engine for behold — adopted from spicypath's FG-040 engine. Parses/holds Ghostty
2
+ // themes (vendored in ./themes.js from mbadolato/iTerm2-Color-Schemes), derives behold's
3
+ // semantic UI + drift tokens from the 16-color palette, applies them as CSS custom
4
+ // properties (so the inlined pinhole SVG and all chrome recolour live), and picks readable
5
+ // text on any coloured fill. Pure browser JS, no build step. See INTENTIUS/behold#62.
6
+ import { THEMES, DEFAULT_THEME } from "./themes.js";
7
+
8
+ const STORE_KEY = "behold.theme";
9
+
10
+ // --- low-level colour math (from spicypath) ------------------------------
11
+ const clamp = (x, lo, hi) => (x < lo ? lo : x > hi ? hi : x);
12
+ export function hexToRgb(hex) {
13
+ let h = String(hex).trim().replace(/^#/, "");
14
+ if (h.length === 3) h = h.split("").map((c) => c + c).join("");
15
+ const n = parseInt(h, 16);
16
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
17
+ }
18
+ export function rgbToHex(r, g, b) {
19
+ return "#" + [r, g, b].map((v) => clamp(Math.round(v), 0, 255).toString(16).padStart(2, "0")).join("");
20
+ }
21
+ export function mix(a, b, t) {
22
+ const A = hexToRgb(a), B = hexToRgb(b);
23
+ return rgbToHex(A[0] + (B[0] - A[0]) * t, A[1] + (B[1] - A[1]) * t, A[2] + (B[2] - A[2]) * t);
24
+ }
25
+ export function luminance(hex) { const [r, g, b] = hexToRgb(hex); return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; }
26
+ export const isDark = (hex) => luminance(hex) < 0.5;
27
+
28
+ // The headline contrast feature (#62): readable text on any coloured background —
29
+ // white on a dark fill, black on a light one, so labels never vanish across 552 themes.
30
+ export function readableOn(bg) { return isDark(bg) ? "#ffffff" : "#000000"; }
31
+
32
+ // --- OKLCH (perceptual hue rotation for category counts > palette slots) --
33
+ const s2l = (c) => ((c /= 255) <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
34
+ const l2s = (c) => { const v = c <= 0.0031308 ? c * 12.92 : 1.055 * c ** (1 / 2.4) - 0.055; return clamp(v, 0, 1) * 255; };
35
+ export function hexToOklch(hex) {
36
+ const [R, G, B] = hexToRgb(hex).map(s2l);
37
+ const l = Math.cbrt(0.4122214708 * R + 0.5363325363 * G + 0.0514459929 * B);
38
+ const m = Math.cbrt(0.2119034982 * R + 0.6806995451 * G + 0.1073969566 * B);
39
+ const s = Math.cbrt(0.0883024619 * R + 0.2817188376 * G + 0.6299787005 * B);
40
+ const L = 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s;
41
+ const a = 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s;
42
+ const bb = 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s;
43
+ return { L, C: Math.hypot(a, bb), H: (Math.atan2(bb, a) * 180 / Math.PI + 360) % 360 };
44
+ }
45
+ export function oklchToHex({ L, C, H }) {
46
+ const h = H * Math.PI / 180, a = C * Math.cos(h), bb = C * Math.sin(h);
47
+ const l = (L + 0.3963377774 * a + 0.2158037573 * bb) ** 3;
48
+ const m = (L - 0.1055613458 * a - 0.0638541728 * bb) ** 3;
49
+ const s = (L - 0.0894841775 * a - 1.2914855480 * bb) ** 3;
50
+ return rgbToHex(
51
+ l2s(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
52
+ l2s(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
53
+ l2s(-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s));
54
+ }
55
+
56
+ // --- behold's semantic tokens derived from a theme -----------------------
57
+ // Drift states are ANCHORED to palette slots so "coloured by drift" stays meaningful across
58
+ // every theme (green=managed … red=degraded); only the shades change. Chrome is derived from
59
+ // bg/fg mixes. `cat` are the chromatic slots that colorForCategory cycles for substrates.
60
+ export function tokensFor(th) {
61
+ const fgMix = (t) => mix(th.bg, th.fg, t);
62
+ return {
63
+ dark: th.dark,
64
+ bg: th.bg,
65
+ panel: fgMix(0.09),
66
+ line: fgMix(0.16),
67
+ fg: th.fg,
68
+ muted: fgMix(0.5),
69
+ edge: fgMix(0.32),
70
+ managed: th.palette[2], // green — good / managed
71
+ foreign: th.palette[3], // yellow — warn / foreign
72
+ pending: th.palette[4], // blue — accent / pending
73
+ degraded: th.palette[1], // red — degraded / failed
74
+ neutral: fgMix(0.45), // grey — not deployed / unknown
75
+ // chant#1180 (#1077, behold#86): a live, undeclared node whose owner
76
+ // chain reaches a declared entity — expected runtime (a Pod its
77
+ // Deployment created), never drift and never foreign. Cyan keeps it
78
+ // clearly apart from foreign (yellow) and degraded (red) — "informational,
79
+ // not a problem" — while still being its own colour, not a reuse of
80
+ // managed/pending (which would misreport it as chant-declared or as a
81
+ // pending change).
82
+ runtime: th.palette[6], // cyan — runtime child (expected, not drift)
83
+ cat: [1, 2, 3, 4, 5, 6, 9, 10, 11, 12, 13, 14].map((i) => th.palette[i]),
84
+ };
85
+ }
86
+
87
+ // CSS custom properties behold's chrome (:root) reads.
88
+ const CSS_VARS = ["bg", "panel", "line", "fg", "muted", "edge", "managed", "foreign", "pending", "degraded", "neutral", "runtime"];
89
+
90
+ // pinhole (the graph painter) reads its OWN vars, `--pin-<token>` (emitted as
91
+ // `var(--pin-token, <baked default>)`). Derive them from the active Ghostty theme so the graph
92
+ // recolours with everything else. Keep pinhole's status intent (good=green, warn=red,
93
+ // accent=blue). Node cards are a PANEL clearly distinct from the bg — else neutral nodes
94
+ // blend into the background and look transparent — tinted toward the status hue; node text
95
+ // (--pin-text = fg) stays readable on them.
96
+ export function pinTokensFor(th) {
97
+ const P = th.palette, fgMix = (t) => mix(th.bg, th.fg, t);
98
+ const good = P[2], warn = P[1], accent = P[4], runtime = P[6]; // P[6] = the cyan the CSS --runtime var rides
99
+ const panel = fgMix(0.18); // node card base — elevated well off the bg
100
+ const fill = (c) => mix(panel, c, 0.4); // panel pulled strongly toward the status hue
101
+ const stroke = (c) => mix(th.bg, c, 0.62);
102
+ return {
103
+ bg0: th.bg, bg1: fgMix(0.045), dots: fgMix(0.12),
104
+ text: th.fg, textMuted: fgMix(0.5), textFaint: fgMix(0.38), edge: fgMix(0.3),
105
+ neutralFill: panel, neutralStroke: fgMix(0.3), neutralBar: fgMix(0.5),
106
+ accentFill: fill(accent), accentStroke: stroke(accent), accentBar: accent,
107
+ goodFill: fill(good), goodStroke: stroke(good), goodBar: good,
108
+ warnFill: fill(warn), warnStroke: stroke(warn), warnBar: warn,
109
+ runtimeFill: fill(runtime), runtimeStroke: stroke(runtime), runtimeBar: runtime,
110
+ selectedStroke: accent,
111
+ };
112
+ }
113
+ const PIN_VARS = ["bg0", "bg1", "dots", "text", "textMuted", "textFaint", "edge", "neutralFill", "neutralStroke", "neutralBar", "accentFill", "accentStroke", "accentBar", "goodFill", "goodStroke", "goodBar", "warnFill", "warnStroke", "warnBar", "runtimeFill", "runtimeStroke", "runtimeBar", "selectedStroke"];
114
+
115
+ function hash(s) { let h = 2166136261; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return h >>> 0; }
116
+ // A substrate/lexicon (aws, k8s, gcp, …) gets one of the theme's categorical hues, perturbed
117
+ // in OKLCH deterministically so families stay distinct past the ~12 slot count (spicypath pattern).
118
+ // The active theme name is folded into the hash (#62): each theme redistributes categories across
119
+ // different palette slots, so switching themes visibly re-maps every node's colour rather than
120
+ // keeping a category anchored to one slot (which stayed e.g. teal in every terminal palette).
121
+ export function colorForCategory(key) {
122
+ const cat = _tokens.cat;
123
+ const h = hash((_active ? _active.name : "") + " " + String(key));
124
+ const { L, C, H } = hexToOklch(cat[h % cat.length]);
125
+ const dH = (((h >>> 4) % 31) - 15) * 1.6;
126
+ const dL = (((h >>> 9) % 5) - 2) * 0.035;
127
+ return oklchToHex({ L: clamp(L + dL, 0.2, 0.92), C, H: (H + dH + 360) % 360 });
128
+ }
129
+
130
+ // --- active theme state + application -------------------------------------
131
+ let _active = THEMES[DEFAULT_THEME] || Object.values(THEMES)[0] || null;
132
+ let _tokens = _active ? tokensFor(_active) : null;
133
+ const _subs = new Set();
134
+
135
+ export function listThemes() { return Object.keys(THEMES); }
136
+ export function getTheme() { return _active; }
137
+ export function getTokens() { return _tokens; }
138
+ export function getThemeName() { return _active && _active.name; }
139
+ export function onThemeChange(cb) { _subs.add(cb); return () => _subs.delete(cb); }
140
+
141
+ export function applyTheme(tokens = _tokens) {
142
+ if (!tokens || typeof document === "undefined") return;
143
+ const root = document.documentElement;
144
+ for (const k of CSS_VARS) root.style.setProperty("--" + k, tokens[k]);
145
+ if (_active) { const pin = pinTokensFor(_active); for (const k of PIN_VARS) root.style.setProperty("--pin-" + k, pin[k]); }
146
+ root.style.setProperty("color-scheme", tokens.dark ? "dark" : "light");
147
+ }
148
+
149
+ export function setTheme(nameOrObj, { persist = true } = {}) {
150
+ const th = typeof nameOrObj === "string" ? THEMES[nameOrObj] : nameOrObj;
151
+ if (!th) return false;
152
+ _active = th; _tokens = tokensFor(th);
153
+ applyTheme(_tokens);
154
+ if (persist) { try { localStorage.setItem(STORE_KEY, th.name); } catch { /* private mode */ } }
155
+ for (const cb of _subs) cb(th, _tokens);
156
+ return true;
157
+ }
158
+
159
+ // Restore the persisted theme (or default) and apply. Call once on boot.
160
+ export function initTheme() {
161
+ let saved = null;
162
+ try { saved = localStorage.getItem(STORE_KEY); } catch { /* private mode */ }
163
+ setTheme(saved && THEMES[saved] ? saved : (_active ? _active.name : DEFAULT_THEME), { persist: false });
164
+ return getThemeName();
165
+ }
166
+
167
+ // A minimal, searchable picker (native <select>; type-to-search over 552 themes). Grouped
168
+ // dark/light. Mount it into any container; it stays in sync with programmatic setTheme.
169
+ export function mountThemePicker(container) {
170
+ if (!container || typeof document === "undefined") return null;
171
+ const sel = document.createElement("select");
172
+ sel.setAttribute("aria-label", "Color theme");
173
+ sel.title = "Color theme";
174
+ sel.style.cssText = "background:var(--panel);color:var(--fg);border:1px solid var(--line);border-radius:6px;padding:3px 8px;font-size:12px;max-width:220px";
175
+ const groups = { Dark: document.createElement("optgroup"), Light: document.createElement("optgroup") };
176
+ groups.Dark.label = "Dark"; groups.Light.label = "Light";
177
+ for (const name of listThemes()) {
178
+ const o = document.createElement("option");
179
+ o.value = name; o.textContent = name;
180
+ (THEMES[name].dark ? groups.Dark : groups.Light).appendChild(o);
181
+ }
182
+ sel.appendChild(groups.Dark); sel.appendChild(groups.Light);
183
+ sel.value = getThemeName();
184
+ sel.addEventListener("change", () => setTheme(sel.value));
185
+ onThemeChange(() => { if (sel.value !== getThemeName()) sel.value = getThemeName(); });
186
+ container.appendChild(sel);
187
+ return sel;
188
+ }