@intentius/behold 0.2.3 → 0.4.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/web/index.html CHANGED
@@ -13,16 +13,15 @@
13
13
  * { box-sizing: border-box; }
14
14
  html, body { margin: 0; height: 100%; background: var(--bg); color: var(--fg);
15
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;
16
+ /* #186: no header row at all the graph runs full-bleed; every piece
17
+ of state the header used to carry (meta line, statusbar) lives in the
18
+ floating panel's footer instead. */
19
+ #app { display: grid; grid-template-columns: 1fr var(--inspect-w, 320px); grid-template-rows: 1fr auto;
17
20
  height: 100%; }
18
21
  #app.inspect-collapsed { grid-template-columns: 1fr 0; }
19
22
  #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);
23
+ #graph { grid-row: 1; grid-column: 1; overflow: auto; position: relative; }
24
+ #inspect { grid-row: 1; grid-column: 2; border-left: 1px solid var(--line); background: var(--panel);
26
25
  padding: 14px; overflow: auto; position: relative; }
27
26
  #inspect h2 { font-size: 13px; margin: 0 0 8px; color: var(--muted); font-weight: 600;
28
27
  text-transform: uppercase; letter-spacing: .5px; }
@@ -101,48 +100,112 @@
101
100
  #actions button:hover, #inspect button:hover { border-color: var(--pending); }
102
101
  #actions button.approve { border-color: var(--managed); color: var(--managed); }
103
102
  #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);
103
+
104
+ /* ---- Floating control panel ------------------------------------------
105
+ An Adobe-style palette holding the controls that used to line the
106
+ header and its strips: zoom, scope lenses, substrate pills, the model
107
+ legends, deploy buttons and the dial. panel.js owns the chrome — drag
108
+ by the tab strip, magnetic snap to viewport edges/corners, collapse to
109
+ the strip (chevron or double-click), tabs, persisted position — and
110
+ app.js renders the tab contents (renderPanel*, renderSubstrates,
111
+ renderDial, initActions: their host elements simply live inside the
112
+ tabs now). Every control here stays reachable in ⌘K too — the panel
113
+ is the discoverable surface, the palette the fast one. Sits above the
114
+ graph's own floating buttons (z 2) and below toasts (60) / the load
115
+ scrim (100) / ⌘K (110). */
116
+ #panel { position: fixed; width: 272px; z-index: 40; background: var(--panel);
117
+ border: 1px solid var(--line); border-radius: 10px; display: flex; flex-direction: column;
118
+ box-shadow: 0 10px 34px rgba(0,0,0,.45); user-select: none; }
119
+ #panel.dragging { opacity: .95; }
120
+ /* #184: tabs-only — the strip doubles as the drag handle and carries the
121
+ ⌘K pill + collapse chevron. Collapsing hides only the body, so the
122
+ panel collapses TO the strip. */
123
+ #panel-tabs { display: flex; align-items: stretch; border-bottom: 1px solid var(--line);
124
+ cursor: grab; padding-right: 4px; }
125
+ #panel.dragging #panel-tabs { cursor: grabbing; }
126
+ #panel.collapsed #panel-tabs { border-bottom: none; }
127
+ #panel.collapsed #panel-body { display: none; }
128
+ #panel-tabs button[data-tab] { flex: 1; background: none; border: none; border-bottom: 2px solid transparent;
129
+ color: var(--muted); font-size: 10.5px; padding: 7px 0; cursor: pointer; }
130
+ #panel-tabs button[data-tab]:hover { color: var(--fg); }
131
+ #panel-tabs button[data-tab].active { color: var(--fg); border-bottom-color: var(--pending); }
132
+ #panel-tabs #hintk { flex: none; align-self: center; font-size: 10px; padding: 1px 6px; margin-left: 2px; }
133
+ #panel-collapse { flex: none; align-self: center; background: none; border: none; color: var(--muted);
134
+ font-size: 12px; cursor: pointer; line-height: 1; padding: 2px 4px; }
135
+ #panel-collapse:hover { color: var(--fg); }
136
+ #panel-body { overflow-y: auto; max-height: min(62vh, 540px); padding: 10px 12px;
137
+ font-size: 12px; user-select: text; }
138
+ #panel-body section { display: none; }
139
+ #panel-body section.active { display: block; }
140
+ #panel h3 { margin: 14px 0 6px; font-size: 10px; text-transform: uppercase; letter-spacing: .5px;
141
+ color: var(--muted); font-weight: 600; }
142
+ #panel h3:first-child { margin-top: 0; }
143
+ #panel .panel-muted { color: var(--muted); font-size: 11px; margin: 4px 0; }
144
+ #panel .prow { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; padding: 2px 0; }
145
+ /* A radio-like option row: the current value carries the accent border. */
146
+ #panel .opt { display: block; width: 100%; text-align: left; background: none;
147
+ border: 1px solid transparent; border-radius: 6px; color: var(--fg); font-size: 12px;
148
+ padding: 4px 8px; cursor: pointer; }
149
+ #panel .opt:hover { border-color: var(--line); }
150
+ #panel .opt.active { border-color: var(--pending); color: var(--pending); }
151
+ #panel select { width: 100%; background: var(--bg); color: var(--fg); border: 1px solid var(--line);
105
152
  border-radius: 6px; padding: 4px 8px; font-size: 12px; cursor: pointer; }
