@hansenexus/hud 0.1.0 → 0.2.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 (91) hide show
  1. package/README.md +42 -23
  2. package/dist/index.d.ts +39 -0
  3. package/dist/index.js +399 -0
  4. package/package.json +45 -136
  5. package/chunk-0cj8dkk7.js +0 -127
  6. package/chunk-1atxwdhw.js +0 -48
  7. package/chunk-24vrg16w.js +0 -39
  8. package/chunk-3j9sm249.js +0 -26
  9. package/chunk-50pq4sg6.js +0 -41
  10. package/chunk-5gtx3pza.js +0 -9
  11. package/chunk-6gw4x08j.js +0 -264
  12. package/chunk-6jt8yg4m.js +0 -117
  13. package/chunk-71se605m.js +0 -85
  14. package/chunk-82nm9wcg.js +0 -316
  15. package/chunk-8tam7r3h.js +0 -68
  16. package/chunk-babjpqkb.js +0 -52
  17. package/chunk-c5y856ad.js +0 -98
  18. package/chunk-ghxh8926.js +0 -172
  19. package/chunk-hc2g0n35.js +0 -24
  20. package/chunk-k0vr8dsy.js +0 -179
  21. package/chunk-k50yfp5d.js +0 -166
  22. package/chunk-kyeb6qeq.js +0 -95
  23. package/chunk-msqkkqw7.js +0 -140
  24. package/chunk-nm82wr8r.js +0 -58
  25. package/chunk-r06bpmy6.js +0 -79
  26. package/chunk-t9eh4t0b.js +0 -89
  27. package/chunk-tf774c4w.js +0 -115
  28. package/chunk-v086j53b.js +0 -19
  29. package/chunk-wspz08q7.js +0 -67
  30. package/chunk-xezxydm3.js +0 -47
  31. package/chunk-xjn0896g.js +0 -574
  32. package/chunk-xztw0xyt.js +0 -113
  33. package/chunk-zzrpd92r.js +0 -172
  34. package/core/access.d.mts +0 -53
  35. package/core/access.js +0 -27
  36. package/core/api-types.d.mts +0 -146
  37. package/core/api-types.js +0 -9
  38. package/core/capabilities.d.mts +0 -27
  39. package/core/capabilities.js +0 -14
  40. package/core/capture-buffer.d.mts +0 -37
  41. package/core/capture-buffer.js +0 -9
  42. package/core/force-state.d.mts +0 -68
  43. package/core/force-state.js +0 -21
  44. package/core/mock-cookie.d.mts +0 -30
  45. package/core/mock-cookie.js +0 -15
  46. package/core/types.d.mts +0 -59
  47. package/core/types.js +0 -9
  48. package/hud-provider.d.mts +0 -38
  49. package/hud-provider.js +0 -15
  50. package/hud.d.mts +0 -15
  51. package/hud.js +0 -18
  52. package/index.d.mts +0 -94
  53. package/index.js +0 -170
  54. package/lazy-panel.d.mts +0 -28
  55. package/lazy-panel.js +0 -11
  56. package/panels/cicd-panel.d.mts +0 -9
  57. package/panels/cicd-panel.js +0 -13
  58. package/panels/debug-info-panel.d.mts +0 -21
  59. package/panels/debug-info-panel.js +0 -9
  60. package/panels/dispatch-panel.d.mts +0 -10
  61. package/panels/dispatch-panel.js +0 -14
  62. package/panels/env-info-panel.d.mts +0 -9
  63. package/panels/env-info-panel.js +0 -12
  64. package/panels/feature-flags-panel.d.mts +0 -26
  65. package/panels/feature-flags-panel.js +0 -9
  66. package/panels/force-state-panel.d.mts +0 -11
  67. package/panels/force-state-panel.js +0 -113
  68. package/panels/info-row.d.mts +0 -21
  69. package/panels/info-row.js +0 -14
  70. package/panels/mock-seed-panel.d.mts +0 -15
  71. package/panels/mock-seed-panel.js +0 -14
  72. package/panels/network-console-panel.d.mts +0 -6
  73. package/panels/network-console-panel.js +0 -9
  74. package/panels/role-switcher-panel.d.mts +0 -28
  75. package/panels/role-switcher-panel.js +0 -9
  76. package/panels/sessions-panel.d.mts +0 -6
  77. package/panels/sessions-panel.js +0 -13
  78. package/panels/tokens-panel.d.mts +0 -38
  79. package/panels/tokens-panel.js +0 -9
  80. package/panels/unlock-panel.d.mts +0 -33
  81. package/panels/unlock-panel.js +0 -14
  82. package/panels/variants-panel.d.mts +0 -51
  83. package/panels/variants-panel.js +0 -12
  84. package/panels/vitals-panel.d.mts +0 -11
  85. package/panels/vitals-panel.js +0 -15
  86. package/server/guard.d.mts +0 -42
  87. package/server/guard.js +0 -12
  88. package/server/unlock.d.mts +0 -28
  89. package/server/unlock.js +0 -108
  90. package/state-boundary.d.mts +0 -25
  91. package/state-boundary.js +0 -54
