@vincentt-xr/harness 1.1.0 → 1.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.
@@ -25,9 +25,17 @@ export interface HarnessProviderProps {
25
25
  captureTrace?: boolean;
26
26
  /**
27
27
  * Mount the floating "Send feedback" button (the reverse-channel capture
28
- * overlay). Defaults to `enabled` on in preview, gone in production.
28
+ * overlay). Present in code and shares the Share control's cluster mount, but
29
+ * defaults to OFF (`false`) pending a later decision — a creator sees only the
30
+ * Share control today. Re-displaying it is a one-line default flip; the button
31
+ * takes the adjacent flex slot with no layout move.
29
32
  */
30
33
  feedback?: boolean;
34
+ /**
35
+ * Mount the in-app Share control (the desktop-to-phone QR handoff overlay).
36
+ * Defaults to `enabled` — on in preview, tree-shaken out of production.
37
+ */
38
+ share?: boolean;
31
39
  }
32
40
  /**
33
41
  * 128 bits of CSPRNG, hex, as `s2_<32 hex>`.
@@ -91,7 +91,10 @@ export function HarnessProvider(props) {
91
91
  });
92
92
  });
93
93
  let unmountFeedback;
94
- if (props.feedback !== false) {
94
+ // Feedback defaults OFF (opt-in with `feedback={true}`) — the code path stays
95
+ // present so re-enabling it later is a one-line default flip, but a creator
96
+ // sees only the Share control today.
97
+ if (props.feedback === true) {
95
98
  // Separate dynamic import so the capture overlay (and its DOM code) also
96
99
  // drops from a production bundle.
97
100
  void import("./annotate.js").then(({ mountFeedbackButton }) => {
@@ -107,10 +110,21 @@ export function HarnessProvider(props) {
107
110
  });
108
111
  });
109
112
  }
113
+ let unmountShare;
114
+ // Share defaults to `enabled`. Separate dynamic import so the overlay's DOM
115
+ // code and `qrcode` (reached only from here) drop from a production bundle.
116
+ if (props.share !== false) {
117
+ void import("./share.js").then(({ mountShareButton }) => {
118
+ if (cancelled)
119
+ return;
120
+ unmountShare = mountShareButton({});
121
+ });
122
+ }
110
123
  return () => {
111
124
  cancelled = true;
112
125
  teardown?.();
113
126
  unmountFeedback?.();
127
+ unmountShare?.();
114
128
  };
115
129
  // Instrumentation is installed once for the provider's lifetime; option
116
130
  // changes mid-session are not a supported case.
@@ -6,6 +6,7 @@
6
6
  //
7
7
  // Loaded via dynamic import from HarnessProvider (like the instrumentation), so a
8
8
  // production build never ships it. DOM-only, no React.
9
+ import { getClusterContainer, releaseClusterContainer } from "./cluster.js";
9
10
  /** Default relay HTTP base: the same origin the app is served on + the harness path. */