106
- #pickers select:hover, #zoom-slot select:hover { border-color: var(--pending); }
153
+ #panel select:hover { border-color: var(--pending); }
154
+ #panel button.act { background: var(--bg); color: var(--fg); border: 1px solid var(--line);
155
+ border-radius: 6px; padding: 3px 10px; font-size: 11px; cursor: pointer; }
156
+ #panel button.act:hover { border-color: var(--pending); }
157
+ #panel input.panel-input { flex: 1; min-width: 0; background: var(--bg); color: var(--fg);
158
+ border: 1px solid var(--line); border-radius: 6px; padding: 3px 8px; font-size: 11px; }
159
+ #panel input.panel-input:focus { border-color: var(--pending); outline: none; }
160
+ /* Model tab rows: legend counts (static) and clickable node rows. */
161
+ #panel .count-row, #panel .node-row { display: flex; align-items: center; gap: 8px; padding: 2px 0; }
162
+ #panel .node-row { cursor: pointer; border-radius: 6px; padding: 3px 6px; }
163
+ #panel .node-row:hover { background: var(--bg); }
164
+ #panel .dot { width: 8px; height: 8px; border-radius: 50%; flex: none; display: inline-block; }
165
+ #panel .grow { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
166
+ #panel .tag { color: var(--muted); font-size: 11px; white-space: nowrap; }
167
+
168
+ /* Substrate readiness (M5, #54) — one row per substrate on the panel's
169
+ Substrates tab: status dot + name + state, with its actions (bring up /
170
+ reset / run pipeline) visible inline again. #73 moved those into ⌘K;
171
+ they remain there too — same actions, two surfaces. */
172
+ #substrates { display: flex; flex-direction: column; gap: 2px; }
173
+ .sub { display: flex; align-items: center; gap: 8px; padding: 3px 0; }
174
+ .sub .dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
175
+ .sub.up .dot { background: var(--managed); }
176
+ .sub.down .dot { background: var(--degraded); }
177
+ .sub.on-demand .dot { background: var(--pending); }
178
+ .sub.unknown .dot { background: var(--muted); }
179
+ .sub.blocked { opacity: .6; }
180
+ .sub.blocked .dot { background: var(--muted); }
181
+
107
182
  /* 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; }
183
+ target sits on the lifecycle progression. Lives on the panel's Deploy
184
+ tab now, stacked vertically under the deploy actions. */
185
+ #actions { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
186
+ #dial { display: none; flex-direction: column; align-items: stretch; gap: 8px;
187
+ font-size: 12px; margin-top: 10px; }
188
+ .dial-track { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
113
189
  .dial-arrow { color: var(--muted); }
114
190
  .dial-step { background: var(--panel); color: var(--fg); border: 1px solid var(--line);
115
191
  border-radius: 999px; padding: 3px 12px; font-size: 12px; cursor: pointer; }
116
192
  .dial-step:hover:not(:disabled) { border-color: var(--pending); }
117
193
  .dial-step.active { border-color: var(--pending); color: var(--pending); }
118
194
  .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;
195
+ .dial-detail { display: flex; flex-wrap: wrap; gap: 4px 12px; color: var(--muted); }
196
+
197
+ #nowline { grid-column: 1 / 3; grid-row: 2; margin: 0; max-height: 160px; overflow: auto; display: none;
137
198
  background: #010409; border-top: 1px solid var(--line); padding: 8px 12px; font: 11px/1.5 ui-monospace, monospace;
138
199
  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; }
