@hansenexus/hud 0.3.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 (54) hide show
  1. package/README.md +186 -4
  2. package/dist/access-CNvJOwKM.d.ts +54 -0
  3. package/dist/capabilities-CjulXFQX.js +189 -0
  4. package/dist/{define-hud-BHQR8Lbv.d.ts → define-hud-BBzsSl8b.d.ts} +6 -1
  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 -36
  12. package/dist/index.js +119 -202
  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 +3 -18
  20. package/dist/next.js +3 -28
  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.js +3 -86
  39. package/dist/{plugins-1QmTmZyL.d.ts → plugins-CQ_0w1ot.d.ts} +39 -1
  40. package/dist/{plugins-CzUiTn_r.js → plugins-DVy_vaYr.js} +3 -1
  41. package/dist/review.d.ts +181 -0
  42. package/dist/review.js +1017 -0
  43. package/dist/selector-NSYSe7AZ.js +28 -0
  44. package/dist/server.d.ts +3 -39
  45. package/dist/server.js +1 -1
  46. package/dist/shortcut-U6ftVY06.js +222 -0
  47. package/dist/state-boundary.d.ts +26 -0
  48. package/dist/state-boundary.js +61 -0
  49. package/dist/styles--slk86ZQ.js +332 -0
  50. package/dist/types-DADBRiGk.d.ts +50 -0
  51. package/dist/types-QJUajwWq.js +35 -0
  52. package/dist/types-f3jlNxzw.d.ts +92 -0
  53. package/dist/vitals-panel-Dvc5dnR-.js +87 -0
  54. package/package.json +23 -3
