@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.
Files changed (55) hide show
  1. package/README.md +253 -9
  2. package/dist/access-CNvJOwKM.d.ts +54 -0
  3. package/dist/capabilities-CjulXFQX.js +189 -0
  4. package/dist/define-hud-BBzsSl8b.d.ts +36 -0
  5. package/dist/env-DfxpPfs9.js +28 -0
  6. package/dist/env-vEjyJfPW.d.ts +17 -0
  7. package/dist/force-state-BaqWsmrf.js +153 -0
  8. package/dist/force-state-CCqoTfVB.d.ts +23 -0
  9. package/dist/handler-WmE4Lu7l.d.ts +39 -0
  10. package/dist/index-BYsdS-7d.d.ts +55 -0
  11. package/dist/index.d.ts +6 -39
  12. package/dist/index.js +980 -264
  13. package/dist/marker-C66LxOhJ.d.ts +9 -0
  14. package/dist/marker-CMrvxPmS.d.ts +12 -0
  15. package/dist/marker-DjeGvL-F.js +9 -0
  16. package/dist/marker-uZYPOJ4M.js +12 -0
  17. package/dist/markers.d.ts +3 -0
  18. package/dist/markers.js +3 -0
  19. package/dist/next.d.ts +33 -0
  20. package/dist/next.js +52 -0
  21. package/dist/payload-CLKDVZWl.d.ts +68 -0
  22. package/dist/plugins/access/server.d.ts +122 -0
  23. package/dist/plugins/access/server.js +339 -0
  24. package/dist/plugins/access.d.ts +41 -0
  25. package/dist/plugins/access.js +250 -0
  26. package/dist/plugins/agent/server.d.ts +129 -0
  27. package/dist/plugins/agent/server.js +429 -0
  28. package/dist/plugins/agent.d.ts +37 -0
  29. package/dist/plugins/agent.js +411 -0
  30. package/dist/plugins/design.d.ts +125 -0
  31. package/dist/plugins/design.js +619 -0
  32. package/dist/plugins/observe.d.ts +64 -0
  33. package/dist/plugins/observe.js +617 -0
  34. package/dist/plugins/ops/server.d.ts +82 -0
  35. package/dist/plugins/ops/server.js +380 -0
  36. package/dist/plugins/ops.d.ts +26 -0
  37. package/dist/plugins/ops.js +362 -0
  38. package/dist/plugins/vitals.d.ts +25 -0
  39. package/dist/plugins/vitals.js +26 -0
  40. package/dist/plugins-CQ_0w1ot.d.ts +114 -0
  41. package/dist/plugins-DVy_vaYr.js +70 -0
  42. package/dist/review.d.ts +181 -0
  43. package/dist/review.js +1017 -0
  44. package/dist/selector-NSYSe7AZ.js +28 -0
  45. package/dist/server.d.ts +3 -0
  46. package/dist/server.js +106 -0
  47. package/dist/shortcut-U6ftVY06.js +222 -0
  48. package/dist/state-boundary.d.ts +26 -0
  49. package/dist/state-boundary.js +61 -0
  50. package/dist/styles--slk86ZQ.js +332 -0
  51. package/dist/types-DADBRiGk.d.ts +50 -0
  52. package/dist/types-QJUajwWq.js +35 -0
  53. package/dist/types-f3jlNxzw.d.ts +92 -0
  54. package/dist/vitals-panel-Dvc5dnR-.js +87 -0
  55. package/package.json +36 -3