200
+ /* #186: the panel's footer the state the removed header carried. The
201
+ statusbar (#73 "hide controls, never state": current zoom + env +
202
+ stack + tier, see renderStatusbar() in app.js) and the meta line.
203
+ Hidden when the panel collapses, like the body. */
204
+ #panel-foot { border-top: 1px solid var(--line); padding: 6px 12px;
205
+ display: flex; flex-direction: column; gap: 2px; }
206
+ #panel.collapsed #panel-foot { display: none; }
207
+ #panel-foot .meta { color: var(--muted); font-size: 10px; overflow-wrap: anywhere; }
208
+ #statusbar { color: var(--muted); font-size: 10.5px; cursor: pointer; }
146
209
  /* #131: why the current zoom rendered what it did. Same muted strip,
147
210
  italic so it reads as a caption on the state rather than more state. */
148
211
  .statusbar-note { font-style: italic; opacity: 0.85; }
@@ -178,58 +241,6 @@
178
241
  <span id="loading-msg">loading…</span>
179
242
  </div>
180
243
  <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
244
  <div id="graph"></div>
234
245
  <aside id="inspect">
235
246
  <div id="inspect-resize" title="Drag to resize"></div>
@@ -239,6 +250,47 @@
239
250
  <button id="inspect-reopen" title="Show inspect panel">inspect ‹</button>
240
251
  <pre id="nowline"></pre>
241
252
  </div>