10
11
  function defaultRelayHttpUrl() {
11
12
  return `${window.location.origin}/__harness`;
@@ -58,10 +59,6 @@ export function mountFeedbackButton(opts = {}) {
58
59
  const btn = document.createElement("button");
59
60
  btn.textContent = "Send feedback";
60
61
  Object.assign(btn.style, {
61
- position: "fixed",
62
- right: "12px",
63
- bottom: "12px",
64
- zIndex: "2147483647",
65
62
  padding: "10px 14px",
66
63
  borderRadius: "10px",
67
64
  border: "none",
@@ -99,6 +96,11 @@ export function mountFeedbackButton(opts = {}) {
99
96
  console.error("[harness] annotation send failed:", err);
100
97
  }
101
98
  });
102
- document.body.appendChild(btn);
103
- return () => btn.remove();
99
+ // Shares the top-right cluster with the other bare-DOM controls (Share today);
100
+ // the container positions it, so no fixed-position styles here.
101
+ getClusterContainer().appendChild(btn);
102
+ return () => {
103
+ btn.remove();
104
+ releaseClusterContainer();
105
+ };
104
106
  }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The shared control container — idempotent. Creates one `#vt-cluster` node on the
3
+ * first call and returns the SAME node on every subsequent call. Each call counts
4
+ * as one holder; pair it with exactly one `releaseClusterContainer()` on unmount.
5
+ */
6
+ export declare function getClusterContainer(): HTMLElement;
7
+ /**
8
+ * Release one holder of the container. When the last holder releases (count hits
9
+ * zero) the `#vt-cluster` node is removed from the DOM. Extra releases past zero
10
+ * are a no-op, so an over-release cannot go negative or throw.
11
+ */
12
+ export declare function releaseClusterContainer(): void;
@@ -0,0 +1,51 @@
1
+ // The one shared fixed-position mount for the harness's bare-DOM dev controls.
2
+ // Every floating control (Share today; feedback when re-displayed) appends into
3
+ // this single top-right flex row rather than positioning itself, so controls sit
4
+ // in a stable cluster and a new one takes the adjacent slot with no layout move.
5
+ //
6
+ // DOM-only, no React. Loaded only through the dynamic imports in HarnessProvider,
7
+ // so it never enters a production bundle.
8
+ const CLUSTER_ID = "vt-cluster";
9
+ // Ref-counted so the container is created on the first control's mount and removed
10
+ // only when the last control unmounts. A module-level count is the shared owner:
11
+ // getClusterContainer() increments, releaseClusterContainer() decrements.
12
+ let refCount = 0;
13
+ /**
14
+ * The shared control container — idempotent. Creates one `#vt-cluster` node on the
15
+ * first call and returns the SAME node on every subsequent call. Each call counts
16
+ * as one holder; pair it with exactly one `releaseClusterContainer()` on unmount.
17
+ */
18
+ export function getClusterContainer() {
19
+ const existing = document.getElementById(CLUSTER_ID);
20
+ if (existing) {
21
+ refCount += 1;
22
+ return existing;
23
+ }
24
+ const el = document.createElement("div");
25
+ el.id = CLUSTER_ID;
26
+ Object.assign(el.style, {
27
+ position: "fixed",
28
+ top: "12px",
29
+ right: "12px",
30
+ zIndex: "2147483647",
31
+ display: "flex",
32
+ gap: "8px",
33
+ alignItems: "flex-start",
34
+ });
35
+ document.body.appendChild(el);
36
+ refCount += 1;
37
+ return el;
38
+ }
39
+ /**
40
+ * Release one holder of the container. When the last holder releases (count hits
41
+ * zero) the `#vt-cluster` node is removed from the DOM. Extra releases past zero
42
+ * are a no-op, so an over-release cannot go negative or throw.
43
+ */
44
+ export function releaseClusterContainer() {
45
+ if (refCount === 0)
46
+ return;
47
+ refCount -= 1;
48
+ if (refCount === 0) {
49
+ document.getElementById(CLUSTER_ID)?.remove();
50
+ }
51
+ }
@@ -1,5 +1,7 @@
1
1
  export { HarnessProvider } from "./HarnessProvider.js";
2
2
  export type { HarnessProviderProps } from "./HarnessProvider.js";
3
3
  export { sendAnnotation, captureScreenshot, mountFeedbackButton, type SendAnnotationOptions, type FeedbackButtonOptions, } from "./annotate.js";
4
+ export { mountShareButton, deriveShareUrl, isPhone, isPreviewOrigin, renderQr, PREVIEW_APEXES, type ShareButtonOptions, } from "./share.js";
5
+ export { getClusterContainer, releaseClusterContainer } from "./cluster.js";
4
6
  export type { DiagEvent, LogEvent, NetworkEvent, TraceEvent } from "../shared/events.js";
5
7
  export type { Annotation, AnnotationInput, AnnotationSpec, AnnotationStroke, AnnotationPin, } from "../shared/events.js";
@@ -4,3 +4,5 @@
4
4
  // run-from-bin, not imported.
5
5
  export { HarnessProvider } from "./HarnessProvider.js";
6
6
  export { sendAnnotation, captureScreenshot, mountFeedbackButton, } from "./annotate.js";
7
+ export { mountShareButton, deriveShareUrl, isPhone, isPreviewOrigin, renderQr, PREVIEW_APEXES, } from "./share.js";
8
+ export { getClusterContainer, releaseClusterContainer } from "./cluster.js";
@@ -16,7 +16,15 @@ export function installInstrumentation(opts) {
16
16
  if (w.__vincenttHarness)
17
17
  return () => undefined;
18
18
  w.__vincenttHarness = true;
19
- const socket = openSocket(opts.relayUrl);
19
+ // The hello is the FIRST message on the socket, sent on every open (including a
20
+ // reconnect, which is a fresh connection the relay must re-attribute). It rides
21
+ // the socket directly, NOT the EventBuffer flush: a silent app never flushes, so
22
+ // routing it through the buffer would reintroduce the exact silence-on-connect
23
+ // this exists to kill. The buffer path stays untouched.
24
+ const hello = { type: "hello", sessionId: opts.sessionId };
25
+ const socket = openSocket(opts.relayUrl, () => {
26
+ socket.send(JSON.stringify(hello));
27
+ });
20
28
  const buffer = new EventBuffer({
21
29
  sessionId: opts.sessionId,
22
30
  send: (batch) => {
@@ -44,8 +52,9 @@ export function installInstrumentation(opts) {
44
52
  w.__vincenttHarness = false;
45
53
  };
46
54
  }
47
- /** A tiny reconnecting WebSocket wrapper. */
48
- function openSocket(url) {
55
+ /** A tiny reconnecting WebSocket wrapper. `onOpen` fires on every open (each
56
+ * reconnect is a fresh connection), so the caller can re-send its hello. */
57
+ function openSocket(url, onOpen) {
49
58
  let ws = null;
50
59
  let closed = false;
51
60
  const connect = () => {
@@ -53,6 +62,7 @@ function openSocket(url) {
53
62
  return;
54
63
  try {
55
64
  ws = new WebSocket(url);
65
+ ws.onopen = () => onOpen?.();
56
66
  ws.onclose = () => {
57
67
  ws = null;
58
68
  if (!closed)
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The compiled-in preview-apex allowlist. Previews sit on their own registrable
3
+ * domain, `vincentt.dev` (f3's `D-Preview-is-its-own-registrable-domain`); staging
4
+ * and dev are subdomains of it (`*.staging.vincentt.dev`, `*.local.vincentt.dev`),
5
+ * so a single `.vincentt.dev` suffix matches every environment. The leading dot is a
6
+ * real subdomain-label boundary, so `<label>.vincentt.dev` matches but a bare
7
+ * `vincentt.dev` or a look-alike (`evilvincentt.dev`) does not. This is a public DNS
8
+ * name, not a secret — the same host the viewer's address bar already shows.
9
+ */
10
+ export declare const PREVIEW_APEXES: readonly [".vincentt.dev"];
11
+ /**
12
+ * The canonical preview URL to encode — `origin + "/"`, stable across in-app SPA
13
+ * navigation. Reads ONLY the origin, so a deep path, query-param secret, or hash is
14
+ * never encoded into the QR or the text field.
15
+ */
16
+ export declare function deriveShareUrl(location: Pick<Location, "origin">): string;
17
+ /**
18
+ * Is the viewer a phone? True iff BOTH `(pointer: coarse)` and `(max-width: 820px)`
19
+ * match — a coarse-pointer narrow viewer is the device you would hand off to, so the
20
+ * button is pointless there. A missing or THROWING `matchMedia`, or a viewer we cannot
21
+ * classify, folds to `false` ⇒ the button shows (the "unknown shows" rule: a missing
22
+ * handoff on a desktop is worse than a stray, send-nothing button on an odd device).
23
+ * `matchMedia` is injected so the predicate is pure and testable without a DOM.
24
+ */
25
+ export declare function isPhone(matchMedia?: typeof window.matchMedia): boolean;
26
+ /**
27
+ * Is this a real, phone-reachable preview origin? `https:` AND the hostname ends with
28
+ * a `PREVIEW_APEXES` suffix (`.vincentt.dev`). On the creator's plain local loop
29
+ * (`http://localhost`) or a self-signed LAN origin (`https://192.168.1.20`, `foo.local`)
30
+ * there is no phone-reachable preview link to encode, so the button does not appear.
31
+ */
32
+ export declare function isPreviewOrigin(loc: Pick<Location, "protocol" | "hostname">): boolean;
33
+ /**
34
+ * Render the preview URL as an inline SVG QR. Same params as the CLI's `qrPage.ts`
35
+ * (`margin:2`, ECC `M`) so the on-screen QR is identical to the proven-scannable
36
+ * terminal one.
37
+ */
38
+ export declare function renderQr(url: string): Promise<string>;
39
+ export interface ShareButtonOptions {
40
+ /** Injectable for tests. Defaults to `window.matchMedia`. */
41
+ matchMedia?: typeof window.matchMedia;
42
+ /** Injectable for tests. Defaults to `window.location`. */
43
+ location?: Pick<Location, "origin" | "protocol" | "hostname">;
44
+ }
45
+ /**
46
+ * Mount the icon-only Share chip into the shared cluster and wire its popover.
47
+ *
48
+ * The show-gate is evaluated ONCE, here at mount: render iff the viewer is not a
49
+ * phone AND the origin is a real preview origin. If the gate fails, nothing is
50
+ * appended to the DOM (no disabled state) and the returned unmount is a no-op.
51
+ *
52
+ * Returns an unmount function that removes the chip + popover, tears down listeners,
53
+ * and releases the shared cluster.
54
+ */
55
+ export declare function mountShareButton(opts?: ShareButtonOptions): () => void;
@@ -0,0 +1,318 @@
1
+ // The in-app Share control — the creator's desktop-to-phone handoff, rendered
2
+ // inside the running preview. An icon-only QR-glyph chip in the shared top-right
3
+ // cluster; clicking it drops a popover with an inline SVG QR of the preview origin,
4
+ // a heading + instruction, and the URL as selectable text (no Copy button).
5
+ //
6
+ // It re-presents ONLY `window.location.origin + "/"` — the capability URL the
7
+ // link-holder already has (B17). It makes NO network call and sends nothing to the
8
+ // relay (B19): pure client-side rendering of a string already in the page.
9
+ //
10
+ // DOM-only, no React. Loaded through the dynamic import in HarnessProvider, so the
11
+ // whole module — and `qrcode`, reached only from here — tree-shakes out of prod.
12
+ import QRCode from "qrcode";
13
+ import { getClusterContainer, releaseClusterContainer } from "./cluster.js";
14
+ /**
15
+ * The compiled-in preview-apex allowlist. Previews sit on their own registrable
16
+ * domain, `vincentt.dev` (f3's `D-Preview-is-its-own-registrable-domain`); staging
17
+ * and dev are subdomains of it (`*.staging.vincentt.dev`, `*.local.vincentt.dev`),
18
+ * so a single `.vincentt.dev` suffix matches every environment. The leading dot is a
19
+ * real subdomain-label boundary, so `<label>.vincentt.dev` matches but a bare
20
+ * `vincentt.dev` or a look-alike (`evilvincentt.dev`) does not. This is a public DNS
21
+ * name, not a secret — the same host the viewer's address bar already shows.
22
+ */
23
+ export const PREVIEW_APEXES = [".vincentt.dev"];
24
+ /**
25
+ * The canonical preview URL to encode — `origin + "/"`, stable across in-app SPA
26
+ * navigation. Reads ONLY the origin, so a deep path, query-param secret, or hash is
27
+ * never encoded into the QR or the text field.
28
+ */
29
+ export function deriveShareUrl(location) {
30
+ return location.origin + "/";
31
+ }
32
+ /**
33
+ * Is the viewer a phone? True iff BOTH `(pointer: coarse)` and `(max-width: 820px)`
34
+ * match — a coarse-pointer narrow viewer is the device you would hand off to, so the
35
+ * button is pointless there. A missing or THROWING `matchMedia`, or a viewer we cannot
36
+ * classify, folds to `false` ⇒ the button shows (the "unknown shows" rule: a missing
37
+ * handoff on a desktop is worse than a stray, send-nothing button on an odd device).
38
+ * `matchMedia` is injected so the predicate is pure and testable without a DOM.
39
+ */
40
+ export function isPhone(matchMedia) {
41
+ if (typeof matchMedia !== "function")
42
+ return false;
43
+ try {
44
+ return (matchMedia("(pointer: coarse)").matches && matchMedia("(max-width: 820px)").matches);
45
+ }
46
+ catch {
47
+ // A throwing matchMedia is "unknown", never an error to propagate ⇒ show.
48
+ return false;
49
+ }
50
+ }
51
+ /**
52
+ * Is this a real, phone-reachable preview origin? `https:` AND the hostname ends with
53
+ * a `PREVIEW_APEXES` suffix (`.vincentt.dev`). On the creator's plain local loop
54
+ * (`http://localhost`) or a self-signed LAN origin (`https://192.168.1.20`, `foo.local`)
55
+ * there is no phone-reachable preview link to encode, so the button does not appear.
56
+ */
57
+ export function isPreviewOrigin(loc) {
58
+ return (loc.protocol === "https:" &&
59
+ PREVIEW_APEXES.some((apex) => loc.hostname.endsWith(apex)));
60
+ }
61
+ /**
62
+ * Render the preview URL as an inline SVG QR. Same params as the CLI's `qrPage.ts`
63
+ * (`margin:2`, ECC `M`) so the on-screen QR is identical to the proven-scannable
64
+ * terminal one.
65
+ */
66
+ export function renderQr(url) {
67
+ return QRCode.toString(url, {
68
+ type: "svg",
69
+ margin: 2,
70
+ errorCorrectionLevel: "M",
71
+ });
72
+ }
73
+ const HEADING = "Open on your phone";
74
+ const INSTRUCTION = "Point your phone's camera at the code, or type in the link below.";
75
+ // A single QR-code glyph (three finder squares + a scattering of modules), ~18px,
76
+ // `currentColor` so it inherits the chip's `#f5f2ef`. Chosen over a share arrow
77
+ // because a QR square most directly says "get this onto a phone".
78
+ const QR_GLYPH_SVG = `<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false"><path d="M3 3h8v8H3V3Zm2 2v4h4V5H5Zm-2 8h8v8H3v-8Zm2 2v4h4v-4H5ZM13 3h8v8h-8V3Zm2 2v4h4V5h-4Zm-2 8h2v2h-2v-2Zm2 2h2v2h-2v-2Zm2-2h2v2h-2v-2Zm0 4h2v2h-2v-2Zm2-2h2v2h-2v-2Zm0 4h2v2h-2v-2Zm-4 0h2v2h-2v-2Z"/></svg>`;
79
+ /**
80
+ * Mount the icon-only Share chip into the shared cluster and wire its popover.
81
+ *
82
+ * The show-gate is evaluated ONCE, here at mount: render iff the viewer is not a
83
+ * phone AND the origin is a real preview origin. If the gate fails, nothing is
84
+ * appended to the DOM (no disabled state) and the returned unmount is a no-op.
85
+ *
86
+ * Returns an unmount function that removes the chip + popover, tears down listeners,
87
+ * and releases the shared cluster.
88
+ */
89
+ export function mountShareButton(opts = {}) {
90
+ if (typeof document === "undefined")
91
+ return () => undefined;
92
+ const matchMedia = opts.matchMedia ?? (typeof window !== "undefined" ? window.matchMedia : undefined);
93
+ const location = opts.location ?? (typeof window !== "undefined" ? window.location : undefined);
94
+ if (!location)
95
+ return () => undefined;
96
+ const show = !isPhone(matchMedia) && isPreviewOrigin(location);
97
+ if (!show)
98
+ return () => undefined;
99
+ const url = deriveShareUrl(location);
100
+ const container = getClusterContainer();
101
+ let released = false;
102
+ const btn = document.createElement("button");
103
+ btn.type = "button";
104
+ btn.setAttribute("aria-label", "Share preview");
105
+ btn.title = "Share preview";
106
+ btn.setAttribute("aria-haspopup", "dialog");
107
+ btn.setAttribute("aria-controls", "vt-popover");
108
+ btn.setAttribute("aria-expanded", "false");
109
+ btn.innerHTML = QR_GLYPH_SVG;
110
+ Object.assign(btn.style, {
111
+ width: "36px",
112
+ height: "36px",
113
+ padding: "0",
114
+ display: "inline-flex",
115
+ alignItems: "center",
116
+ justifyContent: "center",
117
+ borderRadius: "10px",
118
+ border: "1px solid rgba(255,255,255,.14)",
119
+ background: "rgba(28,27,26,.92)",
120
+ color: "#f5f2ef",
121
+ boxShadow: "0 1px 4px rgba(0,0,0,.5)",
122
+ cursor: "pointer",
123
+ transition: "background 100ms, border-color 100ms",
124
+ });
125
+ const IDLE_BG = "rgba(28,27,26,.92)";
126
+ const IDLE_BORDER = "rgba(255,255,255,.14)";
127
+ const HOVER_BG = "rgba(40,38,36,.95)";
128
+ const HOVER_BORDER = "rgba(255,255,255,.24)";
129
+ const ACTIVE_BG = "rgba(40,38,36,.95)";
130
+ const ACTIVE_BORDER = "rgba(147,184,240,.5)";
131
+ const applyIdle = () => {
132
+ btn.style.background = IDLE_BG;
133
+ btn.style.borderColor = IDLE_BORDER;
134
+ };
135
+ const applyActive = () => {
136
+ btn.style.background = ACTIVE_BG;
137
+ btn.style.borderColor = ACTIVE_BORDER;
138
+ };
139
+ btn.addEventListener("mouseenter", () => {
140
+ if (popover)
141
+ return; // active state wins over hover
142
+ btn.style.background = HOVER_BG;
143
+ btn.style.borderColor = HOVER_BORDER;
144
+ });
145
+ btn.addEventListener("mouseleave", () => {
146
+ if (popover)
147
+ return;
148
+ applyIdle();
149
+ });
150
+ // Focus ring: a brand-blue outline that reads on any background. Set inline on
151
+ // focus/blur since there is no stylesheet on this surface for :focus-visible.
152
+ btn.addEventListener("focus", () => {
153
+ btn.style.outline = "2px solid #93b8f0";
154
+ btn.style.outlineOffset = "2px";
155
+ });
156
+ btn.addEventListener("blur", () => {
157
+ btn.style.outline = "none";
158
+ });
159
+ let popover = null;
160
+ let outsideHandler = null;
161
+ let keyHandler = null;
162
+ const prefersReducedMotion = typeof matchMedia === "function"
163
+ ? (() => {
164
+ try {
165
+ return matchMedia("(prefers-reduced-motion: reduce)").matches;
166
+ }
167
+ catch {
168
+ return false;
169
+ }
170
+ })()
171
+ : false;
172
+ const closePopover = (returnFocus) => {
173
+ if (!popover)
174
+ return;
175
+ popover.remove();
176
+ popover = null;
177
+ if (outsideHandler) {
178
+ document.removeEventListener("mousedown", outsideHandler, true);
179
+ outsideHandler = null;
180
+ }
181
+ if (keyHandler) {
182
+ document.removeEventListener("keydown", keyHandler);
183
+ keyHandler = null;
184
+ }
185
+ btn.setAttribute("aria-expanded", "false");
186
+ applyIdle();
187
+ if (returnFocus)
188
+ btn.focus();
189
+ };
190
+ const buildPopover = () => {
191
+ const pop = document.createElement("div");
192
+ pop.id = "vt-popover";
193
+ pop.setAttribute("role", "dialog");
194
+ pop.setAttribute("aria-label", "Open this preview on your phone");
195
+ Object.assign(pop.style, {
196
+ position: "fixed",
197
+ top: "56px",
198
+ right: "12px",
199
+ zIndex: "2147483647",
200
+ width: "260px",
201
+ padding: "16px",
202
+ borderRadius: "12px",
203
+ border: "1px solid rgba(255,255,255,.14)",
204
+ background: "rgba(23,22,21,.86)",
205
+ backdropFilter: "blur(16px) saturate(1.1)",
206
+ // @ts-expect-error vendor-prefixed for Safari; not in the typed CSSStyleDeclaration
207
+ WebkitBackdropFilter: "blur(16px) saturate(1.1)",
208
+ boxShadow: "0 12px 32px rgba(0,0,0,.45)",
209
+ boxSizing: "border-box",
210
+ transformOrigin: "top right",
211
+ });
212
+ const qrTile = document.createElement("div");
213
+ qrTile.className = "vt-qr";
214
+ Object.assign(qrTile.style, {
215
+ width: "100%",
216
+ aspectRatio: "1 / 1",
217
+ background: "#fff",
218
+ padding: "8px",
219
+ borderRadius: "8px",
220
+ border: "1px solid rgba(0,0,0,.06)",
221
+ boxSizing: "border-box",
222
+ });
223
+ // Async QR render; the tile holds its space via aspect-ratio until it lands.
224
+ void renderQr(url).then((svg) => {
225
+ // Guard against a close-before-resolve race.
226
+ if (qrTile.isConnected)
227
+ qrTile.innerHTML = svg;
228
+ });
229
+ const heading = document.createElement("div");
230
+ heading.className = "vt-heading";
231
+ heading.textContent = HEADING;
232
+ Object.assign(heading.style, {
233
+ font: "600 14px/1.35 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif",
234
+ color: "#f5f2ef",
235
+ margin: "12px 0 2px",
236
+ });
237
+ const instr = document.createElement("div");
238
+ instr.className = "vt-instr";
239
+ instr.textContent = INSTRUCTION;
240
+ Object.assign(instr.style, {
241
+ font: "400 13px/1.45 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif",
242
+ color: "#b8b2a9",
243
+ margin: "0 0 10px",
244
+ });
245
+ const urlField = document.createElement("div");
246
+ urlField.className = "vt-url";
247
+ urlField.textContent = url;
248
+ Object.assign(urlField.style, {
249
+ font: "500 12px/1.5 ui-monospace,monospace",
250
+ color: "#93b8f0",
251
+ background: "rgba(255,255,255,.06)",
252
+ border: "1px solid rgba(255,255,255,.10)",
253
+ borderRadius: "8px",
254
+ padding: "8px 10px",
255
+ wordBreak: "break-all",
256
+ cursor: "text",
257
+ userSelect: "all",
258
+ // @ts-expect-error vendor-prefixed for Safari
259
+ WebkitUserSelect: "all",
260
+ boxSizing: "border-box",
261
+ });
262
+ pop.append(qrTile, heading, instr, urlField);
263
+ if (!prefersReducedMotion) {
264
+ pop.style.opacity = "0";
265
+ pop.style.transform = "translateY(-4px) scale(.98)";
266
+ pop.style.transition =
267
+ "opacity 160ms cubic-bezier(0.16,1,0.3,1), transform 160ms cubic-bezier(0.16,1,0.3,1)";
268
+ // Next frame: animate to the resting state.
269
+ requestAnimationFrame(() => {
270
+ pop.style.opacity = "1";
271
+ pop.style.transform = "translateY(0) scale(1)";
272
+ });
273
+ }
274
+ return pop;
275
+ };
276
+ const openPopover = () => {
277
+ if (popover)
278
+ return;
279
+ popover = buildPopover();
280
+ container.appendChild(popover);
281
+ btn.setAttribute("aria-expanded", "true");
282
+ applyActive();
283
+ // Click-outside: a capture-phase mousedown outside both popover and button
284
+ // closes it. Registered only while open, removed on close.
285
+ outsideHandler = (e) => {
286
+ const target = e.target;
287
+ if (!target)
288
+ return;
289
+ if (popover && (popover.contains(target) || btn.contains(target)))
290
+ return;
291
+ closePopover(false);
292
+ };
293
+ document.addEventListener("mousedown", outsideHandler, true);
294
+ // Escape closes and returns focus to the Share button.
295
+ keyHandler = (e) => {
296
+ if (e.key === "Escape") {
297
+ e.stopPropagation();
298
+ closePopover(true);
299
+ }
300
+ };
301
+ document.addEventListener("keydown", keyHandler);
302
+ };
303
+ btn.addEventListener("click", () => {
304
+ if (popover)
305
+ closePopover(false);
306
+ else
307
+ openPopover();
308
+ });
309
+ container.appendChild(btn);
310
+ return () => {
311
+ closePopover(false);
312
+ btn.remove();
313
+ if (!released) {
314
+ released = true;
315
+ releaseClusterContainer();
316
+ }
317
+ };
318
+ }
@@ -85,6 +85,27 @@ export interface ClientToRelay {
85
85
  sessionId: string;
86
86
  events: DiagEvent[];
87
87
  }
88
+ /**
89
+ * The FIRST message on the diagnostics WS, sent at WS-open before HarnessProvider
90
+ * mounts and independent of the EventBuffer having pending events. It carries the
91
+ * same `sessionStorage` key the events batch carries, so the relay can resolve the
92
+ * TESTER on connect rather than waiting for a batch — which is why a SILENT app
93
+ * (no console/network/trace events) and an app that crashes during mount are both
94
+ * attributed instead of reading as an absence.
95
+ *
96
+ * It is a relay-layer message in the `{type:"events"}` family, NOT a tunnel
97
+ * control frame: the edge splices the tunnel as opaque bytes and never reads it.
98
+ * The `sessionId` is grammar-guarded by the relay against CLIENT_SESSION_ID_RE
99
+ * with no sanitize-and-keep branch, exactly as the batch key is — a malformed key
100
+ * is ingested un-keyed, never a crash.
101
+ */
102
+ export interface ClientHello {
103
+ type: "hello";
104
+ /** The client's `sessionStorage` key — same value the events batch carries. */
105
+ sessionId: string;
106
+ }
107
+ /** Every message the client sends up the diagnostics WS. Discriminated on `type`. */
108
+ export type ClientMessage = ClientToRelay | ClientHello;
88
109
  /** What the MCP server asks the relay for. `since` is an exclusive seq cursor. */
89
110
  export interface RelayQuery {
90
111
  kind?: DiagEventKind;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincentt-xr/harness",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Vincentt AR dev-loop harness - in-app diagnostics provider + wire contract",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -31,10 +31,13 @@
31
31
  "devDependencies": {
32
32
  "@types/react": "^18.3.12",
33
33
  "@types/ws": "^8.5.13",
34
- "@types/node": "^22.9.0"
34
+ "@types/node": "^22.9.0",
35
+ "@types/qrcode": "^1.5.5",
36
+ "jsdom": "^25.0.1"
35
37
  },
36
38
  "dependencies": {
37
- "ws": "^8.18.0"
39
+ "ws": "^8.18.0",
40
+ "qrcode": "^1.5.4"
38
41
  },
39
42
  "scripts": {
40
43
  "build": "tsc -p tsconfig.build.json",