package/README.md CHANGED
@@ -1,47 +1,66 @@
1
1
  # @hansenexus/hud
2
2
 
3
- A dev HUD for React apps. It gives you a drawer with panels (vitals, network and console
4
- capture, env info, feature flags, forced UI states and more), and capability-gated access
5
- that stays observe-only in production.
3
+ A dev HUD for React apps: a status pill and floating panels, rendered in a shadow root so a
4
+ client site's CSS cannot touch it and it cannot touch the site.
5
+
6
+ > Early. The plugin API, pinning, the element grabber and the Next adapter are tracked in
7
+ > [the PRD](https://github.com/hansenexus/hud/issues/1).
8
+
9
+ ## Develop
10
+
11
+ ```sh
12
+ bun install
13
+ bun run dev # playground on http://localhost:5173, HUD imported from source (HMR)
14
+ bun run check # lint, typecheck, test, build
15
+ ```
16
+
17
+ The playground (`playground/`) is a fake client site with every element that has fought a dev
18
+ tool for a corner: sticky nav, a fixed booking CTA, a chat launcher and a cookie banner, plus a
19
+ deliberately hostile stylesheet. If the HUD changes when you edit `playground/src/site.css`, the
20
+ isolation leaks.
6
21
 
7
22
  ## Install
8
23
 
9
24
  ```sh
10
- npm install @hansenexus/hud
25
+ bun add -d @hansenexus/hud # or npm i -D @hansenexus/hud
11
26
  ```
12
27
 
13
- The host provides the peers `react` and `react-dom` 19. `next` is optional: without Next,
14
- alias `next/link` to a plain anchor component in your bundler.
28
+ Peer dependencies: `react` and `react-dom` 19. Nothing else: no Tailwind, no stylesheet to import,
29
+ no host CSS assumptions. The HUD ships its own styles inside its shadow root.
15
30
 
16
31
  ## Use
17
32
 
18
33
  ```tsx
19
- import { Hud, type HudPanel, NetworkConsolePanel, VitalsPanel } from "@hansenexus/hud";
20
- import { Activity, Terminal } from "lucide-react";
21
-
22
- const panels: HudPanel[] = [
23
- { id: "vitals", label: "Vitals", icon: Activity, capability: "observe.app", content: <VitalsPanel />, order: 1 },
24
- { id: "net", label: "Net / Console", icon: Terminal, capability: "observe.app", content: <NetworkConsolePanel />, order: 2 },
25
- ];
34
+ import { Hud } from "@hansenexus/hud";
26
35
 
27
36
  export function DevHud() {
28
- return <Hud config={{ appSlug: "my-app" }} panels={panels} />;
37
+ return <Hud appSlug="my-app" />;
29
38
  }
30
39
  ```
31
40
 
32
- Mount it only in development builds, for example behind `import.meta.env.DEV` and a lazy import.
41
+ Mount it only in development builds, behind a check the bundler can fold (for example
42
+ `process.env.NODE_ENV === "development"` around a lazy import), so production bundles carry no
43
+ HUD code. `HUD_DEV_MARKER` is rendered on the HUD root for a CI grep.
33
44
 
34
- ## Tailwind v4
45
+ ## Release
35
46
 
36
- The panels use Tailwind utilities, and Tailwind does not scan `node_modules` on its own. Add
37
- the package as a source in the stylesheet that imports Tailwind:
47
+ Versions are driven by [Changesets](https://github.com/changesets/changesets). A PR that changes
48
+ what the package ships adds one with `bun run changeset` (pre-1.0: `minor` for breaking changes,
49
+ `patch` for the rest). On `main`, `.github/workflows/release.yml` gathers pending changesets into
50
+ a "chore: version packages" PR. Merging that PR publishes the new version to npm over Trusted
51
+ Publishing (GitHub OIDC, no `NPM_TOKEN`), tags `v<version>` and cuts a GitHub release from the
52
+ CHANGELOG section. `Actions > Release > Run workflow` with `dry_run` runs the gate and
53
+ `npm publish --dry-run` without publishing.
38
54
 
39
- ```css
40
- @source "../node_modules/@hansenexus/hud";
41
- ```
55
+ Versions up to 0.1.1 were published from hn-monorepo; 0.2.0 is the first release from this repo.
56
+
57
+ Provenance is off while the repo is private (npm refuses `--provenance` from a private source).
58
+ When the repo goes public, set `NPM_PROVENANCE: "true"` in `release.yml`.
42
59
 
43
- The path is relative to that stylesheet. If a panel renders unstyled, this line is missing
44
- or points at the wrong place.
60
+ **Owner step, once, needs npm 2FA:** npmjs.com → `@hansenexus/hud` → Settings → Trusted
61
+ Publisher → GitHub Actions: organization `hansenexus`, repository `hud`, workflow `release.yml`.
62
+ Do this before merging the first Version Packages PR. Keep the hn-monorepo publisher until
63
+ hn-monorepo#2740 retires its old release workflow.
45
64
 
46
65
  ## License
47
66
 
@@ -0,0 +1,39 @@
1
+ import { Metric } from "web-vitals";
2
+ //#region src/core/marker.d.ts
3
+ /**
4
+ * Literal a consumer's CI bundle gate greps for in production client chunks.
5
+ * It is rendered as an attribute on the HUD root, so a bundler cannot drop it
6
+ * while keeping the HUD, and it is unmistakably deliberate rather than an
7
+ * incidental string a refactor could rename away.
8
+ */
9
+ export declare const HUD_DEV_MARKER = "__HANSENEXUS_HUD_DEV__";
10
+ /** Tag name of the element that hosts the HUD's shadow root. */
11
+ export declare const HUD_HOST_TAG = "hn-hud";
12
+ //#endregion
13
+ //#region src/shell/hud.d.ts
14
+ interface HudProps {
15
+ /** App slug, used for labels now and for the layout storage key later. */
16
+ appSlug: string;
17
+ /** Badge shown at the start of the pill. Default "DEV". */
18
+ envLabel?: string;
19
+ }
20
+ /**
21
+ * The HUD shell: a shadow-rooted status pill and floating panels.
22
+ *
23
+ * It renders unconditionally. Keeping it out of production builds is the
24
+ * loader's job, at the import site, so a bundler can drop the whole module
25
+ * (see the Next adapter, hud#4).
26
+ */
27
+ export declare function Hud({ appSlug, envLabel }: HudProps): import("react").JSX.Element;
28
+ //#endregion
29
+ //#region src/vitals/store.d.ts
30
+ type VitalName = Metric["name"];
31
+ type VitalRating = Metric["rating"];
32
+ interface Vital {
33
+ name: VitalName;
34
+ value: number;
35
+ rating: VitalRating;
36
+ }
37
+ type VitalsSnapshot = Partial<Record<VitalName, Vital>>;
38
+ //#endregion
39
+ export type { HudProps, Vital, VitalName, VitalRating, VitalsSnapshot };
package/dist/index.js ADDED
@@ -0,0 +1,399 @@
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
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";
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
35
+ }
36
+ };
37
+ for (const listener of listeners) listener();
38
+ }
39
+ /**
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.
43
+ */
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 {}
55
+ }
56
+ function getVitals() {
57
+ return snapshot;
58
+ }
59
+ function subscribeVitals(listener) {
60
+ listeners.add(listener);
61
+ return () => listeners.delete(listener);
62
+ }
63
+ function useVitals() {
64
+ return useSyncExternalStore(subscribeVitals, getVitals, getVitals);
65
+ }
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`;
70
+ }
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
+ });
97
+ }
98
+ //#endregion
99
+ //#region src/shell/floating-panel.tsx
100
+ /** Keeps at least this much of the title bar on screen so a panel can always be dragged back. */
101
+ const GRIP = 40;
102
+ function clampPosition(pos, width, viewport) {
103
+ return {
104
+ x: Math.min(Math.max(pos.x, GRIP - width), viewport.w - GRIP),
105
+ y: Math.min(Math.max(pos.y, 0), viewport.h - GRIP)
106
+ };
107
+ }
108
+ /**
109
+ * A draggable panel. Pinning to an edge and persistence arrive with the panel
110
+ * manager (hud#3); this is the single-window tracer.
111
+ */
112
+ function FloatingPanel({ title, initial, onClose, children }) {
113
+ const [pos, setPos] = useState(initial);
114
+ const panelRef = useRef(null);
115
+ const drag = useRef(null);
116
+ const onPointerDown = useCallback((e) => {
117
+ if (e.target.closest("button")) return;
118
+ drag.current = {
119
+ dx: e.clientX - pos.x,
120
+ dy: e.clientY - pos.y
121
+ };
122
+ e.currentTarget.setPointerCapture(e.pointerId);
123
+ }, [pos]);
124
+ const onPointerMove = useCallback((e) => {
125
+ const d = drag.current;
126
+ if (!d) return;
127
+ const width = panelRef.current?.offsetWidth ?? 240;
128
+ setPos(clampPosition({
129
+ x: e.clientX - d.dx,
130
+ y: e.clientY - d.dy
131
+ }, width, {
132
+ w: window.innerWidth,
133
+ h: window.innerHeight
134
+ }));
135
+ }, []);
136
+ const onPointerUp = useCallback((e) => {
137
+ drag.current = null;
138
+ if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
139
+ }, []);
140
+ return /* @__PURE__ */ jsxs("section", {
141
+ ref: panelRef,
142
+ className: "panel",
143
+ "aria-label": title,
144
+ style: {
145
+ left: pos.x,
146
+ top: pos.y
147
+ },
148
+ children: [/* @__PURE__ */ jsxs("div", {
149
+ className: "panel-bar",
150
+ onPointerDown,
151
+ onPointerMove,
152
+ onPointerUp,
153
+ onPointerCancel: onPointerUp,
154
+ children: [/* @__PURE__ */ jsx("span", {
155
+ className: "panel-title",
156
+ children: title
157
+ }), /* @__PURE__ */ jsx("button", {
158
+ type: "button",
159
+ className: "panel-close",
160
+ "aria-label": `Close ${title}`,
161
+ onClick: onClose,
162
+ children: "×"
163
+ })]
164
+ }), /* @__PURE__ */ jsx("div", {
165
+ className: "panel-body",
166
+ children
167
+ })]
168
+ });
169
+ }
170
+ //#endregion
171
+ //#region src/shell/shadow-host.tsx
172
+ /**
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`.
178
+ */
179
+ const HOST_STYLE = {
180
+ all: "initial",
181
+ position: "fixed",
182
+ inset: "0",
183
+ "z-index": "2147483000",
184
+ "pointer-events": "none",
185
+ display: "block"
186
+ };
187
+ /**
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.
191
+ */
192
+ function ShadowHost({ css, children }) {
193
+ const [mount, setMount] = useState(null);
194
+ 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);
206
+ return () => {
207
+ host.remove();
208
+ setMount(null);
209
+ };
210
+ }, [css]);
211
+ return mount ? createPortal(children, mount) : null;
212
+ }
213
+ //#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 = `
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;
237
+
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;
269
+ }
270
+
271
+ .hud :focus-visible { outline: 2px solid var(--hud-accent); outline-offset: 1px; }
272
+
273
+ .pill {
274
+ position: fixed;
275
+ left: 12px;
276
+ bottom: 12px;
277
+ display: flex;
278
+ align-items: center;
279
+ gap: 2px;
280
+ padding: 3px;
281
+ background: var(--hud-bg);
282
+ border: 1px solid var(--hud-border);
283
+ border-radius: 999px;
284
+ box-shadow: var(--hud-shadow);
285
+ font-family: var(--hud-mono);
286
+ pointer-events: auto;
287
+ }
288
+
289
+ .pill-item {
290
+ display: flex;
291
+ align-items: center;
292
+ gap: 6px;
293
+ padding: 2px 8px;
294
+ border-radius: 999px;
295
+ white-space: nowrap;
296
+ }
297
+
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); }
306
+
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
+ .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); }
346
+ `;
347
+ //#endregion
348
+ //#region src/shell/hud.tsx
349
+ /**
350
+ * The HUD shell: a shadow-rooted status pill and floating panels.
351
+ *
352
+ * It renders unconditionally. Keeping it out of production builds is the
353
+ * loader's job, at the import site, so a bundler can drop the whole module
354
+ * (see the Next adapter, hud#4).
355
+ */
356
+ function Hud({ appSlug, envLabel = "DEV" }) {
357
+ useEffect(() => {
358
+ startVitals();
359
+ }, []);
360
+ return /* @__PURE__ */ jsx(ShadowHost, {
361
+ css: HUD_CSS,
362
+ children: /* @__PURE__ */ jsx(Shell, {
363
+ appSlug,
364
+ envLabel
365
+ })
366
+ });
367
+ }
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", {
372
+ className: "pill",
373
+ role: "toolbar",
374
+ "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
+ })] });
397
+ }
398
+ //#endregion
399
+ export { HUD_DEV_MARKER, HUD_HOST_TAG, Hud };