253
+ <!-- The floating control panel (see the #panel CSS comment above). The
254
+ View tab keeps stable slots (#panel-zoom / #panel-viewtools /
255
+ #panel-theme) so the theme picker mounts once and survives re-renders;
256
+ Substrates and Deploy host the long-standing #substrates/#dial/#actions
257
+ elements, whose render functions in app.js are unchanged in spirit. -->
258
+ <div id="panel">
259
+ <!-- #184: tabs-only, no title bar — the tab strip IS the drag surface
260
+ (drag anywhere on it, a still click switches tabs), and it hosts
261
+ the ⌘K pill + collapse chevron. Collapse hides only the body: the
262
+ panel collapses TO the strip. -->
263
+ <div id="panel-tabs" title="Drag to move — snaps to edges and corners. Double-click to collapse.">
264
+ <button data-tab="view" class="active">View</button>
265
+ <button data-tab="scope">Scope</button>
266
+ <button data-tab="substrates">Substrates</button>
267
+ <button data-tab="model">Model</button>
268
+ <button data-tab="deploy">Deploy</button>
269
+ <span id="hintk" title="Command palette — deploy, apply, reset, bring up, lens/zoom, env/stack/tier, export… (⌘K / Ctrl+K)">⌘K</span>
270
+ <button id="panel-collapse" title="Collapse">▾</button>
271
+ </div>
272
+ <div id="panel-body">
273
+ <section data-tab="view" class="active">
274
+ <div id="panel-zoom"></div>
275
+ <div id="panel-viewtools"></div>
276
+ <h3>theme</h3>
277
+ <div id="panel-theme"></div>
278
+ </section>
279
+ <section data-tab="scope" id="tab-scope"></section>
280
+ <section data-tab="substrates"><div id="substrates"></div></section>
281
+ <section data-tab="model" id="tab-model"></section>
282
+ <section data-tab="deploy">
283
+ <div id="actions"></div>
284
+ <div id="dial"></div>
285
+ </section>
286
+ </div>
287
+ <!-- #186: the state the removed app header carried — the zoom/env strip
288
+ and the meta line. Clicking the strip opens ⌘K (renderStatusbar). -->
289
+ <div id="panel-foot">
290
+ <span id="statusbar" title="current zoom · env · stack · tier — change on the tabs above, or ⌘K"></span>
291
+ <span class="meta" id="meta">loading…</span>
292
+ </div>
293
+ </div>
242
294
  <!-- Command palette (⌘K / Ctrl+K, #73) — the spicypath FG-036 shape
243
295
  (../spicypath/src/index.html): input + filtered list, driven entirely
244
296
  by app.js's paletteCommands()/palRender()/openPalette()/closePalette(). -->
package/web/panel.js ADDED
@@ -0,0 +1,163 @@
1
+ // Floating control panel chrome — the Adobe-style palette that replaced the
2
+ // header strips. This module owns ONLY the chrome: drag by the tab strip,
3
+ // magnetic snap to viewport edges and corners, collapse to the bar (button or
4
+ // double-click), tab switching, and persistence. app.js renders what's inside
5
+ // the tabs (renderPanel* plus the #substrates/#dial/#actions hosts). Plain
6
+ // browser JS, no build step, themed entirely by the shared CSS vars — same
7
+ // rules as app.js.
8
+
9
+ const STORE_KEY = "behold.panel";
10
+ const MARGIN = 12; // gap kept between a snapped panel and the viewport edge
11
+ const SNAP = 32; // released within this many px of an edge → dock to it
12
+
13
+ let panel = null;
14
+ // snapX/snapY anchor an edge — a docked edge stays flush through window
15
+ // resizes and content-height changes; null means a free x/y position, clamped
16
+ // so the title bar can never leave the viewport.
17
+ let state = { x: 12, y: 110, snapX: "left", snapY: "top", collapsed: false, tab: "view" };
18
+
19
+ function loadState() {
20
+ try {
21
+ return { ...state, ...JSON.parse(localStorage.getItem(STORE_KEY) || "{}") };
22
+ } catch {
23
+ return { ...state };
24
+ }
25
+ }
26
+ function saveState() {
27
+ try {
28
+ localStorage.setItem(STORE_KEY, JSON.stringify(state));
29
+ } catch {
30
+ /* private mode */
31
+ }
32
+ }
33
+
34
+ // #186: no fixed chrome above the graph any more — top snap is the plain margin.
35
+ function topEdge() {
36
+ return MARGIN;
37
+ }
38
+
39
+ function applyPosition() {
40
+ if (!panel) return;
41
+ const w = panel.offsetWidth;
42
+ const h = panel.offsetHeight;
43
+ let x = state.snapX === "left" ? MARGIN : state.snapX === "right" ? window.innerWidth - w - MARGIN : state.x;
44
+ let y = state.snapY === "top" ? topEdge() : state.snapY === "bottom" ? window.innerHeight - h - MARGIN : state.y;
45
+ x = Math.max(60 - w, Math.min(x, window.innerWidth - 60));
46
+ y = Math.max(0, Math.min(y, window.innerHeight - 34));
47
+ panel.style.left = x + "px";
48
+ panel.style.top = y + "px";
49
+ }
50
+
51
+ // On release: an edge within SNAP px of a viewport edge docks to it — both at
52
+ // once is a corner. Anywhere else keeps the free position.
53
+ function settle() {
54
+ const r = panel.getBoundingClientRect();
55
+ state.snapX = r.left <= MARGIN + SNAP ? "left" : window.innerWidth - r.right <= MARGIN + SNAP ? "right" : null;
56
+ state.snapY = r.top <= topEdge() + SNAP ? "top" : window.innerHeight - r.bottom <= MARGIN + SNAP ? "bottom" : null;
57
+ state.x = r.left;
58
+ state.y = r.top;
59
+ saveState();
60
+ applyPosition();
61
+ }
62
+
63
+ // #184: the tab strip is the drag surface — a drag may START on a tab button
64
+ // (there is no other chrome to grab), so drag vs click is discriminated by
65
+ // movement: past 4px it's a drag, and the click that fires on release is
66
+ // swallowed in the capture phase so the tab doesn't also switch. The collapse
67
+ // chevron and the ⌘K pill opt out of dragging entirely — they're pure acts.
68
+ function initDrag(bar) {
69
+ let suppressClick = false;
70
+ bar.addEventListener(
71
+ "click",
72
+ (e) => {
73
+ if (!suppressClick) return;
74
+ suppressClick = false;
75
+ e.stopPropagation();
76
+ e.preventDefault();
77
+ },
78
+ true,
79
+ );
80
+ bar.addEventListener("pointerdown", (e) => {
81
+ if (e.button !== 0 || e.target.closest("#panel-collapse, #hintk")) return;
82
+ e.preventDefault();
83
+ const r = panel.getBoundingClientRect();
84
+ const startX = e.clientX;
85
+ const startY = e.clientY;
86
+ let moved = false;
87
+ const onMove = (ev) => {
88
+ const dx = ev.clientX - startX;
89
+ const dy = ev.clientY - startY;
90
+ if (!moved && Math.abs(dx) + Math.abs(dy) > 4) {
91
+ moved = true;
92
+ panel.classList.add("dragging");
93
+ }
94
+ if (!moved) return;
95
+ panel.style.left = r.left + dx + "px";
96
+ panel.style.top = r.top + dy + "px";
97
+ };
98
+ const onUp = () => {
99
+ panel.classList.remove("dragging");
100
+ window.removeEventListener("pointermove", onMove);
101
+ window.removeEventListener("pointerup", onUp);
102
+ if (moved) {
103
+ suppressClick = true; // the release's click must not switch tabs
104
+ settle();
105
+ }
106
+ };
107
+ window.addEventListener("pointermove", onMove);
108
+ window.addEventListener("pointerup", onUp);
109
+ });
110
+ }
111
+
112
+ export function setPanelTab(tab, { expand = true } = {}) {
113
+ if (!panel) return;
114
+ state.tab = tab;
115
+ for (const b of panel.querySelectorAll("#panel-tabs button[data-tab]")) b.classList.toggle("active", b.dataset.tab === tab);
116
+ for (const s of panel.querySelectorAll("#panel-body section")) s.classList.toggle("active", s.dataset.tab === tab);
117
+ if (expand && state.collapsed) {
118
+ setCollapsed(false); // saves state (incl. the new tab) + reapplies position
119
+ } else {
120
+ saveState();
121
+ applyPosition(); // tab heights differ — keep a bottom-snapped panel flush
122
+ }
123
+ }
124
+
125
+ function setCollapsed(on) {
126
+ state.collapsed = !!on;
127
+ panel.classList.toggle("collapsed", state.collapsed);
128
+ const btn = document.getElementById("panel-collapse");
129
+ if (btn) {
130
+ btn.textContent = state.collapsed ? "▸" : "▾";
131
+ btn.title = state.collapsed ? "Expand" : "Collapse";
132
+ }
133
+ saveState();
134
+ applyPosition();
135
+ }
136
+ export function togglePanelCollapsed() {
137
+ setCollapsed(!state.collapsed);
138
+ }
139
+ export function isPanelCollapsed() {
140
+ return !!state.collapsed;
141
+ }
142
+
143
+ export function initPanel() {
144
+ panel = document.getElementById("panel");
145
+ if (!panel) return;
146
+ state = loadState();
147
+ const bar = document.getElementById("panel-tabs");
148
+ initDrag(bar);
149
+ bar.addEventListener("dblclick", (e) => {
150
+ if (!e.target.closest("#panel-collapse, #hintk")) togglePanelCollapsed();
151
+ });
152
+ document.getElementById("panel-collapse").addEventListener("click", () => togglePanelCollapsed());
153
+ // Clicking a tab while collapsed expands (setPanelTab's default).
154
+ for (const b of panel.querySelectorAll("#panel-tabs button[data-tab]")) b.addEventListener("click", () => setPanelTab(b.dataset.tab));
155
+ const known = [...panel.querySelectorAll("#panel-tabs button[data-tab]")].map((b) => b.dataset.tab);
156
+ setCollapsed(state.collapsed);
157
+ setPanelTab(known.includes(state.tab) ? state.tab : known[0], { expand: false });
158
+ window.addEventListener("resize", applyPosition);
159
+ // Tab-content re-renders change the panel's height — a bottom-snapped panel
160
+ // must stay flush through them.
161
+ if (typeof ResizeObserver !== "undefined") new ResizeObserver(() => applyPosition()).observe(panel);
162
+ applyPosition();
163
+ }