package/dist/index.js CHANGED
@@ -1,102 +1,252 @@
1
- import { useCallback, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
2
- import { onCLS, onFCP, onINP, onLCP, onTTFB } from "web-vitals";
3
- import { Fragment, jsx, jsxs } from "react/jsx-runtime";
4
- import { createPortal } from "react-dom";
5
- //#region src/core/marker.ts
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
- * Literal a consumer's CI bundle gate greps for in production client chunks.
8
- * It is rendered as an attribute on the HUD root, so a bundler cannot drop it
9
- * while keeping the HUD, and it is unmistakably deliberate rather than an
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
- const HUD_DEV_MARKER = "__HANSENEXUS_HUD_DEV__";
13
- /** Tag name of the element that hosts the HUD's shadow root. */
14
- const HUD_HOST_TAG = "hn-hud";
15
- //#endregion
16
- //#region src/vitals/store.ts
17
- /** Display order: the three Core Web Vitals first, then the diagnostics. */
18
- const VITAL_ORDER = [
19
- "LCP",
20
- "INP",
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
- for (const listener of listeners) listener();
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
- * Starts collection once per page. Called when the HUD mounts, not when the
41
- * panel opens: LCP and FCP are only reported for the initial load, so a lazy
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
- function startVitals() {
45
- if (started) return;
46
- started = true;
47
- const opts = { reportAllChanges: true };
48
- try {
49
- onLCP(record, opts);
50
- onINP(record, opts);
51
- onCLS(record, opts);
52
- onFCP(record, opts);
53
- onTTFB(record, opts);
54
- } catch {}
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
- function getVitals() {
57
- return snapshot;
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 subscribeVitals(listener) {
60
- listeners.add(listener);
61
- return () => listeners.delete(listener);
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 useVitals() {
64
- return useSyncExternalStore(subscribeVitals, getVitals, getVitals);
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 formatVital(name, value) {
67
- if (name === "CLS") return value.toFixed(2);
68
- if (value >= 1e3) return `${(value / 1e3).toFixed(1)} s`;
69
- return `${Math.round(value)} ms`;
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
- //#endregion
72
- //#region src/vitals/vitals-panel.tsx
73
- function VitalsPanel() {
74
- const vitals = useVitals();
75
- const rows = VITAL_ORDER.flatMap((name) => {
76
- const vital = vitals[name];
77
- return vital ? [vital] : [];
78
- });
79
- if (rows.length === 0) return /* @__PURE__ */ jsx("p", {
80
- className: "empty",
81
- children: "No metrics yet. LCP reports after the first interaction or tab switch."
82
- });
83
- return /* @__PURE__ */ jsx("table", {
84
- className: "vitals",
85
- children: /* @__PURE__ */ jsx("tbody", { children: rows.map((vital) => /* @__PURE__ */ jsxs("tr", { children: [/* @__PURE__ */ jsx("td", {
86
- className: "label",
87
- children: vital.name
88
- }), /* @__PURE__ */ jsxs("td", { children: [/* @__PURE__ */ jsx("span", {
89
- className: "dot",
90
- "data-rating": vital.rating,
91
- style: {
92
- display: "inline-block",
93
- marginRight: 6
94
- }
95
- }), formatVital(vital.name, vital.value)] })] }, vital.name)) })
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
- * A draggable panel. Pinning to an edge and persistence arrive with the panel
110
- * manager (hud#3); this is the single-window tracer.
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 FloatingPanel({ title, initial, onClose, children }) {
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 onPointerDown = useCallback((e) => {
117
- if (e.target.closest("button")) return;
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
- }, [pos]);
124
- const onPointerMove = useCallback((e) => {
296
+ };
297
+ const onPointerMove = (e) => {
125
298
  const d = drag.current;
126
299
  if (!d) return;
127
- const width = panelRef.current?.offsetWidth ?? 240;
128
- setPos(clampPosition({
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
- w: window.innerWidth,
133
- h: window.innerHeight
134
- }));
135
- }, []);
136
- const onPointerUp = useCallback((e) => {
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
- style: {
145
- left: pos.x,
146
- top: pos.y
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-close",
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/shadow-host.tsx
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
- * Inline `!important` on the host element: author `!important` in a host
174
- * stylesheet (`* { position: static !important }`) loses to inline
175
- * `!important`, so this is the one set of rules a client site cannot override.
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
- const HOST_STYLE = {
180
- all: "initial",
181
- position: "fixed",
182
- inset: "0",
183
- "z-index": "2147483000",
184
- "pointer-events": "none",
185
- display: "block"
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
- * Renders children into a shadow root on a host element appended to <body>.
189
- * Mounted in an effect, so server rendering emits nothing and hydration never
190
- * differs.
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 ShadowHost({ css, children }) {
193
- const [mount, setMount] = useState(null);
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
- const host = document.createElement(HUD_HOST_TAG);
196
- for (const [prop, value] of Object.entries(HOST_STYLE)) host.style.setProperty(prop, value, "important");
197
- const shadow = host.attachShadow({ mode: "open" });
198
- const style = document.createElement("style");
199
- style.textContent = css;
200
- const root = document.createElement("div");
201
- root.className = "hud";
202
- root.setAttribute("data-hud-marker", HUD_DEV_MARKER);
203
- shadow.append(style, root);
204
- document.body.appendChild(host);
205
- setMount(root);
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
- host.remove();
208
- setMount(null);
699
+ window.removeEventListener("pointerdown", onPointerDown, true);
700
+ window.removeEventListener("keydown", onKeyDown, true);
209
701
  };
210
- }, [css]);
211
- return mount ? createPortal(children, mount) : null;
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
- * 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.
755
+ * Panel manager styles, appended to `HUD_CSS` inside the same shadow root and
756
+ * built on its `--hud-*` tokens.
220
757
  */
221
- const HUD_CSS = `
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
- font-family: var(--hud-font);
239
- font-size: 12px;
240
- line-height: 1.5;
241
- color: var(--hud-fg);
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
- .hud :focus-visible { outline: 2px solid var(--hud-accent); outline-offset: 1px; }
767
+ .menu-anchor { position: relative; display: flex; }
272
768
 
273
- .pill {
274
- position: fixed;
275
- left: 12px;
276
- bottom: 12px;
769
+ .menu {
770
+ position: absolute;
771
+ left: 0;
772
+ bottom: calc(100% + 8px);
277
773
  display: flex;
278
- align-items: center;
279
- gap: 2px;
280
- padding: 3px;
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: 999px;
779
+ border-radius: var(--hud-radius);
284
780
  box-shadow: var(--hud-shadow);
285
- font-family: var(--hud-mono);
286
- pointer-events: auto;
781
+ font-family: var(--hud-font);
287
782
  }
288
783
 
289
- .pill-item {
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
- gap: 6px;
293
- padding: 2px 8px;
294
- border-radius: 999px;
295
- white-space: nowrap;
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
- .pill-item:hover, .pill-item[aria-pressed="true"] { background: var(--hud-bg-subtle); }
299
-
300
- .env { font-weight: 600; color: var(--hud-accent); }
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 8px 6px 10px;
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-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; }
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
- .vitals { width: 100%; border-collapse: collapse; font-family: var(--hud-mono); }
342
- .vitals td { padding: 3px 0; }
343
- .vitals td:last-child { text-align: right; }
344
- .vitals .label { color: var(--hud-fg-muted); }
345
- .empty { color: var(--hud-fg-muted); }
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 the Next adapter, hud#4).
958
+ * (see `@hansenexus/hud/next`).
355
959
  */
356
- function Hud({ appSlug, envLabel = "DEV" }) {
357
- useEffect(() => {
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: HUD_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 [vitalsOpen, setVitalsOpen] = useState(false);
370
- const lcp = useVitals().LCP;
371
- return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("div", {
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: [/* @__PURE__ */ jsx("span", {
376
- className: "pill-item env",
377
- children: envLabel
378
- }), /* @__PURE__ */ jsxs("button", {
379
- type: "button",
380
- className: "pill-item",
381
- "aria-pressed": vitalsOpen,
382
- onClick: () => setVitalsOpen((open) => !open),
383
- children: [/* @__PURE__ */ jsx("span", {
384
- className: "dot",
385
- "data-rating": lcp?.rating
386
- }), lcp ? `LCP ${formatVital("LCP", lcp.value)}` : "LCP –"]
387
- })]
388
- }), vitalsOpen && /* @__PURE__ */ jsx(FloatingPanel, {
389
- title: "Vitals",
390
- initial: {
391
- x: 12,
392
- y: Math.max(12, window.innerHeight - 260)
393
- },
394
- onClose: () => setVitalsOpen(false),
395
- children: /* @__PURE__ */ jsx(VitalsPanel, {})
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 };