@vincentt-xr/harness 1.0.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.
@@ -12,15 +12,60 @@ export interface HarnessProviderProps {
12
12
  * app). Override for the production review-service sink.
13
13
  */
14
14
  relayUrl?: string;
15
- /** Session id grouping this preview run's events. Defaults to a per-load id. */
15
+ /**
16
+ * The key grouping this tab's events into one TESTER. Defaults to a per-tab
17
+ * CSPRNG id in `sessionStorage`, which is what you want; override only for the
18
+ * production review-service path. A value passed here is client-supplied input
19
+ * like any other — the relay validates it and never renders it, and passing
20
+ * another tab's value merges the two into one tester.
21
+ */
16
22
  sessionId?: string;
17
23
  captureConsole?: boolean;
18
24
  captureNetwork?: boolean;
19
25
  captureTrace?: boolean;
20
26
  /**
21
27
  * Mount the floating "Send feedback" button (the reverse-channel capture
22
- * 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.
23
32
  */
24
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;
39
+ }
40
+ /**
41
+ * 128 bits of CSPRNG, hex, as `s2_<32 hex>`.
42
+ *
43
+ * There is deliberately NO Math.random fallback. The previous generator's ~10^6
44
+ * space is exactly what this replaces, and a fallback reintroducing it would be
45
+ * the branch that actually runs on the oldest phone in the room. Both paths here
46
+ * are Web Crypto: randomUUID where it exists, getRandomValues otherwise — and
47
+ * getRandomValues has been in every browser that can run this app for a decade.
48
+ * If neither exists we throw rather than guess, because a weak id is a SILENT
49
+ * merge of two testers' streams, and no id at all is merely un-attributed.
50
+ */
51
+ export declare function generateClientSessionId(): string;
52
+ /** The slice of Storage this needs — so the rule below is testable without a DOM. */
53
+ export interface SessionIdStorage {
54
+ getItem(key: string): string | null;
55
+ setItem(key: string, value: string): void;
25
56
  }
57
+ /**
58
+ * The key the relay groups this tab's events by — one TESTER.
59
+ *
60
+ * `sessionStorage`, not `localStorage`, and the choice is load-bearing on both
61
+ * ends: it is per TAB (so one QR scan is one tester even though the tab opens a
62
+ * second connection for assets) and it DIES WITH THE TAB (so the grouping is
63
+ * bounded by construction rather than by a rule someone has to remember, and
64
+ * cannot outlive the session's terminal window).
65
+ *
66
+ * A stored value that misses the current grammar is OVERWRITTEN, not migrated:
67
+ * it was minted at the entropy this replaces. The rewrite happens here at mount,
68
+ * before the first batch, so no id is ever renumbered mid-run.
69
+ */
70
+ export declare function resolveClientSessionId(storage: SessionIdStorage | undefined): string;
26
71
  export declare function HarnessProvider(props: HarnessProviderProps): ReactNode;
@@ -8,6 +8,7 @@
8
8
  // accident. The relay URL and session id default to what the preview loop sets,
9
9
  // but both are overridable for the production review-service path later.
10
10
  import { useEffect } from "react";