@@ -0,0 +1,332 @@
1
+ import { n as HUD_HOST_TAG } from "./marker-uZYPOJ4M.js";
2
+ import { useEffect, useLayoutEffect, useRef, useState } from "react";
3
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
4
+ import { createPortal } from "react-dom";
5
+ //#region src/core/grabber/picker.tsx
6
+ /**
7
+ * Styles for the picker overlay. The picker renders where it is mounted, so
8
+ * the caller puts these into the same (shadow) root. Colours fall back when
9
+ * the `--hud-*` tokens are absent.
10
+ */
11
+ const PICKER_CSS = `
12
+ .grab-layer { position: fixed; inset: 0; pointer-events: none; }
13
+ .grab-hint {
14
+ position: fixed;
15
+ top: 0;
16
+ left: 50%;
17
+ transform: translateX(-50%);
18
+ padding: 3px 12px;
19
+ border-radius: 0 0 6px 6px;
20
+ white-space: nowrap;
21
+ background: var(--hud-accent, #0369a1);
22
+ color: var(--hud-accent-fg, #ffffff);
23
+ font: 600 11px/1.5 var(--hud-mono, ui-monospace, monospace);
24
+ }
25
+ .grab-outline {
26
+ position: fixed;
27
+ border: 1.5px solid var(--hud-accent, #0369a1);
28
+ background: color-mix(in srgb, var(--hud-accent, #0369a1) 8%, transparent);
29
+ border-radius: 3px;
30
+ }
31
+ .grab-label {
32
+ position: fixed;
33
+ max-width: min(480px, calc(100vw - 8px));
34
+ overflow: hidden;
35
+ text-overflow: ellipsis;
36
+ white-space: nowrap;
37
+ padding: 1px 6px;
38
+ border-radius: 3px;
39
+ background: var(--hud-accent, #0369a1);
40
+ color: var(--hud-accent-fg, #ffffff);
41
+ font: 600 10px/1.6 var(--hud-mono, ui-monospace, monospace);
42
+ }
43
+ `;
44
+ /**
45
+ * Host pages set `cursor` on buttons and links, which beats an inherited
46
+ * crosshair, so grab mode briefly adds one global rule to the document.
47
+ */
48
+ const CURSOR_RULE = "*, *::before, *::after { cursor: crosshair !important; }";
49
+ /** Pointer events the page must not see while picking: they would focus, submit or navigate. */
50
+ const SWALLOWED = [
51
+ "pointerdown",
52
+ "mousedown",
53
+ "pointerup",
54
+ "mouseup",
55
+ "dblclick"
56
+ ];
57
+ function describeTag(element) {
58
+ return element.tagName.toLowerCase();
59
+ }
60
+ /**
61
+ * Click-to-pick controller: crosshair, hover outline with a label, the next
62
+ * click picks, Escape cancels. Events inside the picker's own shadow host
63
+ * (the HUD's pill and panels) pass through untouched, so panels stay usable
64
+ * and mounted during a grab.
65
+ */
66
+ function ElementPicker({ active, onPick, onCancel, hint = "Click an element · Esc cancels", describe = describeTag, ignore }) {
67
+ const layer = useRef(null);
68
+ const [highlight, setHighlight] = useState(null);
69
+ const props = useRef({
70
+ onPick,
71
+ onCancel,
72
+ describe,
73
+ ignore
74
+ });
75
+ props.current = {
76
+ onPick,
77
+ onCancel,
78
+ describe,
79
+ ignore
80
+ };
81
+ useEffect(() => {
82
+ if (!active) {
83
+ setHighlight(null);
84
+ return;
85
+ }
86
+ const root = layer.current?.getRootNode();
87
+ const ownHost = root instanceof ShadowRoot ? root.host : null;
88
+ const doc = layer.current?.ownerDocument ?? document;
89
+ let hovered = null;
90
+ function target(event) {
91
+ const path = event.composedPath();
92
+ if (ownHost && path.includes(ownHost)) return null;
93
+ const el = path[0];
94
+ if (!(el instanceof Element)) return null;
95
+ if (el === doc.documentElement || el === doc.body) return null;
96
+ return props.current.ignore?.(el) ? null : el;
97
+ }
98
+ function measure() {
99
+ if (!hovered?.isConnected) {
100
+ setHighlight(null);
101
+ return;
102
+ }
103
+ const r = hovered.getBoundingClientRect();
104
+ setHighlight({
105
+ left: r.left,
106
+ top: r.top,
107
+ width: r.width,
108
+ height: r.height,
109
+ label: props.current.describe(hovered)
110
+ });
111
+ }
112
+ function onMove(event) {
113
+ const el = target(event);
114
+ if (el === hovered) return;
115
+ hovered = el;
116
+ measure();
117
+ }
118
+ function swallow(event) {
119
+ if (!target(event)) return;
120
+ event.preventDefault();
121
+ event.stopPropagation();
122
+ }
123
+ function onClick(event) {
124
+ const el = target(event);
125
+ if (!el) return;
126
+ event.preventDefault();
127
+ event.stopPropagation();
128
+ props.current.onPick(el, {
129
+ clientX: event.clientX,
130
+ clientY: event.clientY
131
+ });
132
+ }
133
+ function onKey(event) {
134
+ if (event.key !== "Escape") return;
135
+ event.preventDefault();
136
+ event.stopPropagation();
137
+ props.current.onCancel();
138
+ }
139
+ const cursor = doc.createElement("style");
140
+ cursor.textContent = CURSOR_RULE;
141
+ doc.head.appendChild(cursor);
142
+ const win = doc.defaultView ?? window;
143
+ doc.addEventListener("pointermove", onMove, true);
144
+ doc.addEventListener("click", onClick, true);
145
+ for (const type of SWALLOWED) doc.addEventListener(type, swallow, true);
146
+ win.addEventListener("keydown", onKey, true);
147
+ win.addEventListener("scroll", measure, true);
148
+ win.addEventListener("resize", measure);
149
+ return () => {
150
+ cursor.remove();
151
+ doc.removeEventListener("pointermove", onMove, true);
152
+ doc.removeEventListener("click", onClick, true);
153
+ for (const type of SWALLOWED) doc.removeEventListener(type, swallow, true);
154
+ win.removeEventListener("keydown", onKey, true);
155
+ win.removeEventListener("scroll", measure, true);
156
+ win.removeEventListener("resize", measure);
157
+ setHighlight(null);
158
+ };
159
+ }, [active]);
160
+ return /* @__PURE__ */ jsxs("div", {
161
+ ref: layer,
162
+ className: "grab-layer",
163
+ hidden: !active,
164
+ children: [active && /* @__PURE__ */ jsx("div", {
165
+ className: "grab-hint",
166
+ role: "status",
167
+ children: hint
168
+ }), active && highlight && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("div", {
169
+ className: "grab-outline",
170
+ style: {
171
+ left: highlight.left - 2,
172
+ top: highlight.top - 2,
173
+ width: highlight.width + 4,
174
+ height: highlight.height + 4
175
+ }
176
+ }), /* @__PURE__ */ jsx("div", {
177
+ className: "grab-label",
178
+ style: {
179
+ left: Math.max(highlight.left - 2, 4),
180
+ top: highlight.top >= 22 ? highlight.top - 20 : highlight.top + highlight.height + 4
181
+ },
182
+ children: highlight.label
183
+ })] })]
184
+ });
185
+ }
186
+ //#endregion
187
+ //#region src/shell/shadow-host.tsx
188
+ /**
189
+ * Inline `!important` on the host element: author `!important` in a host
190
+ * stylesheet (`* { position: static !important }`) loses to inline
191
+ * `!important`, so this is the one set of rules a client site cannot override.
192
+ * The host covers the viewport but lets every click through; HUD surfaces opt
193
+ * back in with `pointer-events: auto`.
194
+ */
195
+ const HOST_STYLE = {
196
+ all: "initial",
197
+ position: "fixed",
198
+ inset: "0",
199
+ "z-index": "2147483000",
200
+ "pointer-events": "none",
201
+ display: "block"
202
+ };
203
+ /**
204
+ * Renders children into a shadow root on a host element appended to <body>.
205
+ * Mounted in an effect, so server rendering emits nothing and hydration never
206
+ * differs.
207
+ */
208
+ function ShadowHost({ css, marker, children }) {
209
+ const [mount, setMount] = useState(null);
210
+ useLayoutEffect(() => {
211
+ const host = document.createElement(HUD_HOST_TAG);
212
+ for (const [prop, value] of Object.entries(HOST_STYLE)) host.style.setProperty(prop, value, "important");
213
+ const shadow = host.attachShadow({ mode: "open" });
214
+ const style = document.createElement("style");
215
+ style.textContent = css;
216
+ const root = document.createElement("div");
217
+ root.className = "hud";
218
+ root.setAttribute("data-hud-marker", marker);
219
+ shadow.append(style, root);
220
+ document.body.appendChild(host);
221
+ setMount(root);
222
+ return () => {
223
+ host.remove();
224
+ setMount(null);
225
+ };
226
+ }, [css, marker]);
227
+ return mount ? createPortal(children, mount) : null;
228
+ }
229
+ //#endregion
230
+ //#region src/shell/styles.ts
231
+ /**
232
+ * The HUD's whole stylesheet. It lives inside the shadow root, so host
233
+ * selectors cannot reach in and these rules cannot leak out. Inherited
234
+ * properties (font, color, line-height) still cross the shadow boundary, which
235
+ * is what `all: initial` on the root resets.
236
+ */
237
+ const HUD_CSS = `
238
+ .hud {
239
+ all: initial;
240
+ --hud-bg: #ffffff;
241
+ --hud-bg-subtle: #f4f4f5;
242
+ --hud-fg: #18181b;
243
+ --hud-fg-muted: #71717a;
244
+ --hud-border: rgba(24, 24, 27, 0.14);
245
+ --hud-accent: #0369a1;
246
+ --hud-accent-fg: #ffffff;
247
+ --hud-good: #15803d;
248
+ --hud-warn: #b45309;
249
+ --hud-poor: #b91c1c;
250
+ --hud-shadow: 0 8px 24px rgba(0, 0, 0, 0.12), 0 2px 6px rgba(0, 0, 0, 0.08);
251
+ --hud-radius: 8px;
252
+ --hud-font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
253
+ --hud-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
254
+
255
+ font-family: var(--hud-font);
256
+ font-size: 12px;
257
+ line-height: 1.5;
258
+ color: var(--hud-fg);
259
+ }
260
+
261
+ @media (prefers-color-scheme: dark) {
262
+ .hud {
263
+ --hud-bg: #18181b;
264
+ --hud-bg-subtle: #27272a;
265
+ --hud-fg: #f4f4f5;
266
+ --hud-fg-muted: #a1a1aa;
267
+ --hud-border: rgba(244, 244, 245, 0.16);
268
+ --hud-accent: #38bdf8;
269
+ --hud-accent-fg: #082f49;
270
+ --hud-good: #4ade80;
271
+ --hud-warn: #fbbf24;
272
+ --hud-poor: #f87171;
273
+ --hud-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
274
+ }
275
+ }
276
+
277
+ .hud *, .hud *::before, .hud *::after { box-sizing: border-box; }
278
+
279
+ .hud button {
280
+ font: inherit;
281
+ color: inherit;
282
+ background: none;
283
+ border: 0;
284
+ margin: 0;
285
+ padding: 0;
286
+ cursor: pointer;
287
+ }
288
+
289
+ .hud :focus-visible { outline: 2px solid var(--hud-accent); outline-offset: 1px; }
290
+
291
+ .pill {
292
+ position: fixed;
293
+ left: 12px;
294
+ bottom: 12px;
295
+ display: flex;
296
+ align-items: center;
297
+ gap: 2px;
298
+ padding: 3px;
299
+ background: var(--hud-bg);
300
+ border: 1px solid var(--hud-border);
301
+ border-radius: 999px;
302
+ box-shadow: var(--hud-shadow);
303
+ font-family: var(--hud-mono);
304
+ pointer-events: auto;
305
+ }
306
+
307
+ .hud .pill-item {
308
+ display: flex;
309
+ align-items: center;
310
+ gap: 6px;
311
+ padding: 2px 8px;
312
+ border-radius: 999px;
313
+ white-space: nowrap;
314
+ }
315
+
316
+ .pill-item:hover, .pill-item[aria-pressed="true"] { background: var(--hud-bg-subtle); }
317
+
318
+ .env { font-weight: 600; color: var(--hud-accent); }
319
+
320
+ .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--hud-fg-muted); }
321
+ .dot[data-rating="good"] { background: var(--hud-good); }
322
+ .dot[data-rating="needs-improvement"] { background: var(--hud-warn); }
323
+ .dot[data-rating="poor"] { background: var(--hud-poor); }
324
+
325
+ .vitals { width: 100%; border-collapse: collapse; font-family: var(--hud-mono); }
326
+ .vitals td { padding: 3px 0; }
327
+ .vitals td:last-child { text-align: right; }
328
+ .vitals .label { color: var(--hud-fg-muted); }
329
+ .empty { color: var(--hud-fg-muted); }
330
+ `;
331
+ //#endregion
332
+ export { PICKER_CSS as i, ShadowHost as n, ElementPicker as r, HUD_CSS as t };
@@ -0,0 +1,50 @@
1
+ import { r as GrabPayload, t as ComponentHint } from "./payload-CLKDVZWl.js";
2
+ //#region src/plugins/agent/types.d.ts
3
+ /** Wire types shared by the agent plugins' client and server halves. */
4
+ /** One dispatch target as `GET <apiBase>/agent-dispatch/targets` lists it. */
5
+ interface DispatchTargetInfo {
6
+ id: string;
7
+ label: string;
8
+ description?: string;
9
+ available: boolean;
10
+ /** Why the target is unavailable, e.g. "no Hermes token configured". */
11
+ reason?: string;
12
+ }
13
+ /** `POST <apiBase>/agent-dispatch/dispatch`. Needs a `note`, a `payload`, or both. */
14
+ interface DispatchBody {
15
+ target: string;
16
+ note: string;
17
+ payload: GrabPayload | null;
18
+ /** Component-manifest matches for the payload, see `matchComponentHints`. */
19
+ hints?: ComponentHint[];
20
+ }
21
+ interface DispatchReceipt {
22
+ ok: boolean;
23
+ target: string;
24
+ runId?: string;
25
+ /** Provider detail, e.g. the log file of a local run. */
26
+ detail?: string;
27
+ error?: string;
28
+ }
29
+ type AgentSessionStatus = "queued" | "running" | "idle" | "done" | "failed";
30
+ interface AgentSession {
31
+ /** Provider id, e.g. `claude-code` or `hermes`. */
32
+ provider: string;
33
+ id: string;
34
+ title: string;
35
+ status: AgentSessionStatus;
36
+ startedAt?: string;
37
+ updatedAt?: string;
38
+ detail?: string;
39
+ }
40
+ /** `GET <apiBase>/agent-sessions/sessions`: every provider's sessions, newest first. */
41
+ interface SessionsResponse {
42
+ sessions: AgentSession[];
43
+ /** Providers that failed to answer; the others still list. */
44
+ errors: {
45
+ provider: string;
46
+ error: string;
47
+ }[];
48
+ }
49
+ //#endregion
50
+ export { DispatchTargetInfo as a, DispatchReceipt as i, AgentSessionStatus as n, SessionsResponse as o, DispatchBody as r, AgentSession as t };
@@ -0,0 +1,35 @@
1
+ //#region src/plugins/ops/meta.ts
2
+ const OPS_CAPABILITY = "cicd.read";
3
+ //#endregion
4
+ //#region src/plugins/ops/state.ts
5
+ /**
6
+ * Combines the states of one commit's runs: any failure fails it, anything
7
+ * still going keeps it running, and it only passes when something passed.
8
+ */
9
+ function combineStates(states) {
10
+ if (states.length === 0) return null;
11
+ if (states.includes("failure")) return "failure";
12
+ if (states.includes("running")) return "running";
13
+ if (states.includes("queued")) return "queued";
14
+ if (states.includes("cancelled")) return "cancelled";
15
+ return states.includes("success") ? "success" : "neutral";
16
+ }
17
+ /** Maps a state onto the shell's `.dot[data-rating]` colours. */
18
+ function stateRating(state) {
19
+ switch (state) {
20
+ case "success": return "good";
21
+ case "running":
22
+ case "queued": return "needs-improvement";
23
+ case "failure": return "poor";
24
+ default: return;
25
+ }
26
+ }
27
+ //#endregion
28
+ //#region src/plugins/ops/types.ts
29
+ const TELEMETRY_RANGES = [
30
+ "1h",
31
+ "24h",
32
+ "7d"
33
+ ];
34
+ //#endregion
35
+ export { OPS_CAPABILITY as i, combineStates as n, stateRating as r, TELEMETRY_RANGES as t };
@@ -0,0 +1,92 @@
1
+ //#region src/plugins/ops/types.d.ts
2
+ /**
3
+ * Provider seams of the `ops` plugin and the JSON its routes answer with.
4
+ * Type-only, so the client half can share them without pulling in a provider.
5
+ */
6
+ /** Normalised run outcome, whatever the CI provider calls it. */
7
+ type CiState = "success" | "failure" | "cancelled" | "running" | "queued" | "neutral";
8
+ interface CiRun {
9
+ id: string;
10
+ /** Workflow or pipeline name. */
11
+ name: string;
12
+ /** First line of the commit message, or the run name. */
13
+ title: string;
14
+ state: CiState;
15
+ branch?: string;
16
+ /** Short commit sha. */
17
+ sha?: string;
18
+ url?: string;
19
+ /** ISO timestamp. */
20
+ startedAt?: string;
21
+ }
22
+ /** What is deployed right now, for providers that know. */
23
+ interface CiDeployment {
24
+ /** Environment name, such as `production`. */
25
+ environment?: string;
26
+ /** Deployed image reference, if the deploy is an image. */
27
+ image?: string;
28
+ sha?: string;
29
+ /** Ready replicas as `ready/desired`, or a provider's own status text. */
30
+ ready?: string;
31
+ url?: string;
32
+ /** ISO timestamp. */
33
+ at?: string;
34
+ }
35
+ interface CiSnapshot {
36
+ /** Provider name, such as `github-actions`. */
37
+ provider: string;
38
+ /** False when the provider's env is incomplete; `missing` names what to set. */
39
+ configured: boolean;
40
+ missing?: string;
41
+ /** Repository or project the runs belong to. */
42
+ repo: string;
43
+ /** Branch the runs were filtered by; null when it could not be resolved. */
44
+ branch: string | null;
45
+ /** Combined state of the latest commit's runs on `branch`; null without runs. */
46
+ state: CiState | null;
47
+ /** Short sha of the commit `state` describes. */
48
+ sha: string | null;
49
+ /** Newest first. */
50
+ runs: CiRun[];
51
+ }
52
+ /** A CI provider. Runs server side only; it may read tokens from env. */
53
+ interface CiProvider {
54
+ readonly name: string;
55
+ snapshot(): Promise<CiSnapshot>;
56
+ /** Optional: current deployment. Null when nothing is deployed. */
57
+ deployment?(): Promise<CiDeployment | null>;
58
+ }
59
+ /** `GET <apiBase>/ops/ci` */
60
+ interface CiResponse extends CiSnapshot {
61
+ ok: true;
62
+ /** Present when the provider implements `deployment`. */
63
+ deployment?: CiDeployment | null;
64
+ }
65
+ declare const TELEMETRY_RANGES: readonly ["1h", "24h", "7d"];
66
+ type TelemetryRange = (typeof TELEMETRY_RANGES)[number];
67
+ interface TelemetrySnapshot {
68
+ /** Provider name, such as `signoz`. */
69
+ provider: string;
70
+ /** False when the provider's env is incomplete; `missing` names what to set. */
71
+ configured: boolean;
72
+ missing?: string;
73
+ service: string;
74
+ range: TelemetryRange;
75
+ /** Errored calls per second, latest sample. */
76
+ errorRate: number | null;
77
+ /** 95th percentile latency in ms, latest sample. */
78
+ p95Ms: number | null;
79
+ /** Calls per second, latest sample. */
80
+ requestRate: number | null;
81
+ }
82
+ /** A telemetry provider. Runs server side only; it may read keys from env. */
83
+ interface TelemetryProvider {
84
+ readonly name: string;
85
+ snapshot(range: TelemetryRange): Promise<TelemetrySnapshot>;
86
+ }
87
+ /** `GET <apiBase>/ops/telemetry?range=1h` */
88
+ interface TelemetryResponse extends TelemetrySnapshot {
89
+ ok: true;
90
+ }
91
+ //#endregion
92
+ export { CiSnapshot as a, TelemetryRange as c, CiRun as i, TelemetryResponse as l, CiProvider as n, CiState as o, CiResponse as r, TelemetryProvider as s, CiDeployment as t, TelemetrySnapshot as u };
@@ -0,0 +1,87 @@
1
+ import { useSyncExternalStore } from "react";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { onCLS, onFCP, onINP, onLCP, onTTFB } from "web-vitals";
4
+ //#region src/plugins/vitals/store.ts
5
+ /** Display order: the three Core Web Vitals first, then the diagnostics. */
6
+ const VITAL_ORDER = [
7
+ "LCP",
8
+ "INP",
9
+ "CLS",
10
+ "FCP",
11
+ "TTFB"
12
+ ];
13
+ let snapshot = {};
14
+ const listeners = /* @__PURE__ */ new Set();
15
+ let started = false;
16
+ function record(metric) {
17
+ snapshot = {
18
+ ...snapshot,
19
+ [metric.name]: {
20
+ name: metric.name,
21
+ value: metric.value,
22
+ rating: metric.rating
23
+ }
24
+ };
25
+ for (const listener of listeners) listener();
26
+ }
27
+ /**
28
+ * Starts collection once per page. Called when the HUD mounts, not when the
29
+ * panel opens: LCP and FCP are only reported for the initial load, so a lazy
30
+ * start would miss them.
31
+ */
32
+ function startVitals() {
33
+ if (started) return;
34
+ started = true;
35
+ const opts = { reportAllChanges: true };
36
+ try {
37
+ onLCP(record, opts);
38
+ onINP(record, opts);
39
+ onCLS(record, opts);
40
+ onFCP(record, opts);
41
+ onTTFB(record, opts);
42
+ } catch {}
43
+ }
44
+ function getVitals() {
45
+ return snapshot;
46
+ }
47
+ function subscribeVitals(listener) {
48
+ listeners.add(listener);
49
+ return () => listeners.delete(listener);
50
+ }
51
+ function useVitals() {
52
+ return useSyncExternalStore(subscribeVitals, getVitals, getVitals);
53
+ }
54
+ function formatVital(name, value) {
55
+ if (name === "CLS") return value.toFixed(2);
56
+ if (value >= 1e3) return `${(value / 1e3).toFixed(1)} s`;
57
+ return `${Math.round(value)} ms`;
58
+ }
59
+ //#endregion
60
+ //#region src/plugins/vitals/vitals-panel.tsx
61
+ function VitalsPanel() {
62
+ const vitals = useVitals();
63
+ const rows = VITAL_ORDER.flatMap((name) => {
64
+ const vital = vitals[name];
65
+ return vital ? [vital] : [];
66
+ });
67
+ if (rows.length === 0) return /* @__PURE__ */ jsx("p", {
68
+ className: "empty",
69
+ children: "No metrics yet. LCP reports after the first interaction or tab switch."
70
+ });
71
+ return /* @__PURE__ */ jsx("table", {
72
+ className: "vitals",
73
+ children: /* @__PURE__ */ jsx("tbody", { children: rows.map((vital) => /* @__PURE__ */ jsxs("tr", { children: [/* @__PURE__ */ jsx("td", {
74
+ className: "label",
75
+ children: vital.name
76
+ }), /* @__PURE__ */ jsxs("td", { children: [/* @__PURE__ */ jsx("span", {
77
+ className: "dot",
78
+ "data-rating": vital.rating,
79
+ style: {
80
+ display: "inline-block",
81
+ marginRight: 6
82
+ }
83
+ }), formatVital(vital.name, vital.value)] })] }, vital.name)) })
84
+ });
85
+ }
86
+ //#endregion
87
+ export { useVitals as i, formatVital as n, startVitals as r, VitalsPanel as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hansenexus/hud",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Modular dev HUD for React apps: status pill, floating panels, element grabber, plugin API.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -10,7 +10,8 @@
10
10
  },
