@hansenexus/hud 0.2.0 → 0.3.0

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