11
+ import { CLIENT_SESSION_ID_RE } from "../shared/events.js";
11
12
  function defaultRelayUrl() {
12
13
  if (typeof window === "undefined")
13
14
  return "ws://localhost:7331";
@@ -16,19 +17,56 @@ function defaultRelayUrl() {
16
17
  // so one cloudflared tunnel carries both. The relay CLI serves this path.
17
18
  return `${proto}://${window.location.host}/__harness`;
18
19
  }
20
+ const SESSION_STORAGE_KEY = "__vincentt_harness_session";
21
+ /**
22
+ * 128 bits of CSPRNG, hex, as `s2_<32 hex>`.
23
+ *
24
+ * There is deliberately NO Math.random fallback. The previous generator's ~10^6
25
+ * space is exactly what this replaces, and a fallback reintroducing it would be
26
+ * the branch that actually runs on the oldest phone in the room. Both paths here
27
+ * are Web Crypto: randomUUID where it exists, getRandomValues otherwise — and
28
+ * getRandomValues has been in every browser that can run this app for a decade.
29
+ * If neither exists we throw rather than guess, because a weak id is a SILENT
30
+ * merge of two testers' streams, and no id at all is merely un-attributed.
31
+ */
32
+ export function generateClientSessionId() {
33
+ const c = globalThis.crypto;
34
+ if (c?.randomUUID)
35
+ return `s2_${c.randomUUID().replace(/-/g, "")}`;
36
+ if (c?.getRandomValues) {
37
+ const bytes = c.getRandomValues(new Uint8Array(16));
38
+ let hex = "";
39
+ for (const b of bytes)
40
+ hex += b.toString(16).padStart(2, "0");
41
+ return `s2_${hex}`;
42
+ }
43
+ throw new Error("harness: Web Crypto unavailable; cannot mint a session id");
44
+ }
45
+ /**
46
+ * The key the relay groups this tab's events by — one TESTER.
47
+ *
48
+ * `sessionStorage`, not `localStorage`, and the choice is load-bearing on both
49
+ * ends: it is per TAB (so one QR scan is one tester even though the tab opens a
50
+ * second connection for assets) and it DIES WITH THE TAB (so the grouping is
51
+ * bounded by construction rather than by a rule someone has to remember, and
52
+ * cannot outlive the session's terminal window).
53
+ *
54
+ * A stored value that misses the current grammar is OVERWRITTEN, not migrated:
55
+ * it was minted at the entropy this replaces. The rewrite happens here at mount,
56
+ * before the first batch, so no id is ever renumbered mid-run.
57
+ */
58
+ export function resolveClientSessionId(storage) {
59
+ const existing = storage?.getItem(SESSION_STORAGE_KEY);
60
+ if (existing && CLIENT_SESSION_ID_RE.test(existing))
61
+ return existing;
62
+ const id = generateClientSessionId();
63
+ storage?.setItem(SESSION_STORAGE_KEY, id);
64
+ return id;
65
+ }
19
66
  function defaultSessionId() {
20
67
  if (typeof window === "undefined")
21
68
  return "server";
22
- const key = "__vincentt_harness_session";
23
- const existing = window.sessionStorage?.getItem(key);
24
- if (existing)
25
- return existing;
26
- // Per-load id; not security-sensitive, just needs to be distinct enough that
27
- // two phones on one relay don't collide. No Math.random dependency in the
28
- // pure layer — this imperative shell may use it.
29
- const id = `s_${Date.now().toString(36)}_${Math.floor(Math.random() * 1e6).toString(36)}`;
30
- window.sessionStorage?.setItem(key, id);
31
- return id;
69
+ return resolveClientSessionId(window.sessionStorage);
32
70
  }
33
71
  const DEV = typeof import.meta !== "undefined" &&
34
72
  import.meta.env?.DEV;
@@ -53,19 +91,40 @@ export function HarnessProvider(props) {
53
91
  });
54
92
  });
55
93
  let unmountFeedback;
56
- 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) {
57
98
  // Separate dynamic import so the capture overlay (and its DOM code) also
58
99
  // drops from a production bundle.
59
100
  void import("./annotate.js").then(({ mountFeedbackButton }) => {
60
101
  if (cancelled)
61
102
  return;
62
- unmountFeedback = mountFeedbackButton({ sessionId: props.sessionId });
103
+ // The SAME resolved key the instrumentation sends, not the bare prop.
104
+ // The prop is undefined for every app that does not set it — i.e. the
105
+ // default — so passing it through left every real annotation
106
+ // un-attributed while the tests, which build the input by hand, never
107
+ // exercised this line.
108
+ unmountFeedback = mountFeedbackButton({
109
+ sessionId: props.sessionId ?? defaultSessionId(),
110
+ });
111
+ });
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({});
63
121
  });
64
122
  }
65
123
  return () => {
66
124
  cancelled = true;
67
125
  teardown?.();
68
126
  unmountFeedback?.();
127
+ unmountShare?.();
69
128
  };
70
129
  // Instrumentation is installed once for the provider's lifetime; option
71
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;