11
11
  "workspaces": [
12
12
  ".",
13
- "playground"
13
+ "playground",
14
+ "fixtures/next-app"
14
15
  ],
15
16
  "files": [
16
17
  "dist",
@@ -33,6 +34,18 @@
33
34
  "./plugins/*": {
34
35
  "types": "./dist/plugins/*.d.ts",
35
36
  "import": "./dist/plugins/*.js"
37
+ },
38
+ "./state-boundary": {
39
+ "types": "./dist/state-boundary.d.ts",
40
+ "import": "./dist/state-boundary.js"
41
+ },
42
+ "./review": {
43
+ "types": "./dist/review.d.ts",
44
+ "import": "./dist/review.js"
45
+ },
46
+ "./markers": {
47
+ "types": "./dist/markers.d.ts",
48
+ "import": "./dist/markers.js"
36
49
  }
37
50
  },
38
51
  "sideEffects": false,
@@ -54,7 +67,13 @@
54
67
  },
55
68
  "peerDependencies": {
56
69
  "react": "^19.0.0",
57
- "react-dom": "^19.0.0"
70
+ "react-dom": "^19.0.0",
71
+ "convex": "^1.43.0"
72
+ },
73
+ "peerDependenciesMeta": {
74
+ "convex": {
75
+ "optional": true
76
+ }
58
77
  },
59
78
  "devDependencies": {
60
79
  "@biomejs/biome": "^2.5.15",
@@ -63,6 +82,7 @@
63
82
  "@testing-library/react": "^16.3.3",
64
83
  "@types/react": "^19.3.0",
65
84
  "@types/react-dom": "^19.3.0",
85
+ "convex": "^1.46.0",
66
86
  "jsdom": "^30.1.2",
67
87
  "rolldown": "^1.2.12",
68
88
  "tsdown": "^0.23.0",