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