@hansenexus/hud 0.2.0 → 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 +71 -9
- package/dist/define-hud-BHQR8Lbv.d.ts +31 -0
- package/dist/index.d.ts +12 -15
- package/dist/index.js +991 -192
- 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 +14 -1
package/dist/index.js
CHANGED
|
@@ -1,102 +1,256 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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
4
|
import { createPortal } from "react-dom";
|
|
5
|
-
//#region src/
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
+
}
|
|
15
26
|
//#endregion
|
|
16
|
-
//#region src/
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
];
|
|
25
|
-
let snapshot = {};
|
|
26
|
-
const listeners = /* @__PURE__ */ new Set();
|
|
27
|
-
let started = false;
|
|
28
|
-
function record(metric) {
|
|
29
|
-
snapshot = {
|
|
30
|
-
...snapshot,
|
|
31
|
-
[metric.name]: {
|
|
32
|
-
name: metric.name,
|
|
33
|
-
value: metric.value,
|
|
34
|
-
rating: metric.rating
|
|
35
|
-
}
|
|
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")
|
|
36
35
|
};
|
|
37
|
-
for (const listener of listeners) listener();
|
|
38
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
|
+
};
|
|
39
67
|
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* start would miss them.
|
|
68
|
+
* Commands the shell and plugins contribute. Registering an id again
|
|
69
|
+
* replaces the earlier command; the first registered shortcut match wins.
|
|
43
70
|
*/
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
+
});
|
|
55
132
|
}
|
|
56
|
-
|
|
57
|
-
|
|
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;
|
|
58
145
|
}
|
|
59
|
-
function
|
|
60
|
-
|
|
61
|
-
|
|
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
|
+
};
|
|
62
156
|
}
|
|
63
|
-
function
|
|
64
|
-
|
|
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
|
+
};
|
|
65
163
|
}
|
|
66
|
-
function
|
|
67
|
-
if (
|
|
68
|
-
|
|
69
|
-
|
|
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
|
+
};
|
|
70
170
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
+
};
|
|
97
253
|
}
|
|
98
|
-
//#endregion
|
|
99
|
-
//#region src/shell/floating-panel.tsx
|
|
100
254
|
/** Keeps at least this much of the title bar on screen so a panel can always be dragged back. */
|
|
101
255
|
const GRIP = 40;
|
|
102
256
|
function clampPosition(pos, width, viewport) {
|
|
@@ -105,46 +259,86 @@ function clampPosition(pos, width, viewport) {
|
|
|
105
259
|
y: Math.min(Math.max(pos.y, 0), viewport.h - GRIP)
|
|
106
260
|
};
|
|
107
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
|
+
];
|
|
108
282
|
/**
|
|
109
|
-
*
|
|
110
|
-
*
|
|
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.
|
|
111
285
|
*/
|
|
112
|
-
function
|
|
113
|
-
const [pos, setPos] = useState(initial);
|
|
286
|
+
function PanelFrame({ title, z, viewport, position, pinned, onFocus, onClose, onMove, onPin, onSnapPreview, children }) {
|
|
114
287
|
const panelRef = useRef(null);
|
|
115
288
|
const drag = useRef(null);
|
|
116
|
-
const
|
|
117
|
-
|
|
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;
|
|
118
293
|
drag.current = {
|
|
119
294
|
dx: e.clientX - pos.x,
|
|
120
|
-
dy: e.clientY - pos.y
|
|
295
|
+
dy: e.clientY - pos.y,
|
|
296
|
+
pos,
|
|
297
|
+
edge: null
|
|
121
298
|
};
|
|
122
299
|
e.currentTarget.setPointerCapture(e.pointerId);
|
|
123
|
-
}
|
|
124
|
-
const onPointerMove =
|
|
300
|
+
};
|
|
301
|
+
const onPointerMove = (e) => {
|
|
125
302
|
const d = drag.current;
|
|
126
303
|
if (!d) return;
|
|
127
|
-
const width = panelRef.current?.offsetWidth ??
|
|
128
|
-
|
|
304
|
+
const width = panelRef.current?.offsetWidth ?? MIN_WIDTH;
|
|
305
|
+
d.pos = clampPosition({
|
|
129
306
|
x: e.clientX - d.dx,
|
|
130
307
|
y: e.clientY - d.dy
|
|
131
|
-
}, width,
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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;
|
|
137
318
|
drag.current = null;
|
|
138
319
|
if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
|
|
139
|
-
|
|
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
|
+
};
|
|
140
335
|
return /* @__PURE__ */ jsxs("section", {
|
|
141
336
|
ref: panelRef,
|
|
142
337
|
className: "panel",
|
|
143
338
|
"aria-label": title,
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
},
|
|
339
|
+
"data-pin": pinned?.edge,
|
|
340
|
+
style,
|
|
341
|
+
onPointerDownCapture: onFocus,
|
|
148
342
|
children: [/* @__PURE__ */ jsxs("div", {
|
|
149
343
|
className: "panel-bar",
|
|
150
344
|
onPointerDown,
|
|
@@ -154,20 +348,615 @@ function FloatingPanel({ title, initial, onClose, children }) {
|
|
|
154
348
|
children: [/* @__PURE__ */ jsx("span", {
|
|
155
349
|
className: "panel-title",
|
|
156
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))
|
|
157
402
|
}), /* @__PURE__ */ jsx("button", {
|
|
158
403
|
type: "button",
|
|
159
|
-
className: "panel-
|
|
160
|
-
"aria-label": `Close ${title}`,
|
|
161
|
-
onClick: onClose,
|
|
404
|
+
className: "panel-action",
|
|
405
|
+
"aria-label": `Close ${active.title}`,
|
|
406
|
+
onClick: () => onClose(active.id),
|
|
162
407
|
children: "×"
|
|
163
408
|
})]
|
|
164
409
|
}), /* @__PURE__ */ jsx("div", {
|
|
165
410
|
className: "panel-body",
|
|
411
|
+
role: "tabpanel",
|
|
166
412
|
children
|
|
167
413
|
})]
|
|
168
414
|
});
|
|
169
415
|
}
|
|
170
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
|
|
171
960
|
//#region src/shell/shadow-host.tsx
|
|
172
961
|
/**
|
|
173
962
|
* Inline `!important` on the host element: author `!important` in a host
|
|
@@ -211,14 +1000,8 @@ function ShadowHost({ css, children }) {
|
|
|
211
1000
|
return mount ? createPortal(children, mount) : null;
|
|
212
1001
|
}
|
|
213
1002
|
//#endregion
|
|
214
|
-
//#region src/shell/
|
|
215
|
-
|
|
216
|
-
* The HUD's whole stylesheet. It lives inside the shadow root, so host
|
|
217
|
-
* selectors cannot reach in and these rules cannot leak out. Inherited
|
|
218
|
-
* properties (font, color, line-height) still cross the shadow boundary, which
|
|
219
|
-
* is what `all: initial` on the root resets.
|
|
220
|
-
*/
|
|
221
|
-
const HUD_CSS = `
|
|
1003
|
+
//#region src/shell/hud.tsx
|
|
1004
|
+
const CSS = `
|
|
222
1005
|
.hud {
|
|
223
1006
|
all: initial;
|
|
224
1007
|
--hud-bg: #ffffff;
|
|
@@ -286,7 +1069,7 @@ const HUD_CSS = `
|
|
|
286
1069
|
pointer-events: auto;
|
|
287
1070
|
}
|
|
288
1071
|
|
|
289
|
-
.pill-item {
|
|
1072
|
+
.hud .pill-item {
|
|
290
1073
|
display: flex;
|
|
291
1074
|
align-items: center;
|
|
292
1075
|
gap: 6px;
|
|
@@ -304,96 +1087,112 @@ const HUD_CSS = `
|
|
|
304
1087
|
.dot[data-rating="needs-improvement"] { background: var(--hud-warn); }
|
|
305
1088
|
.dot[data-rating="poor"] { background: var(--hud-poor); }
|
|
306
1089
|
|
|
307
|
-
.panel {
|
|
308
|
-
position: fixed;
|
|
309
|
-
display: flex;
|
|
310
|
-
flex-direction: column;
|
|
311
|
-
min-width: 240px;
|
|
312
|
-
max-width: min(420px, calc(100vw - 24px));
|
|
313
|
-
max-height: calc(100vh - 24px);
|
|
314
|
-
background: var(--hud-bg);
|
|
315
|
-
border: 1px solid var(--hud-border);
|
|
316
|
-
border-radius: var(--hud-radius);
|
|
317
|
-
box-shadow: var(--hud-shadow);
|
|
318
|
-
pointer-events: auto;
|
|
319
|
-
overflow: hidden;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
.panel-bar {
|
|
323
|
-
display: flex;
|
|
324
|
-
align-items: center;
|
|
325
|
-
justify-content: space-between;
|
|
326
|
-
gap: 8px;
|
|
327
|
-
padding: 6px 8px 6px 10px;
|
|
328
|
-
border-bottom: 1px solid var(--hud-border);
|
|
329
|
-
background: var(--hud-bg-subtle);
|
|
330
|
-
cursor: grab;
|
|
331
|
-
user-select: none;
|
|
332
|
-
touch-action: none;
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
.panel-bar:active { cursor: grabbing; }
|
|
336
|
-
.panel-title { font-weight: 600; }
|
|
337
|
-
.panel-close { width: 20px; height: 20px; border-radius: 4px; color: var(--hud-fg-muted); }
|
|
338
|
-
.panel-close:hover { background: var(--hud-border); color: var(--hud-fg); }
|
|
339
|
-
.panel-body { padding: 8px 10px; overflow: auto; }
|
|
340
|
-
|
|
341
1090
|
.vitals { width: 100%; border-collapse: collapse; font-family: var(--hud-mono); }
|
|
342
1091
|
.vitals td { padding: 3px 0; }
|
|
343
1092
|
.vitals td:last-child { text-align: right; }
|
|
344
1093
|
.vitals .label { color: var(--hud-fg-muted); }
|
|
345
1094
|
.empty { color: var(--hud-fg-muted); }
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
1095
|
+
` + PANEL_CSS;
|
|
1096
|
+
const NO_PLUGINS = [];
|
|
1097
|
+
const GRANT_ALL = () => true;
|
|
349
1098
|
/**
|
|
350
1099
|
* The HUD shell: a shadow-rooted status pill and floating panels.
|
|
351
1100
|
*
|
|
352
1101
|
* It renders unconditionally. Keeping it out of production builds is the
|
|
353
1102
|
* loader's job, at the import site, so a bundler can drop the whole module
|
|
354
|
-
* (see
|
|
1103
|
+
* (see `@hansenexus/hud/next`).
|
|
355
1104
|
*/
|
|
356
|
-
function Hud({ appSlug, envLabel = "DEV" }) {
|
|
357
|
-
|
|
358
|
-
startVitals();
|
|
359
|
-
}, []);
|
|
1105
|
+
function Hud({ appSlug, envLabel = "DEV", plugins = NO_PLUGINS, apiBase = "/api/hud", can = GRANT_ALL }) {
|
|
1106
|
+
const resolved = useMemo(() => resolvePlugins(plugins, can), [plugins, can]);
|
|
360
1107
|
return /* @__PURE__ */ jsx(ShadowHost, {
|
|
361
|
-
css:
|
|
1108
|
+
css: CSS,
|
|
362
1109
|
children: /* @__PURE__ */ jsx(Shell, {
|
|
363
1110
|
appSlug,
|
|
364
|
-
envLabel
|
|
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
|
|
365
1147
|
})
|
|
366
1148
|
});
|
|
367
1149
|
}
|
|
368
|
-
function
|
|
369
|
-
const
|
|
370
|
-
|
|
371
|
-
return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("div", {
|
|
1150
|
+
function Pill({ appSlug, envLabel, slots }) {
|
|
1151
|
+
const panels = usePanels();
|
|
1152
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
372
1153
|
className: "pill",
|
|
373
1154
|
role: "toolbar",
|
|
374
1155
|
"aria-label": `${appSlug} hud`,
|
|
375
|
-
children: [
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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;
|
|
397
1196
|
}
|
|
398
1197
|
//#endregion
|
|
399
|
-
export { HUD_DEV_MARKER, HUD_HOST_TAG, Hud };
|
|
1198
|
+
export { HUD_DEV_MARKER, HUD_HOST_TAG, Hud, HudApiError, defineHud, definePlugin };
|