@intentius/behold 0.2.3 → 0.3.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 +64 -0
- package/README.md +25 -1
- package/dist/cli.js +298 -88
- package/example-writes/README.md +88 -0
- package/example-writes/chant.config.ts +12 -0
- package/example-writes/ops/apply.op.ts +7 -0
- package/example-writes/ops/floci.op.ts +20 -0
- package/example-writes/ops/reconcile.op.ts +6 -0
- package/example-writes/package-lock.json +1249 -0
- package/example-writes/package.json +14 -0
- package/example-writes/src/bucket.ts +54 -0
- package/example-writes/tsconfig.json +1 -0
- package/package.json +4 -2
- package/web/app.js +468 -115
- package/web/index.html +143 -91
- package/web/panel.js +163 -0
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
|
-
#
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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.
|
|
109
|
-
|
|
110
|
-
#
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
/*
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
+
}
|