@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/README.md +338 -0
- package/bin/behold.js +15 -0
- package/dist/cli.js +4830 -0
- package/package.json +48 -0
- package/web/app.js +2352 -0
- package/web/index.html +248 -0
- package/web/theme.js +188 -0
- package/web/themes.js +559 -0
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
|
+
}
|