@hansenexus/hud 0.1.1 → 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/README.md +106 -25
- package/dist/define-hud-BHQR8Lbv.d.ts +31 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.js +1198 -0
- package/dist/next.d.ts +48 -0
- package/dist/next.js +77 -0
- package/dist/plugins/vitals.d.ts +25 -0
- package/dist/plugins/vitals.js +109 -0
- package/dist/plugins-1QmTmZyL.d.ts +76 -0
- package/dist/plugins-CzUiTn_r.js +68 -0
- package/dist/server.d.ts +39 -0
- package/dist/server.js +106 -0
- package/package.json +55 -133
- package/chunk-20zke7cs.js +0 -574
- package/chunk-24vrg16w.js +0 -39
- package/chunk-50pq4sg6.js +0 -41
- package/chunk-5gtx3pza.js +0 -9
- package/chunk-6cdpasm9.js +0 -79
- package/chunk-6jt8yg4m.js +0 -117
- package/chunk-6ngkpwz7.js +0 -127
- package/chunk-71se605m.js +0 -85
- package/chunk-82nm9wcg.js +0 -316
- package/chunk-9hw5r9y1.js +0 -67
- package/chunk-c5y856ad.js +0 -98
- package/chunk-edq4jqch.js +0 -89
- package/chunk-emfnvz4p.js +0 -166
- package/chunk-h0hmjdqa.js +0 -264
- package/chunk-hc2g0n35.js +0 -24
- package/chunk-hex3wqag.js +0 -179
- package/chunk-kvxve0hx.js +0 -140
- package/chunk-kyeb6qeq.js +0 -95
- package/chunk-mz55rvw3.js +0 -172
- package/chunk-nm82wr8r.js +0 -58
- package/chunk-q15xwpy8.js +0 -48
- package/chunk-q96mkjve.js +0 -68
- package/chunk-tczzx083.js +0 -26
- package/chunk-tf774c4w.js +0 -115
- package/chunk-v086j53b.js +0 -19
- package/chunk-vjh0d29g.js +0 -52
- package/chunk-xezxydm3.js +0 -47
- package/chunk-xztw0xyt.js +0 -113
- package/chunk-yn1d38pb.js +0 -172
- package/core/access.d.mts +0 -53
- package/core/access.js +0 -27
- package/core/api-types.d.mts +0 -146
- package/core/api-types.js +0 -9
- package/core/capabilities.d.mts +0 -27
- package/core/capabilities.js +0 -14
- package/core/capture-buffer.d.mts +0 -37
- package/core/capture-buffer.js +0 -9
- package/core/force-state.d.mts +0 -68
- package/core/force-state.js +0 -21
- package/core/mock-cookie.d.mts +0 -30
- package/core/mock-cookie.js +0 -15
- package/core/types.d.mts +0 -59
- package/core/types.js +0 -9
- package/hud-provider.d.mts +0 -38
- package/hud-provider.js +0 -15
- package/hud.d.mts +0 -15
- package/hud.js +0 -18
- package/index.d.mts +0 -94
- package/index.js +0 -170
- package/lazy-panel.d.mts +0 -28
- package/lazy-panel.js +0 -11
- package/panels/cicd-panel.d.mts +0 -9
- package/panels/cicd-panel.js +0 -13
- package/panels/debug-info-panel.d.mts +0 -21
- package/panels/debug-info-panel.js +0 -9
- package/panels/dispatch-panel.d.mts +0 -10
- package/panels/dispatch-panel.js +0 -14
- package/panels/env-info-panel.d.mts +0 -9
- package/panels/env-info-panel.js +0 -12
- package/panels/feature-flags-panel.d.mts +0 -26
- package/panels/feature-flags-panel.js +0 -9
- package/panels/force-state-panel.d.mts +0 -11
- package/panels/force-state-panel.js +0 -113
- package/panels/info-row.d.mts +0 -21
- package/panels/info-row.js +0 -14
- package/panels/mock-seed-panel.d.mts +0 -15
- package/panels/mock-seed-panel.js +0 -14
- package/panels/network-console-panel.d.mts +0 -6
- package/panels/network-console-panel.js +0 -9
- package/panels/role-switcher-panel.d.mts +0 -28
- package/panels/role-switcher-panel.js +0 -9
- package/panels/sessions-panel.d.mts +0 -6
- package/panels/sessions-panel.js +0 -13
- package/panels/tokens-panel.d.mts +0 -38
- package/panels/tokens-panel.js +0 -9
- package/panels/unlock-panel.d.mts +0 -33
- package/panels/unlock-panel.js +0 -14
- package/panels/variants-panel.d.mts +0 -51
- package/panels/variants-panel.js +0 -12
- package/panels/vitals-panel.d.mts +0 -11
- package/panels/vitals-panel.js +0 -15
- package/server/guard.d.mts +0 -42
- package/server/guard.js +0 -12
- package/server/unlock.d.mts +0 -28
- package/server/unlock.js +0 -108
- package/state-boundary.d.mts +0 -25
- package/state-boundary.js +0 -54
package/dist/index.js
ADDED
|
@@ -0,0 +1,1198 @@
|
|
|
1
|
+
import { a as resolvePlugins, i as definePlugin, n as HudApiError, r as createPluginContext } from "./plugins-CzUiTn_r.js";
|
|
2
|
+
import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
3
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
4
|
+
import { createPortal } from "react-dom";
|
|
5
|
+
//#region src/shell/commands/context.ts
|
|
6
|
+
const RegistryContext = createContext(null);
|
|
7
|
+
function useRegistry() {
|
|
8
|
+
const registry = useContext(RegistryContext);
|
|
9
|
+
if (!registry) throw new Error("HUD commands used outside <CommandProvider>");
|
|
10
|
+
return registry;
|
|
11
|
+
}
|
|
12
|
+
function useCommands() {
|
|
13
|
+
const registry = useRegistry();
|
|
14
|
+
return useSyncExternalStore(registry.subscribe, registry.list, registry.list);
|
|
15
|
+
}
|
|
16
|
+
/** Registers `commands` while the caller is mounted. Pass a stable (memoised) array. */
|
|
17
|
+
function useRegisterCommands(commands) {
|
|
18
|
+
const registry = useRegistry();
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
const unregister = commands.map((command) => registry.register(command));
|
|
21
|
+
return () => {
|
|
22
|
+
for (const off of unregister) off();
|
|
23
|
+
};
|
|
24
|
+
}, [registry, commands]);
|
|
25
|
+
}
|
|
26
|
+
//#endregion
|
|
27
|
+
//#region src/shell/commands/shortcut.ts
|
|
28
|
+
function parseShortcut(spec) {
|
|
29
|
+
const parts = spec.toLowerCase().split("+");
|
|
30
|
+
return {
|
|
31
|
+
key: parts.at(-1) ?? "",
|
|
32
|
+
mod: parts.includes("mod"),
|
|
33
|
+
shift: parts.includes("shift"),
|
|
34
|
+
alt: parts.includes("alt")
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function isApplePlatform() {
|
|
38
|
+
return typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent);
|
|
39
|
+
}
|
|
40
|
+
function matchesShortcut(event, shortcut, apple) {
|
|
41
|
+
if ((apple ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey) !== shortcut.mod) return false;
|
|
42
|
+
if (event.shiftKey !== shortcut.shift || event.altKey !== shortcut.alt) return false;
|
|
43
|
+
if (event.key.toLowerCase() === shortcut.key) return true;
|
|
44
|
+
if (/^[a-z]$/.test(shortcut.key)) return event.code === `Key${shortcut.key.toUpperCase()}`;
|
|
45
|
+
if (/^\d$/.test(shortcut.key)) return event.code === `Digit${shortcut.key}`;
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
function formatShortcut(shortcut, apple) {
|
|
49
|
+
const key = shortcut.key.toUpperCase();
|
|
50
|
+
if (apple) return `${shortcut.mod ? "⌘" : ""}${shortcut.alt ? "⌥" : ""}${shortcut.shift ? "⇧" : ""}${key}`;
|
|
51
|
+
return [
|
|
52
|
+
shortcut.mod && "Ctrl",
|
|
53
|
+
shortcut.alt && "Alt",
|
|
54
|
+
shortcut.shift && "Shift",
|
|
55
|
+
key
|
|
56
|
+
].filter(Boolean).join("+");
|
|
57
|
+
}
|
|
58
|
+
//#endregion
|
|
59
|
+
//#region src/shell/commands/registry.ts
|
|
60
|
+
/** Element grab arrives with the grabber slice (hud#7); the shortcut is reserved now. */
|
|
61
|
+
const GRAB_COMMAND = {
|
|
62
|
+
id: "hud.grab",
|
|
63
|
+
title: "Grab element",
|
|
64
|
+
shortcut: "mod+shift+g",
|
|
65
|
+
run: () => {}
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* Commands the shell and plugins contribute. Registering an id again
|
|
69
|
+
* replaces the earlier command; the first registered shortcut match wins.
|
|
70
|
+
*/
|
|
71
|
+
var CommandRegistry = class {
|
|
72
|
+
#commands = /* @__PURE__ */ new Map();
|
|
73
|
+
#snapshot = [];
|
|
74
|
+
#listeners = /* @__PURE__ */ new Set();
|
|
75
|
+
register(command) {
|
|
76
|
+
this.#commands.set(command.id, command);
|
|
77
|
+
this.#emit();
|
|
78
|
+
return () => {
|
|
79
|
+
if (this.#commands.get(command.id) !== command) return;
|
|
80
|
+
this.#commands.delete(command.id);
|
|
81
|
+
this.#emit();
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
list = () => this.#snapshot;
|
|
85
|
+
subscribe = (listener) => {
|
|
86
|
+
this.#listeners.add(listener);
|
|
87
|
+
return () => this.#listeners.delete(listener);
|
|
88
|
+
};
|
|
89
|
+
run(id) {
|
|
90
|
+
const command = this.#commands.get(id);
|
|
91
|
+
command?.run();
|
|
92
|
+
return command !== void 0;
|
|
93
|
+
}
|
|
94
|
+
/** Runs the command bound to this key press, if any, and reports whether one ran. */
|
|
95
|
+
handleKey(event, apple) {
|
|
96
|
+
for (const command of this.#commands.values()) if (command.shortcut && matchesShortcut(event, parseShortcut(command.shortcut), apple)) {
|
|
97
|
+
command.run();
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
#emit() {
|
|
103
|
+
this.#snapshot = [...this.#commands.values()];
|
|
104
|
+
for (const listener of this.#listeners) listener();
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
//#endregion
|
|
108
|
+
//#region src/shell/commands/command-provider.tsx
|
|
109
|
+
const BUILTIN = [GRAB_COMMAND];
|
|
110
|
+
/** Owns one HUD's command registry and binds its shortcuts on `window`. */
|
|
111
|
+
function CommandProvider({ commands, children }) {
|
|
112
|
+
const [registry] = useState(() => new CommandRegistry());
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
const unregister = [...BUILTIN, ...commands].map((command) => registry.register(command));
|
|
115
|
+
return () => {
|
|
116
|
+
for (const off of unregister) off();
|
|
117
|
+
};
|
|
118
|
+
}, [registry, commands]);
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
const apple = isApplePlatform();
|
|
121
|
+
const onKeyDown = (event) => {
|
|
122
|
+
if (event.repeat || event.defaultPrevented) return;
|
|
123
|
+
if (registry.handleKey(event, apple)) event.preventDefault();
|
|
124
|
+
};
|
|
125
|
+
window.addEventListener("keydown", onKeyDown, true);
|
|
126
|
+
return () => window.removeEventListener("keydown", onKeyDown, true);
|
|
127
|
+
}, [registry]);
|
|
128
|
+
return /* @__PURE__ */ jsx(RegistryContext.Provider, {
|
|
129
|
+
value: registry,
|
|
130
|
+
children
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
//#endregion
|
|
134
|
+
//#region src/shell/panels/layout.ts
|
|
135
|
+
const EMPTY_LAYOUT = {
|
|
136
|
+
open: [],
|
|
137
|
+
floats: {},
|
|
138
|
+
pins: {},
|
|
139
|
+
hidden: false
|
|
140
|
+
};
|
|
141
|
+
/** A pinned edge never takes more than this share of the viewport. */
|
|
142
|
+
const MAX_SHARE = .4;
|
|
143
|
+
function visiblePanels(layout) {
|
|
144
|
+
return layout.hidden ? [] : layout.open;
|
|
145
|
+
}
|
|
146
|
+
function openPanel(layout, id, fallback) {
|
|
147
|
+
return {
|
|
148
|
+
...layout,
|
|
149
|
+
open: [...layout.open.filter((open) => open !== id), id],
|
|
150
|
+
floats: id in layout.floats ? layout.floats : {
|
|
151
|
+
...layout.floats,
|
|
152
|
+
[id]: fallback
|
|
153
|
+
},
|
|
154
|
+
hidden: false
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
function closePanel(layout, id) {
|
|
158
|
+
if (!layout.open.includes(id)) return layout;
|
|
159
|
+
return {
|
|
160
|
+
...layout,
|
|
161
|
+
open: layout.open.filter((open) => open !== id)
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
function focusPanel(layout, id) {
|
|
165
|
+
if (layout.open.at(-1) === id || !layout.open.includes(id)) return layout;
|
|
166
|
+
return {
|
|
167
|
+
...layout,
|
|
168
|
+
open: [...layout.open.filter((open) => open !== id), id]
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
function movePanel(layout, id, pos) {
|
|
172
|
+
return {
|
|
173
|
+
...layout,
|
|
174
|
+
floats: {
|
|
175
|
+
...layout.floats,
|
|
176
|
+
[id]: pos
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
/** Pins `id` to `edge`, at the end of that edge's stack; `null` floats it again. */
|
|
181
|
+
function pinPanel(layout, id, edge) {
|
|
182
|
+
const pins = Object.fromEntries(Object.entries(layout.pins).filter(([pinned]) => pinned !== id));
|
|
183
|
+
if (edge) pins[id] = edge;
|
|
184
|
+
return {
|
|
185
|
+
...layout,
|
|
186
|
+
pins
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* ⌘⇧H: hide a shown layout, show a hidden one, and on an empty layout open
|
|
191
|
+
* `firstId` so the shortcut always shows something.
|
|
192
|
+
*/
|
|
193
|
+
function toggleLayout(layout, firstId, fallback) {
|
|
194
|
+
if (layout.hidden) return {
|
|
195
|
+
...layout,
|
|
196
|
+
hidden: false
|
|
197
|
+
};
|
|
198
|
+
if (layout.open.length > 0) return {
|
|
199
|
+
...layout,
|
|
200
|
+
hidden: true
|
|
201
|
+
};
|
|
202
|
+
return firstId ? openPanel(layout, firstId, fallback) : layout;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Where pinned panels sit. Side pins run the full height and split it; bottom
|
|
206
|
+
* pins sit between the side pins and split the remaining width.
|
|
207
|
+
*/
|
|
208
|
+
function pinGeometry(layout, viewport) {
|
|
209
|
+
const visible = new Set(visiblePanels(layout));
|
|
210
|
+
const stacks = {
|
|
211
|
+
left: [],
|
|
212
|
+
right: [],
|
|
213
|
+
bottom: []
|
|
214
|
+
};
|
|
215
|
+
for (const [id, edge] of Object.entries(layout.pins)) if (visible.has(id)) stacks[edge].push(id);
|
|
216
|
+
const side = Math.min(360, Math.round(viewport.w * MAX_SHARE));
|
|
217
|
+
const bottom = Math.min(280, Math.round(viewport.h * MAX_SHARE));
|
|
218
|
+
const reserve = {
|
|
219
|
+
left: stacks.left.length > 0 ? side : 0,
|
|
220
|
+
right: stacks.right.length > 0 ? side : 0,
|
|
221
|
+
bottom: stacks.bottom.length > 0 ? bottom : 0
|
|
222
|
+
};
|
|
223
|
+
const rects = {};
|
|
224
|
+
const split = (ids, length, place) => {
|
|
225
|
+
ids.forEach((id, i) => {
|
|
226
|
+
const start = Math.round(i * length / ids.length);
|
|
227
|
+
const end = Math.round((i + 1) * length / ids.length);
|
|
228
|
+
rects[id] = place(start, end - start);
|
|
229
|
+
});
|
|
230
|
+
};
|
|
231
|
+
split(stacks.left, viewport.h, (top, height) => ({
|
|
232
|
+
left: 0,
|
|
233
|
+
top,
|
|
234
|
+
width: side,
|
|
235
|
+
height
|
|
236
|
+
}));
|
|
237
|
+
split(stacks.right, viewport.h, (top, height) => ({
|
|
238
|
+
left: viewport.w - side,
|
|
239
|
+
top,
|
|
240
|
+
width: side,
|
|
241
|
+
height
|
|
242
|
+
}));
|
|
243
|
+
split(stacks.bottom, viewport.w - reserve.left - reserve.right, (start, width) => ({
|
|
244
|
+
left: reserve.left + start,
|
|
245
|
+
top: viewport.h - bottom,
|
|
246
|
+
width,
|
|
247
|
+
height: bottom
|
|
248
|
+
}));
|
|
249
|
+
return {
|
|
250
|
+
reserve,
|
|
251
|
+
rects
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
/** Keeps at least this much of the title bar on screen so a panel can always be dragged back. */
|
|
255
|
+
const GRIP = 40;
|
|
256
|
+
function clampPosition(pos, width, viewport) {
|
|
257
|
+
return {
|
|
258
|
+
x: Math.min(Math.max(pos.x, GRIP - width), viewport.w - GRIP),
|
|
259
|
+
y: Math.min(Math.max(pos.y, 0), viewport.h - GRIP)
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
function snapEdge(x, y, viewport) {
|
|
263
|
+
if (x <= 24) return "left";
|
|
264
|
+
if (x >= viewport.w - 24) return "right";
|
|
265
|
+
if (y >= viewport.h - 24) return "bottom";
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
//#endregion
|
|
269
|
+
//#region src/shell/panels/panel-frame.tsx
|
|
270
|
+
/** The narrowest a floating panel renders (`.panel { min-width }`). */
|
|
271
|
+
const MIN_WIDTH = 240;
|
|
272
|
+
const PIN_GLYPH = {
|
|
273
|
+
left: "◧",
|
|
274
|
+
right: "◨",
|
|
275
|
+
bottom: "⬓"
|
|
276
|
+
};
|
|
277
|
+
const PIN_ORDER = [
|
|
278
|
+
"left",
|
|
279
|
+
"bottom",
|
|
280
|
+
"right"
|
|
281
|
+
];
|
|
282
|
+
/**
|
|
283
|
+
* One panel window: floating and draggable, or pinned to an edge. Dropping a
|
|
284
|
+
* drag near the left, right or bottom edge pins it there.
|
|
285
|
+
*/
|
|
286
|
+
function PanelFrame({ title, z, viewport, position, pinned, onFocus, onClose, onMove, onPin, onSnapPreview, children }) {
|
|
287
|
+
const panelRef = useRef(null);
|
|
288
|
+
const drag = useRef(null);
|
|
289
|
+
const [dragPos, setDragPos] = useState(null);
|
|
290
|
+
const pos = dragPos ?? clampPosition(position, MIN_WIDTH, viewport);
|
|
291
|
+
const onPointerDown = (e) => {
|
|
292
|
+
if (pinned || e.target.closest("button")) return;
|
|
293
|
+
drag.current = {
|
|
294
|
+
dx: e.clientX - pos.x,
|
|
295
|
+
dy: e.clientY - pos.y,
|
|
296
|
+
pos,
|
|
297
|
+
edge: null
|
|
298
|
+
};
|
|
299
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
300
|
+
};
|
|
301
|
+
const onPointerMove = (e) => {
|
|
302
|
+
const d = drag.current;
|
|
303
|
+
if (!d) return;
|
|
304
|
+
const width = panelRef.current?.offsetWidth ?? MIN_WIDTH;
|
|
305
|
+
d.pos = clampPosition({
|
|
306
|
+
x: e.clientX - d.dx,
|
|
307
|
+
y: e.clientY - d.dy
|
|
308
|
+
}, width, viewport);
|
|
309
|
+
const edge = snapEdge(e.clientX, e.clientY, viewport);
|
|
310
|
+
if (edge !== d.edge) {
|
|
311
|
+
d.edge = edge;
|
|
312
|
+
onSnapPreview(edge);
|
|
313
|
+
}
|
|
314
|
+
setDragPos(d.pos);
|
|
315
|
+
};
|
|
316
|
+
const onPointerUp = (e) => {
|
|
317
|
+
const d = drag.current;
|
|
318
|
+
drag.current = null;
|
|
319
|
+
if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
|
|
320
|
+
if (!d) return;
|
|
321
|
+
setDragPos(null);
|
|
322
|
+
if (d.edge) {
|
|
323
|
+
onSnapPreview(null);
|
|
324
|
+
onPin(d.edge);
|
|
325
|
+
} else if (d.pos.x !== position.x || d.pos.y !== position.y) onMove(d.pos);
|
|
326
|
+
};
|
|
327
|
+
const style = pinned ? {
|
|
328
|
+
...pinned.rect,
|
|
329
|
+
zIndex: z
|
|
330
|
+
} : {
|
|
331
|
+
left: pos.x,
|
|
332
|
+
top: pos.y,
|
|
333
|
+
zIndex: z
|
|
334
|
+
};
|
|
335
|
+
return /* @__PURE__ */ jsxs("section", {
|
|
336
|
+
ref: panelRef,
|
|
337
|
+
className: "panel",
|
|
338
|
+
"aria-label": title,
|
|
339
|
+
"data-pin": pinned?.edge,
|
|
340
|
+
style,
|
|
341
|
+
onPointerDownCapture: onFocus,
|
|
342
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
343
|
+
className: "panel-bar",
|
|
344
|
+
onPointerDown,
|
|
345
|
+
onPointerMove,
|
|
346
|
+
onPointerUp,
|
|
347
|
+
onPointerCancel: onPointerUp,
|
|
348
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
349
|
+
className: "panel-title",
|
|
350
|
+
children: title
|
|
351
|
+
}), /* @__PURE__ */ jsxs("span", {
|
|
352
|
+
className: "panel-actions",
|
|
353
|
+
children: [pinned ? /* @__PURE__ */ jsx("button", {
|
|
354
|
+
type: "button",
|
|
355
|
+
className: "panel-action",
|
|
356
|
+
"aria-label": `Unpin ${title}`,
|
|
357
|
+
title: "Unpin",
|
|
358
|
+
onClick: () => onPin(null),
|
|
359
|
+
children: "▢"
|
|
360
|
+
}) : PIN_ORDER.map((edge) => /* @__PURE__ */ jsx("button", {
|
|
361
|
+
type: "button",
|
|
362
|
+
className: "panel-action",
|
|
363
|
+
"aria-label": `Pin ${title} to the ${edge} edge`,
|
|
364
|
+
title: `Pin ${edge}`,
|
|
365
|
+
onClick: () => onPin(edge),
|
|
366
|
+
children: PIN_GLYPH[edge]
|
|
367
|
+
}, edge)), /* @__PURE__ */ jsx("button", {
|
|
368
|
+
type: "button",
|
|
369
|
+
className: "panel-action",
|
|
370
|
+
"aria-label": `Close ${title}`,
|
|
371
|
+
onClick: onClose,
|
|
372
|
+
children: "×"
|
|
373
|
+
})]
|
|
374
|
+
})]
|
|
375
|
+
}), /* @__PURE__ */ jsx("div", {
|
|
376
|
+
className: "panel-body",
|
|
377
|
+
children
|
|
378
|
+
})]
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
//#endregion
|
|
382
|
+
//#region src/shell/panels/panel-sheet.tsx
|
|
383
|
+
/** Below 768 px every open panel lives in one full-width bottom sheet, one tab each. */
|
|
384
|
+
function PanelSheet({ panels, active, onSelect, onClose, children }) {
|
|
385
|
+
return /* @__PURE__ */ jsxs("section", {
|
|
386
|
+
className: "sheet",
|
|
387
|
+
"aria-label": active.title,
|
|
388
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
389
|
+
className: "sheet-bar",
|
|
390
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
391
|
+
className: "sheet-tabs",
|
|
392
|
+
role: "tablist",
|
|
393
|
+
"aria-label": "Open panels",
|
|
394
|
+
children: panels.map((panel) => /* @__PURE__ */ jsx("button", {
|
|
395
|
+
type: "button",
|
|
396
|
+
role: "tab",
|
|
397
|
+
className: "sheet-tab",
|
|
398
|
+
"aria-selected": panel === active,
|
|
399
|
+
onClick: () => onSelect(panel.id),
|
|
400
|
+
children: panel.title
|
|
401
|
+
}, panel.id))
|
|
402
|
+
}), /* @__PURE__ */ jsx("button", {
|
|
403
|
+
type: "button",
|
|
404
|
+
className: "panel-action",
|
|
405
|
+
"aria-label": `Close ${active.title}`,
|
|
406
|
+
onClick: () => onClose(active.id),
|
|
407
|
+
children: "×"
|
|
408
|
+
})]
|
|
409
|
+
}), /* @__PURE__ */ jsx("div", {
|
|
410
|
+
className: "panel-body",
|
|
411
|
+
role: "tabpanel",
|
|
412
|
+
children
|
|
413
|
+
})]
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
//#endregion
|
|
417
|
+
//#region src/shell/panels/panels-context.ts
|
|
418
|
+
const PanelsContext = createContext(null);
|
|
419
|
+
function usePanels() {
|
|
420
|
+
const api = useContext(PanelsContext);
|
|
421
|
+
if (!api) throw new Error("usePanels() used outside <PanelManager>");
|
|
422
|
+
return api;
|
|
423
|
+
}
|
|
424
|
+
//#endregion
|
|
425
|
+
//#region src/shell/panels/types.ts
|
|
426
|
+
const EDGES = [
|
|
427
|
+
"left",
|
|
428
|
+
"right",
|
|
429
|
+
"bottom"
|
|
430
|
+
];
|
|
431
|
+
//#endregion
|
|
432
|
+
//#region src/shell/panels/reserve.ts
|
|
433
|
+
const PADDING = {
|
|
434
|
+
left: "padding-left",
|
|
435
|
+
right: "padding-right",
|
|
436
|
+
bottom: "padding-bottom"
|
|
437
|
+
};
|
|
438
|
+
/**
|
|
439
|
+
* Pushes the page away from pinned panels by adding padding to <html>, so
|
|
440
|
+
* normal-flow content reflows instead of being covered. Inline `!important`,
|
|
441
|
+
* like the HUD host, so a client stylesheet cannot zero it. The host's own
|
|
442
|
+
* padding is kept and added to.
|
|
443
|
+
*
|
|
444
|
+
* Limit: nothing short of an iframe narrows the viewport, so the host's
|
|
445
|
+
* `position: fixed` elements and its media queries still see the full window.
|
|
446
|
+
*
|
|
447
|
+
* Returns a function that restores the previous inline values.
|
|
448
|
+
*/
|
|
449
|
+
function reservePageSpace(reserve, root = document.documentElement) {
|
|
450
|
+
const touched = EDGES.filter((edge) => reserve[edge] > 0).map((edge) => PADDING[edge]);
|
|
451
|
+
if (touched.length === 0) return () => {};
|
|
452
|
+
const computed = getComputedStyle(root);
|
|
453
|
+
const saved = touched.map((prop) => ({
|
|
454
|
+
prop,
|
|
455
|
+
value: root.style.getPropertyValue(prop),
|
|
456
|
+
priority: root.style.getPropertyPriority(prop)
|
|
457
|
+
}));
|
|
458
|
+
for (const edge of EDGES) {
|
|
459
|
+
if (reserve[edge] <= 0) continue;
|
|
460
|
+
const prop = PADDING[edge];
|
|
461
|
+
const base = Number.parseFloat(computed.getPropertyValue(prop)) || 0;
|
|
462
|
+
root.style.setProperty(prop, `${base + reserve[edge]}px`, "important");
|
|
463
|
+
}
|
|
464
|
+
return () => {
|
|
465
|
+
for (const { prop, value, priority } of saved) if (value) root.style.setProperty(prop, value, priority);
|
|
466
|
+
else root.style.removeProperty(prop);
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
//#endregion
|
|
470
|
+
//#region src/shell/panels/storage.ts
|
|
471
|
+
const VERSION = 1;
|
|
472
|
+
function layoutKey(appSlug) {
|
|
473
|
+
return `hansenexus-hud:layout:${appSlug}`;
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Reads the stored layout for `appSlug`, dropping panels that are no longer
|
|
477
|
+
* registered. Storage can be missing, blocked or full of someone else's JSON;
|
|
478
|
+
* every failure yields an empty layout.
|
|
479
|
+
*/
|
|
480
|
+
function loadLayout(appSlug, known) {
|
|
481
|
+
try {
|
|
482
|
+
const raw = window.localStorage.getItem(layoutKey(appSlug));
|
|
483
|
+
return raw ? sanitizeLayout(JSON.parse(raw), known) : EMPTY_LAYOUT;
|
|
484
|
+
} catch {
|
|
485
|
+
return EMPTY_LAYOUT;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
function saveLayout(appSlug, layout) {
|
|
489
|
+
try {
|
|
490
|
+
window.localStorage.setItem(layoutKey(appSlug), JSON.stringify({
|
|
491
|
+
v: VERSION,
|
|
492
|
+
...layout
|
|
493
|
+
}));
|
|
494
|
+
} catch {}
|
|
495
|
+
}
|
|
496
|
+
function isRecord(value) {
|
|
497
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
498
|
+
}
|
|
499
|
+
function isPosition(value) {
|
|
500
|
+
return isRecord(value) && Number.isFinite(value.x) && Number.isFinite(value.y);
|
|
501
|
+
}
|
|
502
|
+
function isEdge(value) {
|
|
503
|
+
return EDGES.some((edge) => edge === value);
|
|
504
|
+
}
|
|
505
|
+
function sanitizeLayout(value, known) {
|
|
506
|
+
if (!isRecord(value) || value.v !== VERSION) return EMPTY_LAYOUT;
|
|
507
|
+
const open = Array.isArray(value.open) ? [...new Set(value.open.filter((id) => typeof id === "string" && known.has(id)))] : [];
|
|
508
|
+
const floats = {};
|
|
509
|
+
if (isRecord(value.floats)) {
|
|
510
|
+
for (const [id, pos] of Object.entries(value.floats)) if (known.has(id) && isPosition(pos)) floats[id] = {
|
|
511
|
+
x: pos.x,
|
|
512
|
+
y: pos.y
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
const pins = {};
|
|
516
|
+
if (isRecord(value.pins)) {
|
|
517
|
+
for (const [id, edge] of Object.entries(value.pins)) if (known.has(id) && isEdge(edge)) pins[id] = edge;
|
|
518
|
+
}
|
|
519
|
+
return {
|
|
520
|
+
open,
|
|
521
|
+
floats,
|
|
522
|
+
pins,
|
|
523
|
+
hidden: value.hidden === true
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
function subscribeResize(listener) {
|
|
527
|
+
window.addEventListener("resize", listener);
|
|
528
|
+
return () => window.removeEventListener("resize", listener);
|
|
529
|
+
}
|
|
530
|
+
let cached = {
|
|
531
|
+
w: 0,
|
|
532
|
+
h: 0
|
|
533
|
+
};
|
|
534
|
+
/**
|
|
535
|
+
* The layout viewport without scrollbars: the box `position: fixed` resolves
|
|
536
|
+
* against. `clientWidth` of <html> is that box regardless of its padding.
|
|
537
|
+
*/
|
|
538
|
+
function readViewport() {
|
|
539
|
+
const root = document.documentElement;
|
|
540
|
+
const w = root.clientWidth || window.innerWidth;
|
|
541
|
+
const h = root.clientHeight || window.innerHeight;
|
|
542
|
+
if (w !== cached.w || h !== cached.h) cached = {
|
|
543
|
+
w,
|
|
544
|
+
h
|
|
545
|
+
};
|
|
546
|
+
return cached;
|
|
547
|
+
}
|
|
548
|
+
function useViewport() {
|
|
549
|
+
return useSyncExternalStore(subscribeResize, readViewport, readViewport);
|
|
550
|
+
}
|
|
551
|
+
/** Window width including the scrollbar, the same width a `max-width` media query tests. */
|
|
552
|
+
const readNarrow = () => window.innerWidth < 768;
|
|
553
|
+
function useNarrow() {
|
|
554
|
+
return useSyncExternalStore(subscribeResize, readNarrow, readNarrow);
|
|
555
|
+
}
|
|
556
|
+
//#endregion
|
|
557
|
+
//#region src/shell/panels/panel-manager.tsx
|
|
558
|
+
/** Where a panel opens the first time: stacked up and right from above the pill. */
|
|
559
|
+
function defaultPosition(index) {
|
|
560
|
+
const step = 28 * index;
|
|
561
|
+
return {
|
|
562
|
+
x: 12 + step,
|
|
563
|
+
y: Math.max(12, window.innerHeight - 300 - step)
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Window management for the HUD: open panels, z-order, drag, pin-to-edge with
|
|
568
|
+
* page push, per-app persistence, the mobile sheet, and the command registry
|
|
569
|
+
* (⌘⇧H toggles the layout).
|
|
570
|
+
*/
|
|
571
|
+
function PanelManager({ commands, ...props }) {
|
|
572
|
+
return /* @__PURE__ */ jsx(CommandProvider, {
|
|
573
|
+
commands,
|
|
574
|
+
children: /* @__PURE__ */ jsx(Workspace, { ...props })
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
function Workspace({ appSlug, panels, apiRef, children }) {
|
|
578
|
+
const byId = useMemo(() => new Map(panels.map((panel) => [panel.id, panel])), [panels]);
|
|
579
|
+
const [layout, setLayout] = useState(() => loadLayout(appSlug, new Set(byId.keys())));
|
|
580
|
+
const viewport = useViewport();
|
|
581
|
+
const narrow = useNarrow();
|
|
582
|
+
const [snap, setSnap] = useState(null);
|
|
583
|
+
useEffect(() => saveLayout(appSlug, layout), [appSlug, layout]);
|
|
584
|
+
const { reserve, rects } = useMemo(() => pinGeometry(layout, viewport), [layout, viewport]);
|
|
585
|
+
const { left, right, bottom } = narrow ? {
|
|
586
|
+
left: 0,
|
|
587
|
+
right: 0,
|
|
588
|
+
bottom: 0
|
|
589
|
+
} : reserve;
|
|
590
|
+
useLayoutEffect(() => reservePageSpace({
|
|
591
|
+
left,
|
|
592
|
+
right,
|
|
593
|
+
bottom
|
|
594
|
+
}), [
|
|
595
|
+
left,
|
|
596
|
+
right,
|
|
597
|
+
bottom
|
|
598
|
+
]);
|
|
599
|
+
const api = useMemo(() => ({
|
|
600
|
+
panels,
|
|
601
|
+
isOpen: (id) => !layout.hidden && layout.open.includes(id),
|
|
602
|
+
open: (id) => setLayout((l) => openPanel(l, id, defaultPosition(l.open.length))),
|
|
603
|
+
close: (id) => setLayout((l) => closePanel(l, id)),
|
|
604
|
+
toggle: (id) => setLayout((l) => !l.hidden && l.open.includes(id) ? closePanel(l, id) : openPanel(l, id, defaultPosition(l.open.length))),
|
|
605
|
+
pin: (id, edge) => setLayout((l) => pinPanel(l, id, edge))
|
|
606
|
+
}), [panels, layout]);
|
|
607
|
+
useLayoutEffect(() => {
|
|
608
|
+
if (!apiRef) return;
|
|
609
|
+
apiRef.current = api;
|
|
610
|
+
return () => {
|
|
611
|
+
apiRef.current = null;
|
|
612
|
+
};
|
|
613
|
+
}, [apiRef, api]);
|
|
614
|
+
useRegisterCommands(useMemo(() => [{
|
|
615
|
+
id: "hud.toggle-layout",
|
|
616
|
+
title: "Toggle layout",
|
|
617
|
+
shortcut: "mod+shift+h",
|
|
618
|
+
run: () => setLayout((l) => toggleLayout(l, panels[0]?.id, defaultPosition(l.open.length)))
|
|
619
|
+
}, {
|
|
620
|
+
id: "hud.reset-layout",
|
|
621
|
+
title: "Reset layout",
|
|
622
|
+
run: () => setLayout(EMPTY_LAYOUT)
|
|
623
|
+
}], [panels]));
|
|
624
|
+
const zOrder = visiblePanels(layout);
|
|
625
|
+
const visible = panels.filter((panel) => zOrder.includes(panel.id));
|
|
626
|
+
const active = byId.get(zOrder.at(-1) ?? "");
|
|
627
|
+
const controls = (id) => ({
|
|
628
|
+
id,
|
|
629
|
+
pinned: narrow ? null : layout.pins[id] ?? null,
|
|
630
|
+
narrow,
|
|
631
|
+
pin: (edge) => api.pin(id, edge),
|
|
632
|
+
close: () => api.close(id)
|
|
633
|
+
});
|
|
634
|
+
const snapRect = snap ? pinGeometry(pinPanel(layout, snap.id, snap.edge), viewport).rects[snap.id] : void 0;
|
|
635
|
+
const offsets = {
|
|
636
|
+
"--hud-reserve-left": `${left}px`,
|
|
637
|
+
"--hud-reserve-bottom": `${bottom}px`
|
|
638
|
+
};
|
|
639
|
+
return /* @__PURE__ */ jsx(PanelsContext.Provider, {
|
|
640
|
+
value: api,
|
|
641
|
+
children: /* @__PURE__ */ jsxs("div", {
|
|
642
|
+
className: "hud-layout",
|
|
643
|
+
style: offsets,
|
|
644
|
+
children: [
|
|
645
|
+
children,
|
|
646
|
+
narrow ? active && /* @__PURE__ */ jsx(PanelSheet, {
|
|
647
|
+
panels: visible,
|
|
648
|
+
active,
|
|
649
|
+
onSelect: (id) => setLayout((l) => focusPanel(l, id)),
|
|
650
|
+
onClose: api.close,
|
|
651
|
+
children: active.render(controls(active.id))
|
|
652
|
+
}) : visible.map((panel) => {
|
|
653
|
+
const edge = layout.pins[panel.id];
|
|
654
|
+
const rect = rects[panel.id];
|
|
655
|
+
return /* @__PURE__ */ jsx(PanelFrame, {
|
|
656
|
+
title: panel.title,
|
|
657
|
+
z: zOrder.indexOf(panel.id) + 1,
|
|
658
|
+
viewport,
|
|
659
|
+
position: layout.floats[panel.id] ?? defaultPosition(0),
|
|
660
|
+
pinned: edge && rect ? {
|
|
661
|
+
edge,
|
|
662
|
+
rect
|
|
663
|
+
} : null,
|
|
664
|
+
onFocus: () => setLayout((l) => focusPanel(l, panel.id)),
|
|
665
|
+
onClose: () => api.close(panel.id),
|
|
666
|
+
onMove: (pos) => setLayout((l) => movePanel(l, panel.id, pos)),
|
|
667
|
+
onPin: (next) => api.pin(panel.id, next),
|
|
668
|
+
onSnapPreview: (next) => setSnap(next ? {
|
|
669
|
+
id: panel.id,
|
|
670
|
+
edge: next
|
|
671
|
+
} : null),
|
|
672
|
+
children: panel.render(controls(panel.id))
|
|
673
|
+
}, panel.id);
|
|
674
|
+
}),
|
|
675
|
+
snapRect && /* @__PURE__ */ jsx("div", {
|
|
676
|
+
className: "snap-zone",
|
|
677
|
+
style: snapRect
|
|
678
|
+
})
|
|
679
|
+
]
|
|
680
|
+
})
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
//#endregion
|
|
684
|
+
//#region src/shell/panels/pill-menu.tsx
|
|
685
|
+
/** The pill's "⋯" menu: every panel, every command with its shortcut, and Reset layout. */
|
|
686
|
+
function PillMenu() {
|
|
687
|
+
const panels = usePanels();
|
|
688
|
+
const commands = useCommands();
|
|
689
|
+
const apple = useMemo(isApplePlatform, []);
|
|
690
|
+
const [open, setOpen] = useState(false);
|
|
691
|
+
const ref = useRef(null);
|
|
692
|
+
useEffect(() => {
|
|
693
|
+
if (!open) return;
|
|
694
|
+
const onPointerDown = (e) => {
|
|
695
|
+
if (ref.current && !e.composedPath().includes(ref.current)) setOpen(false);
|
|
696
|
+
};
|
|
697
|
+
const onKeyDown = (e) => {
|
|
698
|
+
if (e.key === "Escape") setOpen(false);
|
|
699
|
+
};
|
|
700
|
+
window.addEventListener("pointerdown", onPointerDown, true);
|
|
701
|
+
window.addEventListener("keydown", onKeyDown, true);
|
|
702
|
+
return () => {
|
|
703
|
+
window.removeEventListener("pointerdown", onPointerDown, true);
|
|
704
|
+
window.removeEventListener("keydown", onKeyDown, true);
|
|
705
|
+
};
|
|
706
|
+
}, [open]);
|
|
707
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
708
|
+
className: "menu-anchor",
|
|
709
|
+
ref,
|
|
710
|
+
children: [/* @__PURE__ */ jsx("button", {
|
|
711
|
+
type: "button",
|
|
712
|
+
className: "pill-item",
|
|
713
|
+
"aria-label": "HUD menu",
|
|
714
|
+
"aria-haspopup": "menu",
|
|
715
|
+
"aria-expanded": open,
|
|
716
|
+
onClick: () => setOpen((o) => !o),
|
|
717
|
+
children: "⋯"
|
|
718
|
+
}), open && /* @__PURE__ */ jsxs("div", {
|
|
719
|
+
className: "menu",
|
|
720
|
+
role: "menu",
|
|
721
|
+
"aria-label": "HUD menu",
|
|
722
|
+
children: [
|
|
723
|
+
/* @__PURE__ */ jsx("div", {
|
|
724
|
+
className: "menu-label",
|
|
725
|
+
children: "Panels"
|
|
726
|
+
}),
|
|
727
|
+
panels.panels.map((panel) => /* @__PURE__ */ jsxs("button", {
|
|
728
|
+
type: "button",
|
|
729
|
+
role: "menuitemcheckbox",
|
|
730
|
+
className: "menu-item",
|
|
731
|
+
"aria-checked": panels.isOpen(panel.id),
|
|
732
|
+
onClick: () => panels.toggle(panel.id),
|
|
733
|
+
children: [/* @__PURE__ */ jsx("span", { children: panel.title }), /* @__PURE__ */ jsx("span", {
|
|
734
|
+
className: "menu-check",
|
|
735
|
+
children: panels.isOpen(panel.id) ? "✓" : ""
|
|
736
|
+
})]
|
|
737
|
+
}, panel.id)),
|
|
738
|
+
/* @__PURE__ */ jsx("div", {
|
|
739
|
+
className: "menu-label",
|
|
740
|
+
children: "Commands"
|
|
741
|
+
}),
|
|
742
|
+
commands.map((command) => /* @__PURE__ */ jsxs("button", {
|
|
743
|
+
type: "button",
|
|
744
|
+
role: "menuitem",
|
|
745
|
+
className: "menu-item",
|
|
746
|
+
onClick: () => {
|
|
747
|
+
setOpen(false);
|
|
748
|
+
command.run();
|
|
749
|
+
},
|
|
750
|
+
children: [/* @__PURE__ */ jsx("span", { children: command.title }), command.shortcut && /* @__PURE__ */ jsx("kbd", { children: formatShortcut(parseShortcut(command.shortcut), apple) })]
|
|
751
|
+
}, command.id))
|
|
752
|
+
]
|
|
753
|
+
})]
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
//#endregion
|
|
757
|
+
//#region src/shell/panels/styles.ts
|
|
758
|
+
/**
|
|
759
|
+
* Panel manager styles, appended to `HUD_CSS` inside the same shadow root and
|
|
760
|
+
* built on its `--hud-*` tokens.
|
|
761
|
+
*/
|
|
762
|
+
const PANEL_CSS = `
|
|
763
|
+
.hud-layout { display: contents; }
|
|
764
|
+
|
|
765
|
+
/* Pinned panels push the page; the pill moves with it so it stays visible. */
|
|
766
|
+
.pill {
|
|
767
|
+
left: calc(12px + var(--hud-reserve-left, 0px));
|
|
768
|
+
bottom: calc(12px + var(--hud-reserve-bottom, 0px));
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
.menu-anchor { position: relative; display: flex; }
|
|
772
|
+
|
|
773
|
+
.menu {
|
|
774
|
+
position: absolute;
|
|
775
|
+
left: 0;
|
|
776
|
+
bottom: calc(100% + 8px);
|
|
777
|
+
display: flex;
|
|
778
|
+
flex-direction: column;
|
|
779
|
+
min-width: 220px;
|
|
780
|
+
padding: 4px;
|
|
781
|
+
background: var(--hud-bg);
|
|
782
|
+
border: 1px solid var(--hud-border);
|
|
783
|
+
border-radius: var(--hud-radius);
|
|
784
|
+
box-shadow: var(--hud-shadow);
|
|
785
|
+
font-family: var(--hud-font);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
.menu-label {
|
|
789
|
+
padding: 6px 8px 2px;
|
|
790
|
+
font-size: 10px;
|
|
791
|
+
font-weight: 600;
|
|
792
|
+
letter-spacing: 0.06em;
|
|
793
|
+
text-transform: uppercase;
|
|
794
|
+
color: var(--hud-fg-muted);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
.hud .menu-item {
|
|
798
|
+
display: flex;
|
|
799
|
+
align-items: center;
|
|
800
|
+
justify-content: space-between;
|
|
801
|
+
gap: 16px;
|
|
802
|
+
padding: 4px 8px;
|
|
803
|
+
border-radius: 4px;
|
|
804
|
+
text-align: left;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
.hud .menu-item:hover { background: var(--hud-bg-subtle); }
|
|
808
|
+
.menu-check { color: var(--hud-accent); }
|
|
809
|
+
.menu kbd { font-family: var(--hud-mono); font-size: 11px; color: var(--hud-fg-muted); }
|
|
810
|
+
|
|
811
|
+
.panel {
|
|
812
|
+
position: fixed;
|
|
813
|
+
display: flex;
|
|
814
|
+
flex-direction: column;
|
|
815
|
+
min-width: 240px;
|
|
816
|
+
max-width: min(420px, calc(100vw - 24px));
|
|
817
|
+
max-height: calc(100vh - 24px);
|
|
818
|
+
background: var(--hud-bg);
|
|
819
|
+
border: 1px solid var(--hud-border);
|
|
820
|
+
border-radius: var(--hud-radius);
|
|
821
|
+
box-shadow: var(--hud-shadow);
|
|
822
|
+
pointer-events: auto;
|
|
823
|
+
overflow: hidden;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
.panel[data-pin] {
|
|
827
|
+
min-width: 0;
|
|
828
|
+
max-width: none;
|
|
829
|
+
max-height: none;
|
|
830
|
+
border-radius: 0;
|
|
831
|
+
box-shadow: none;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
.panel-bar {
|
|
835
|
+
display: flex;
|
|
836
|
+
align-items: center;
|
|
837
|
+
justify-content: space-between;
|
|
838
|
+
gap: 8px;
|
|
839
|
+
padding: 6px 6px 6px 10px;
|
|
840
|
+
border-bottom: 1px solid var(--hud-border);
|
|
841
|
+
background: var(--hud-bg-subtle);
|
|
842
|
+
cursor: grab;
|
|
843
|
+
user-select: none;
|
|
844
|
+
touch-action: none;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
.panel-bar:active { cursor: grabbing; }
|
|
848
|
+
.panel[data-pin] .panel-bar { cursor: default; }
|
|
849
|
+
.panel-title { font-weight: 600; }
|
|
850
|
+
.panel-actions { display: flex; gap: 2px; }
|
|
851
|
+
.hud .panel-action {
|
|
852
|
+
width: 20px;
|
|
853
|
+
height: 20px;
|
|
854
|
+
border-radius: 4px;
|
|
855
|
+
color: var(--hud-fg-muted);
|
|
856
|
+
line-height: 20px;
|
|
857
|
+
text-align: center;
|
|
858
|
+
}
|
|
859
|
+
.hud .panel-action:hover { background: var(--hud-border); color: var(--hud-fg); }
|
|
860
|
+
.panel-body { flex: 1; padding: 8px 10px; overflow: auto; }
|
|
861
|
+
|
|
862
|
+
.snap-zone {
|
|
863
|
+
position: fixed;
|
|
864
|
+
z-index: 1000;
|
|
865
|
+
background: color-mix(in srgb, var(--hud-accent) 18%, transparent);
|
|
866
|
+
border: 2px dashed var(--hud-accent);
|
|
867
|
+
pointer-events: none;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
.sheet {
|
|
871
|
+
position: fixed;
|
|
872
|
+
left: 0;
|
|
873
|
+
right: 0;
|
|
874
|
+
bottom: 0;
|
|
875
|
+
z-index: 1000;
|
|
876
|
+
display: flex;
|
|
877
|
+
flex-direction: column;
|
|
878
|
+
max-height: 60vh;
|
|
879
|
+
background: var(--hud-bg);
|
|
880
|
+
border-top: 1px solid var(--hud-border);
|
|
881
|
+
border-radius: var(--hud-radius) var(--hud-radius) 0 0;
|
|
882
|
+
box-shadow: var(--hud-shadow);
|
|
883
|
+
pointer-events: auto;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
.sheet-bar {
|
|
887
|
+
display: flex;
|
|
888
|
+
align-items: center;
|
|
889
|
+
gap: 8px;
|
|
890
|
+
padding: 4px 6px;
|
|
891
|
+
border-bottom: 1px solid var(--hud-border);
|
|
892
|
+
background: var(--hud-bg-subtle);
|
|
893
|
+
border-radius: var(--hud-radius) var(--hud-radius) 0 0;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
.sheet-tabs { display: flex; flex: 1; gap: 2px; overflow-x: auto; }
|
|
897
|
+
.hud .sheet-tab { padding: 4px 10px; border-radius: 4px; white-space: nowrap; color: var(--hud-fg-muted); }
|
|
898
|
+
.hud .sheet-tab[aria-selected="true"] { background: var(--hud-bg); color: var(--hud-fg); font-weight: 600; }
|
|
899
|
+
`;
|
|
900
|
+
//#endregion
|
|
901
|
+
//#region src/shell/plugin-slots.tsx
|
|
902
|
+
/** One stable context per plugin, so panels and pills can depend on `ctx` in effects. */
|
|
903
|
+
function usePluginSlots(plugins, { app, apiBase, can, close, pin }) {
|
|
904
|
+
return useMemo(() => plugins.map((plugin) => ({
|
|
905
|
+
plugin,
|
|
906
|
+
title: plugin.title ?? plugin.id,
|
|
907
|
+
ctx: createPluginContext(plugin, {
|
|
908
|
+
app,
|
|
909
|
+
apiBase,
|
|
910
|
+
can,
|
|
911
|
+
close: () => close(plugin.id),
|
|
912
|
+
pin: (edge) => pin(plugin.id, edge)
|
|
913
|
+
})
|
|
914
|
+
})), [
|
|
915
|
+
plugins,
|
|
916
|
+
app,
|
|
917
|
+
apiBase,
|
|
918
|
+
can,
|
|
919
|
+
close,
|
|
920
|
+
pin
|
|
921
|
+
]);
|
|
922
|
+
}
|
|
923
|
+
/**
|
|
924
|
+
* A plugin's pill item. With a panel it is a toggle button; without one it
|
|
925
|
+
* only shows the plugin's `pill` content.
|
|
926
|
+
*/
|
|
927
|
+
function PluginPillItem({ slot, open, onToggle }) {
|
|
928
|
+
const { plugin, ctx, title } = slot;
|
|
929
|
+
const Pill = plugin.pill;
|
|
930
|
+
const content = Pill ? /* @__PURE__ */ jsx(Pill, { ctx }) : title;
|
|
931
|
+
if (!plugin.panel) return Pill ? /* @__PURE__ */ jsx("span", {
|
|
932
|
+
className: "pill-item",
|
|
933
|
+
children: content
|
|
934
|
+
}) : null;
|
|
935
|
+
return /* @__PURE__ */ jsx("button", {
|
|
936
|
+
type: "button",
|
|
937
|
+
className: "pill-item",
|
|
938
|
+
"aria-pressed": open,
|
|
939
|
+
title,
|
|
940
|
+
onClick: onToggle,
|
|
941
|
+
children: content
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
function PluginPanelBody({ slot }) {
|
|
945
|
+
const Panel = slot.plugin.panel;
|
|
946
|
+
return Panel ? /* @__PURE__ */ jsx(Panel, { ctx: slot.ctx }) : null;
|
|
947
|
+
}
|
|
948
|
+
//#endregion
|
|
949
|
+
//#region src/core/marker.ts
|
|
950
|
+
/**
|
|
951
|
+
* Literal a consumer's CI bundle gate greps for in production client chunks.
|
|
952
|
+
* It is rendered as an attribute on the HUD root, so a bundler cannot drop it
|
|
953
|
+
* while keeping the HUD, and it is unmistakably deliberate rather than an
|
|
954
|
+
* incidental string a refactor could rename away.
|
|
955
|
+
*/
|
|
956
|
+
const HUD_DEV_MARKER = "__HANSENEXUS_HUD_DEV__";
|
|
957
|
+
/** Tag name of the element that hosts the HUD's shadow root. */
|
|
958
|
+
const HUD_HOST_TAG = "hn-hud";
|
|
959
|
+
//#endregion
|
|
960
|
+
//#region src/shell/shadow-host.tsx
|
|
961
|
+
/**
|
|
962
|
+
* Inline `!important` on the host element: author `!important` in a host
|
|
963
|
+
* stylesheet (`* { position: static !important }`) loses to inline
|
|
964
|
+
* `!important`, so this is the one set of rules a client site cannot override.
|
|
965
|
+
* The host covers the viewport but lets every click through; HUD surfaces opt
|
|
966
|
+
* back in with `pointer-events: auto`.
|
|
967
|
+
*/
|
|
968
|
+
const HOST_STYLE = {
|
|
969
|
+
all: "initial",
|
|
970
|
+
position: "fixed",
|
|
971
|
+
inset: "0",
|
|
972
|
+
"z-index": "2147483000",
|
|
973
|
+
"pointer-events": "none",
|
|
974
|
+
display: "block"
|
|
975
|
+
};
|
|
976
|
+
/**
|
|
977
|
+
* Renders children into a shadow root on a host element appended to <body>.
|
|
978
|
+
* Mounted in an effect, so server rendering emits nothing and hydration never
|
|
979
|
+
* differs.
|
|
980
|
+
*/
|
|
981
|
+
function ShadowHost({ css, children }) {
|
|
982
|
+
const [mount, setMount] = useState(null);
|
|
983
|
+
useLayoutEffect(() => {
|
|
984
|
+
const host = document.createElement(HUD_HOST_TAG);
|
|
985
|
+
for (const [prop, value] of Object.entries(HOST_STYLE)) host.style.setProperty(prop, value, "important");
|
|
986
|
+
const shadow = host.attachShadow({ mode: "open" });
|
|
987
|
+
const style = document.createElement("style");
|
|
988
|
+
style.textContent = css;
|
|
989
|
+
const root = document.createElement("div");
|
|
990
|
+
root.className = "hud";
|
|
991
|
+
root.setAttribute("data-hud-marker", HUD_DEV_MARKER);
|
|
992
|
+
shadow.append(style, root);
|
|
993
|
+
document.body.appendChild(host);
|
|
994
|
+
setMount(root);
|
|
995
|
+
return () => {
|
|
996
|
+
host.remove();
|
|
997
|
+
setMount(null);
|
|
998
|
+
};
|
|
999
|
+
}, [css]);
|
|
1000
|
+
return mount ? createPortal(children, mount) : null;
|
|
1001
|
+
}
|
|
1002
|
+
//#endregion
|
|
1003
|
+
//#region src/shell/hud.tsx
|
|
1004
|
+
const CSS = `
|
|
1005
|
+
.hud {
|
|
1006
|
+
all: initial;
|
|
1007
|
+
--hud-bg: #ffffff;
|
|
1008
|
+
--hud-bg-subtle: #f4f4f5;
|
|
1009
|
+
--hud-fg: #18181b;
|
|
1010
|
+
--hud-fg-muted: #71717a;
|
|
1011
|
+
--hud-border: rgba(24, 24, 27, 0.14);
|
|
1012
|
+
--hud-accent: #0369a1;
|
|
1013
|
+
--hud-good: #15803d;
|
|
1014
|
+
--hud-warn: #b45309;
|
|
1015
|
+
--hud-poor: #b91c1c;
|
|
1016
|
+
--hud-shadow: 0 8px 24px rgba(0, 0, 0, 0.12), 0 2px 6px rgba(0, 0, 0, 0.08);
|
|
1017
|
+
--hud-radius: 8px;
|
|
1018
|
+
--hud-font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
1019
|
+
--hud-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
|
1020
|
+
|
|
1021
|
+
font-family: var(--hud-font);
|
|
1022
|
+
font-size: 12px;
|
|
1023
|
+
line-height: 1.5;
|
|
1024
|
+
color: var(--hud-fg);
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
@media (prefers-color-scheme: dark) {
|
|
1028
|
+
.hud {
|
|
1029
|
+
--hud-bg: #18181b;
|
|
1030
|
+
--hud-bg-subtle: #27272a;
|
|
1031
|
+
--hud-fg: #f4f4f5;
|
|
1032
|
+
--hud-fg-muted: #a1a1aa;
|
|
1033
|
+
--hud-border: rgba(244, 244, 245, 0.16);
|
|
1034
|
+
--hud-accent: #38bdf8;
|
|
1035
|
+
--hud-good: #4ade80;
|
|
1036
|
+
--hud-warn: #fbbf24;
|
|
1037
|
+
--hud-poor: #f87171;
|
|
1038
|
+
--hud-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
.hud *, .hud *::before, .hud *::after { box-sizing: border-box; }
|
|
1043
|
+
|
|
1044
|
+
.hud button {
|
|
1045
|
+
font: inherit;
|
|
1046
|
+
color: inherit;
|
|
1047
|
+
background: none;
|
|
1048
|
+
border: 0;
|
|
1049
|
+
margin: 0;
|
|
1050
|
+
padding: 0;
|
|
1051
|
+
cursor: pointer;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
.hud :focus-visible { outline: 2px solid var(--hud-accent); outline-offset: 1px; }
|
|
1055
|
+
|
|
1056
|
+
.pill {
|
|
1057
|
+
position: fixed;
|
|
1058
|
+
left: 12px;
|
|
1059
|
+
bottom: 12px;
|
|
1060
|
+
display: flex;
|
|
1061
|
+
align-items: center;
|
|
1062
|
+
gap: 2px;
|
|
1063
|
+
padding: 3px;
|
|
1064
|
+
background: var(--hud-bg);
|
|
1065
|
+
border: 1px solid var(--hud-border);
|
|
1066
|
+
border-radius: 999px;
|
|
1067
|
+
box-shadow: var(--hud-shadow);
|
|
1068
|
+
font-family: var(--hud-mono);
|
|
1069
|
+
pointer-events: auto;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
.hud .pill-item {
|
|
1073
|
+
display: flex;
|
|
1074
|
+
align-items: center;
|
|
1075
|
+
gap: 6px;
|
|
1076
|
+
padding: 2px 8px;
|
|
1077
|
+
border-radius: 999px;
|
|
1078
|
+
white-space: nowrap;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
.pill-item:hover, .pill-item[aria-pressed="true"] { background: var(--hud-bg-subtle); }
|
|
1082
|
+
|
|
1083
|
+
.env { font-weight: 600; color: var(--hud-accent); }
|
|
1084
|
+
|
|
1085
|
+
.dot { width: 6px; height: 6px; border-radius: 50%; background: var(--hud-fg-muted); }
|
|
1086
|
+
.dot[data-rating="good"] { background: var(--hud-good); }
|
|
1087
|
+
.dot[data-rating="needs-improvement"] { background: var(--hud-warn); }
|
|
1088
|
+
.dot[data-rating="poor"] { background: var(--hud-poor); }
|
|
1089
|
+
|
|
1090
|
+
.vitals { width: 100%; border-collapse: collapse; font-family: var(--hud-mono); }
|
|
1091
|
+
.vitals td { padding: 3px 0; }
|
|
1092
|
+
.vitals td:last-child { text-align: right; }
|
|
1093
|
+
.vitals .label { color: var(--hud-fg-muted); }
|
|
1094
|
+
.empty { color: var(--hud-fg-muted); }
|
|
1095
|
+
` + PANEL_CSS;
|
|
1096
|
+
const NO_PLUGINS = [];
|
|
1097
|
+
const GRANT_ALL = () => true;
|
|
1098
|
+
/**
|
|
1099
|
+
* The HUD shell: a shadow-rooted status pill and floating panels.
|
|
1100
|
+
*
|
|
1101
|
+
* It renders unconditionally. Keeping it out of production builds is the
|
|
1102
|
+
* loader's job, at the import site, so a bundler can drop the whole module
|
|
1103
|
+
* (see `@hansenexus/hud/next`).
|
|
1104
|
+
*/
|
|
1105
|
+
function Hud({ appSlug, envLabel = "DEV", plugins = NO_PLUGINS, apiBase = "/api/hud", can = GRANT_ALL }) {
|
|
1106
|
+
const resolved = useMemo(() => resolvePlugins(plugins, can), [plugins, can]);
|
|
1107
|
+
return /* @__PURE__ */ jsx(ShadowHost, {
|
|
1108
|
+
css: CSS,
|
|
1109
|
+
children: /* @__PURE__ */ jsx(Shell, {
|
|
1110
|
+
appSlug,
|
|
1111
|
+
envLabel,
|
|
1112
|
+
plugins: resolved,
|
|
1113
|
+
apiBase,
|
|
1114
|
+
can
|
|
1115
|
+
})
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
function Shell({ appSlug, envLabel, plugins, apiBase, can }) {
|
|
1119
|
+
const panelsRef = useRef(null);
|
|
1120
|
+
const slots = usePluginSlots(plugins, {
|
|
1121
|
+
app: appSlug,
|
|
1122
|
+
apiBase,
|
|
1123
|
+
can,
|
|
1124
|
+
close: useCallback((id) => panelsRef.current?.close(id), []),
|
|
1125
|
+
pin: useCallback((id, edge) => panelsRef.current?.pin(id, edge), [])
|
|
1126
|
+
});
|
|
1127
|
+
const panels = useMemo(() => slots.filter((slot) => slot.plugin.panel).map((slot) => ({
|
|
1128
|
+
id: slot.plugin.id,
|
|
1129
|
+
title: slot.title,
|
|
1130
|
+
render: () => /* @__PURE__ */ jsx(PluginPanelBody, { slot })
|
|
1131
|
+
})), [slots]);
|
|
1132
|
+
const commands = useMemo(() => slots.flatMap(({ plugin, ctx }) => (plugin.commands ?? []).map((command) => ({
|
|
1133
|
+
id: `${plugin.id}.${command.id}`,
|
|
1134
|
+
title: command.title,
|
|
1135
|
+
shortcut: command.shortcut,
|
|
1136
|
+
run: () => command.run(ctx)
|
|
1137
|
+
}))), [slots]);
|
|
1138
|
+
return /* @__PURE__ */ jsx(PanelManager, {
|
|
1139
|
+
appSlug,
|
|
1140
|
+
panels,
|
|
1141
|
+
commands,
|
|
1142
|
+
apiRef: panelsRef,
|
|
1143
|
+
children: /* @__PURE__ */ jsx(Pill, {
|
|
1144
|
+
appSlug,
|
|
1145
|
+
envLabel,
|
|
1146
|
+
slots
|
|
1147
|
+
})
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
function Pill({ appSlug, envLabel, slots }) {
|
|
1151
|
+
const panels = usePanels();
|
|
1152
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
1153
|
+
className: "pill",
|
|
1154
|
+
role: "toolbar",
|
|
1155
|
+
"aria-label": `${appSlug} hud`,
|
|
1156
|
+
children: [
|
|
1157
|
+
/* @__PURE__ */ jsx("span", {
|
|
1158
|
+
className: "pill-item env",
|
|
1159
|
+
children: envLabel
|
|
1160
|
+
}),
|
|
1161
|
+
slots.map((slot) => /* @__PURE__ */ jsx(PluginPillItem, {
|
|
1162
|
+
slot,
|
|
1163
|
+
open: panels.isOpen(slot.plugin.id),
|
|
1164
|
+
onToggle: () => panels.toggle(slot.plugin.id)
|
|
1165
|
+
}, slot.plugin.id)),
|
|
1166
|
+
/* @__PURE__ */ jsx(PillMenu, {})
|
|
1167
|
+
]
|
|
1168
|
+
});
|
|
1169
|
+
}
|
|
1170
|
+
//#endregion
|
|
1171
|
+
//#region src/core/define-hud.tsx
|
|
1172
|
+
/**
|
|
1173
|
+
* The app's one HUD file:
|
|
1174
|
+
*
|
|
1175
|
+
* ```tsx
|
|
1176
|
+
* export default defineHud({ app: "hansenexus", plugins: [vitals()] });
|
|
1177
|
+
* ```
|
|
1178
|
+
*
|
|
1179
|
+
* Returns a component. The plugin list is frozen here, so the shell sees a
|
|
1180
|
+
* stable array across renders.
|
|
1181
|
+
*/
|
|
1182
|
+
function defineHud({ app, plugins, apiBase, can }) {
|
|
1183
|
+
resolvePlugins(plugins, () => true);
|
|
1184
|
+
const list = Object.freeze([...plugins]);
|
|
1185
|
+
function DefinedHud({ envLabel }) {
|
|
1186
|
+
return /* @__PURE__ */ jsx(Hud, {
|
|
1187
|
+
appSlug: app,
|
|
1188
|
+
plugins: list,
|
|
1189
|
+
...envLabel === void 0 ? {} : { envLabel },
|
|
1190
|
+
...apiBase === void 0 ? {} : { apiBase },
|
|
1191
|
+
...can === void 0 ? {} : { can }
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
DefinedHud.displayName = `Hud(${app})`;
|
|
1195
|
+
return DefinedHud;
|
|
1196
|
+
}
|
|
1197
|
+
//#endregion
|
|
1198
|
+
export { HUD_DEV_MARKER, HUD_HOST_TAG, Hud, HudApiError, defineHud, definePlugin };
|