@hansenexus/hud 0.1.1 → 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.
- package/README.md +42 -23
- package/dist/index.d.ts +39 -0
- package/dist/index.js +399 -0
- package/package.json +45 -136
- package/chunk-20zke7cs.js +0 -574
- package/chunk-24vrg16w.js +0 -39
- package/chunk-50pq4sg6.js +0 -41
- package/chunk-5gtx3pza.js +0 -9
- package/chunk-6cdpasm9.js +0 -79
- package/chunk-6jt8yg4m.js +0 -117
- package/chunk-6ngkpwz7.js +0 -127
- package/chunk-71se605m.js +0 -85
- package/chunk-82nm9wcg.js +0 -316
- package/chunk-9hw5r9y1.js +0 -67
- package/chunk-c5y856ad.js +0 -98
- package/chunk-edq4jqch.js +0 -89
- package/chunk-emfnvz4p.js +0 -166
- package/chunk-h0hmjdqa.js +0 -264
- package/chunk-hc2g0n35.js +0 -24
- package/chunk-hex3wqag.js +0 -179
- package/chunk-kvxve0hx.js +0 -140
- package/chunk-kyeb6qeq.js +0 -95
- package/chunk-mz55rvw3.js +0 -172
- package/chunk-nm82wr8r.js +0 -58
- package/chunk-q15xwpy8.js +0 -48
- package/chunk-q96mkjve.js +0 -68
- package/chunk-tczzx083.js +0 -26
- package/chunk-tf774c4w.js +0 -115
- package/chunk-v086j53b.js +0 -19
- package/chunk-vjh0d29g.js +0 -52
- package/chunk-xezxydm3.js +0 -47
- package/chunk-xztw0xyt.js +0 -113
- package/chunk-yn1d38pb.js +0 -172
- package/core/access.d.mts +0 -53
- package/core/access.js +0 -27
- package/core/api-types.d.mts +0 -146
- package/core/api-types.js +0 -9
- package/core/capabilities.d.mts +0 -27
- package/core/capabilities.js +0 -14
- package/core/capture-buffer.d.mts +0 -37
- package/core/capture-buffer.js +0 -9
- package/core/force-state.d.mts +0 -68
- package/core/force-state.js +0 -21
- package/core/mock-cookie.d.mts +0 -30
- package/core/mock-cookie.js +0 -15
- package/core/types.d.mts +0 -59
- package/core/types.js +0 -9
- package/hud-provider.d.mts +0 -38
- package/hud-provider.js +0 -15
- package/hud.d.mts +0 -15
- package/hud.js +0 -18
- package/index.d.mts +0 -94
- package/index.js +0 -170
- package/lazy-panel.d.mts +0 -28
- package/lazy-panel.js +0 -11
- package/panels/cicd-panel.d.mts +0 -9
- package/panels/cicd-panel.js +0 -13
- package/panels/debug-info-panel.d.mts +0 -21
- package/panels/debug-info-panel.js +0 -9
- package/panels/dispatch-panel.d.mts +0 -10
- package/panels/dispatch-panel.js +0 -14
- package/panels/env-info-panel.d.mts +0 -9
- package/panels/env-info-panel.js +0 -12
- package/panels/feature-flags-panel.d.mts +0 -26
- package/panels/feature-flags-panel.js +0 -9
- package/panels/force-state-panel.d.mts +0 -11
- package/panels/force-state-panel.js +0 -113
- package/panels/info-row.d.mts +0 -21
- package/panels/info-row.js +0 -14
- package/panels/mock-seed-panel.d.mts +0 -15
- package/panels/mock-seed-panel.js +0 -14
- package/panels/network-console-panel.d.mts +0 -6
- package/panels/network-console-panel.js +0 -9
- package/panels/role-switcher-panel.d.mts +0 -28
- package/panels/role-switcher-panel.js +0 -9
- package/panels/sessions-panel.d.mts +0 -6
- package/panels/sessions-panel.js +0 -13
- package/panels/tokens-panel.d.mts +0 -38
- package/panels/tokens-panel.js +0 -9
- package/panels/unlock-panel.d.mts +0 -33
- package/panels/unlock-panel.js +0 -14
- package/panels/variants-panel.d.mts +0 -51
- package/panels/variants-panel.js +0 -12
- package/panels/vitals-panel.d.mts +0 -11
- package/panels/vitals-panel.js +0 -15
- package/server/guard.d.mts +0 -42
- package/server/guard.js +0 -12
- package/server/unlock.d.mts +0 -28
- package/server/unlock.js +0 -108
- package/state-boundary.d.mts +0 -25
- 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
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
25
|
+
bun add -d @hansenexus/hud # or npm i -D @hansenexus/hud
|
|
11
26
|
```
|
|
12
27
|
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
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
|
|
37
|
+
return <Hud appSlug="my-app" />;
|
|
29
38
|
}
|
|
30
39
|
```
|
|
31
40
|
|
|
32
|
-
Mount it only in development builds,
|
|
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
|
-
##
|
|
45
|
+
## Release
|
|
35
46
|
|
|
36
|
-
|
|
37
|
-
the package
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
|
package/dist/index.d.ts
ADDED
|
@@ -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 };
|