@vincentt-xr/harness 1.1.0 → 1.3.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/dist/client/HarnessProvider.d.ts +9 -2
- package/dist/client/HarnessProvider.js +36 -3
- package/dist/client/annotate.d.ts +75 -6
- package/dist/client/annotate.js +432 -34
- package/dist/client/cluster.d.ts +12 -0
- package/dist/client/cluster.js +51 -0
- package/dist/client/index.d.ts +4 -1
- package/dist/client/index.js +4 -1
- package/dist/client/instrument.js +13 -3
- package/dist/client/previewOrigin.d.ts +50 -0
- package/dist/client/previewOrigin.js +76 -0
- package/dist/client/share.d.ts +30 -0
- package/dist/client/share.js +285 -0
- package/dist/shared/events.d.ts +60 -0
- package/package.json +6 -3
|
@@ -0,0 +1,50 @@
|
|
|
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
|
+
* Is the viewer a phone? True iff BOTH `(pointer: coarse)` and `(max-width: 820px)`
|
|
13
|
+
* match — a coarse-pointer narrow viewer is the device you would hand off to, so the
|
|
14
|
+
* Share button is pointless there. A missing or THROWING `matchMedia`, or a viewer we
|
|
15
|
+
* cannot classify, folds to `false` ⇒ the button shows (the "unknown shows" rule: a
|
|
16
|
+
* missing handoff on a desktop is worse than a stray, send-nothing button on an odd
|
|
17
|
+
* device). `matchMedia` is injected so the predicate is pure and testable without a DOM.
|
|
18
|
+
*/
|
|
19
|
+
export declare function isPhone(matchMedia?: typeof window.matchMedia): boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Is this a real, phone-reachable preview origin? `https:` AND the hostname ends with
|
|
22
|
+
* a `PREVIEW_APEXES` suffix (`.vincentt.dev`). On the creator's plain local loop
|
|
23
|
+
* (`http://localhost`) or a self-signed LAN origin (`https://192.168.1.20`, `foo.local`)
|
|
24
|
+
* there is no phone-reachable preview link to encode and no relay a phone can POST to,
|
|
25
|
+
* so neither cluster control appears.
|
|
26
|
+
*/
|
|
27
|
+
export declare function isPreviewOrigin(loc: Pick<Location, "protocol" | "hostname">): boolean;
|
|
28
|
+
/** The two window handles the framed check compares. Injected so it is testable. */
|
|
29
|
+
export interface FramingView {
|
|
30
|
+
self: unknown;
|
|
31
|
+
top: unknown;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Is this document running inside a frame? `window.self !== window.top`.
|
|
35
|
+
*
|
|
36
|
+
* A fact the app reads about ITSELF — no sender, no parameter, no message, no
|
|
37
|
+
* channel from the embedder. It is also true inside ANY frame, which is correct:
|
|
38
|
+
* the reason a control is withheld holds for any embedder, not just ours.
|
|
39
|
+
*
|
|
40
|
+
* Cross-origin does not break it. Reading `window.top` as an opaque handle and
|
|
41
|
+
* comparing references is same-origin-policy-safe; only reaching THROUGH it
|
|
42
|
+
* (`top.location`, `top.document`) throws. Nothing here dereferences it.
|
|
43
|
+
*
|
|
44
|
+
* A missing or throwing `window` folds to `false` ⇒ NOT framed. That direction is
|
|
45
|
+
* deliberate and is the only one that is safe here: it means an unknown context
|
|
46
|
+
* behaves like today's shipped top-level case rather than silently withholding a
|
|
47
|
+
* control on the creator's own phone. The framed case this exists for is a real
|
|
48
|
+
* browser with a real `window`, where the comparison is exact.
|
|
49
|
+
*/
|
|
50
|
+
export declare function isFramed(view?: FramingView): boolean;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// The viewer/origin predicates the bare-DOM cluster controls gate on. A LEAF
|
|
2
|
+
// module: no imports, no dependencies.
|
|
3
|
+
//
|
|
4
|
+
// It exists as its own file so a control can ask "is this a real preview origin?"
|
|
5
|
+
// without importing `share.ts`, which statically pulls in `qrcode`. Both overlays
|
|
6
|
+
// sit behind guarded dynamic imports, so a prod fold drops either one — but with
|
|
7
|
+
// the predicates living in share.ts, mounting only the FEEDBACK chip would still
|
|
8
|
+
// drag the QR encoder into its chunk for a 40-line string check.
|
|
9
|
+
/**
|
|
10
|
+
* The compiled-in preview-apex allowlist. Previews sit on their own registrable
|
|
11
|
+
* domain, `vincentt.dev` (f3's `D-Preview-is-its-own-registrable-domain`); staging
|
|
12
|
+
* and dev are subdomains of it (`*.staging.vincentt.dev`, `*.local.vincentt.dev`),
|
|
13
|
+
* so a single `.vincentt.dev` suffix matches every environment. The leading dot is a
|
|
14
|
+
* real subdomain-label boundary, so `<label>.vincentt.dev` matches but a bare
|
|
15
|
+
* `vincentt.dev` or a look-alike (`evilvincentt.dev`) does not. This is a public DNS
|
|
16
|
+
* name, not a secret — the same host the viewer's address bar already shows.
|
|
17
|
+
*/
|
|
18
|
+
export const PREVIEW_APEXES = [".vincentt.dev"];
|
|
19
|
+
/**
|
|
20
|
+
* Is the viewer a phone? True iff BOTH `(pointer: coarse)` and `(max-width: 820px)`
|
|
21
|
+
* match — a coarse-pointer narrow viewer is the device you would hand off to, so the
|
|
22
|
+
* Share button is pointless there. A missing or THROWING `matchMedia`, or a viewer we
|
|
23
|
+
* cannot classify, folds to `false` ⇒ the button shows (the "unknown shows" rule: a
|
|
24
|
+
* missing handoff on a desktop is worse than a stray, send-nothing button on an odd
|
|
25
|
+
* device). `matchMedia` is injected so the predicate is pure and testable without a DOM.
|
|
26
|
+
*/
|
|
27
|
+
export function isPhone(matchMedia) {
|
|
28
|
+
if (typeof matchMedia !== "function")
|
|
29
|
+
return false;
|
|
30
|
+
try {
|
|
31
|
+
return (matchMedia("(pointer: coarse)").matches && matchMedia("(max-width: 820px)").matches);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// A throwing matchMedia is "unknown", never an error to propagate ⇒ show.
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Is this a real, phone-reachable preview origin? `https:` AND the hostname ends with
|
|
40
|
+
* a `PREVIEW_APEXES` suffix (`.vincentt.dev`). On the creator's plain local loop
|
|
41
|
+
* (`http://localhost`) or a self-signed LAN origin (`https://192.168.1.20`, `foo.local`)
|
|
42
|
+
* there is no phone-reachable preview link to encode and no relay a phone can POST to,
|
|
43
|
+
* so neither cluster control appears.
|
|
44
|
+
*/
|
|
45
|
+
export function isPreviewOrigin(loc) {
|
|
46
|
+
return (loc.protocol === "https:" &&
|
|
47
|
+
PREVIEW_APEXES.some((apex) => loc.hostname.endsWith(apex)));
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Is this document running inside a frame? `window.self !== window.top`.
|
|
51
|
+
*
|
|
52
|
+
* A fact the app reads about ITSELF — no sender, no parameter, no message, no
|
|
53
|
+
* channel from the embedder. It is also true inside ANY frame, which is correct:
|
|
54
|
+
* the reason a control is withheld holds for any embedder, not just ours.
|
|
55
|
+
*
|
|
56
|
+
* Cross-origin does not break it. Reading `window.top` as an opaque handle and
|
|
57
|
+
* comparing references is same-origin-policy-safe; only reaching THROUGH it
|
|
58
|
+
* (`top.location`, `top.document`) throws. Nothing here dereferences it.
|
|
59
|
+
*
|
|
60
|
+
* A missing or throwing `window` folds to `false` ⇒ NOT framed. That direction is
|
|
61
|
+
* deliberate and is the only one that is safe here: it means an unknown context
|
|
62
|
+
* behaves like today's shipped top-level case rather than silently withholding a
|
|
63
|
+
* control on the creator's own phone. The framed case this exists for is a real
|
|
64
|
+
* browser with a real `window`, where the comparison is exact.
|
|
65
|
+
*/
|
|
66
|
+
export function isFramed(view) {
|
|
67
|
+
const w = view ?? (typeof window === "undefined" ? undefined : window);
|
|
68
|
+
if (!w)
|
|
69
|
+
return false;
|
|
70
|
+
try {
|
|
71
|
+
return w.self !== w.top;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export { isPhone, isPreviewOrigin, PREVIEW_APEXES } from "./previewOrigin.js";
|
|
2
|
+
/**
|
|
3
|
+
* The canonical preview URL to encode — `origin + "/"`, stable across in-app SPA
|
|
4
|
+
* navigation. Reads ONLY the origin, so a deep path, query-param secret, or hash is
|
|
5
|
+
* never encoded into the QR or the text field.
|
|
6
|
+
*/
|
|
7
|
+
export declare function deriveShareUrl(location: Pick<Location, "origin">): string;
|
|
8
|
+
/**
|
|
9
|
+
* Render the preview URL as an inline SVG QR. Same params as the CLI's `qrPage.ts`
|
|
10
|
+
* (`margin:2`, ECC `M`) so the on-screen QR is identical to the proven-scannable
|
|
11
|
+
* terminal one.
|
|
12
|
+
*/
|
|
13
|
+
export declare function renderQr(url: string): Promise<string>;
|
|
14
|
+
export interface ShareButtonOptions {
|
|
15
|
+
/** Injectable for tests. Defaults to `window.matchMedia`. */
|
|
16
|
+
matchMedia?: typeof window.matchMedia;
|
|
17
|
+
/** Injectable for tests. Defaults to `window.location`. */
|
|
18
|
+
location?: Pick<Location, "origin" | "protocol" | "hostname">;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Mount the icon-only Share chip into the shared cluster and wire its popover.
|
|
22
|
+
*
|
|
23
|
+
* The show-gate is evaluated ONCE, here at mount: render iff the viewer is not a
|
|
24
|
+
* phone AND the origin is a real preview origin. If the gate fails, nothing is
|
|
25
|
+
* appended to the DOM (no disabled state) and the returned unmount is a no-op.
|
|
26
|
+
*
|
|
27
|
+
* Returns an unmount function that removes the chip + popover, tears down listeners,
|
|
28
|
+
* and releases the shared cluster.
|
|
29
|
+
*/
|
|
30
|
+
export declare function mountShareButton(opts?: ShareButtonOptions): () => void;
|
|
@@ -0,0 +1,285 @@
|
|
|
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
|
+
import { isPhone, isPreviewOrigin } from "./previewOrigin.js";
|
|
15
|
+
// The viewer/origin predicates moved to the dependency-free `previewOrigin.ts` so
|
|
16
|
+
// the feedback chip can gate on them without importing this module (and with it
|
|
17
|
+
// `qrcode`). Re-exported here because this is the path every existing caller and
|
|
18
|
+
// test imports them from, and the wire/API surface should not move for a refactor.
|
|
19
|
+
export { isPhone, isPreviewOrigin, PREVIEW_APEXES } from "./previewOrigin.js";
|
|
20
|
+
/**
|
|
21
|
+
* The canonical preview URL to encode — `origin + "/"`, stable across in-app SPA
|
|
22
|
+
* navigation. Reads ONLY the origin, so a deep path, query-param secret, or hash is
|
|
23
|
+
* never encoded into the QR or the text field.
|
|
24
|
+
*/
|
|
25
|
+
export function deriveShareUrl(location) {
|
|
26
|
+
return location.origin + "/";
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Render the preview URL as an inline SVG QR. Same params as the CLI's `qrPage.ts`
|
|
30
|
+
* (`margin:2`, ECC `M`) so the on-screen QR is identical to the proven-scannable
|
|
31
|
+
* terminal one.
|
|
32
|
+
*/
|
|
33
|
+
export function renderQr(url) {
|
|
34
|
+
return QRCode.toString(url, {
|
|
35
|
+
type: "svg",
|
|
36
|
+
margin: 2,
|
|
37
|
+
errorCorrectionLevel: "M",
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
const HEADING = "Open on your phone";
|
|
41
|
+
const INSTRUCTION = "Point your phone's camera at the code, or type in the link below.";
|
|
42
|
+
// A single QR-code glyph (three finder squares + a scattering of modules), ~18px,
|
|
43
|
+
// `currentColor` so it inherits the chip's `#f5f2ef`. Chosen over a share arrow
|
|
44
|
+
// because a QR square most directly says "get this onto a phone".
|
|
45
|
+
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>`;
|
|
46
|
+
/**
|
|
47
|
+
* Mount the icon-only Share chip into the shared cluster and wire its popover.
|
|
48
|
+
*
|
|
49
|
+
* The show-gate is evaluated ONCE, here at mount: render iff the viewer is not a
|
|
50
|
+
* phone AND the origin is a real preview origin. If the gate fails, nothing is
|
|
51
|
+
* appended to the DOM (no disabled state) and the returned unmount is a no-op.
|
|
52
|
+
*
|
|
53
|
+
* Returns an unmount function that removes the chip + popover, tears down listeners,
|
|
54
|
+
* and releases the shared cluster.
|
|
55
|
+
*/
|
|
56
|
+
export function mountShareButton(opts = {}) {
|
|
57
|
+
if (typeof document === "undefined")
|
|
58
|
+
return () => undefined;
|
|
59
|
+
const matchMedia = opts.matchMedia ?? (typeof window !== "undefined" ? window.matchMedia : undefined);
|
|
60
|
+
const location = opts.location ?? (typeof window !== "undefined" ? window.location : undefined);
|
|
61
|
+
if (!location)
|
|
62
|
+
return () => undefined;
|
|
63
|
+
const show = !isPhone(matchMedia) && isPreviewOrigin(location);
|
|
64
|
+
if (!show)
|
|
65
|
+
return () => undefined;
|
|
66
|
+
const url = deriveShareUrl(location);
|
|
67
|
+
const container = getClusterContainer();
|
|
68
|
+
let released = false;
|
|
69
|
+
const btn = document.createElement("button");
|
|
70
|
+
btn.type = "button";
|
|
71
|
+
btn.setAttribute("aria-label", "Share preview");
|
|
72
|
+
btn.title = "Share preview";
|
|
73
|
+
btn.setAttribute("aria-haspopup", "dialog");
|
|
74
|
+
btn.setAttribute("aria-controls", "vt-popover");
|
|
75
|
+
btn.setAttribute("aria-expanded", "false");
|
|
76
|
+
btn.innerHTML = QR_GLYPH_SVG;
|
|
77
|
+
Object.assign(btn.style, {
|
|
78
|
+
width: "36px",
|
|
79
|
+
height: "36px",
|
|
80
|
+
padding: "0",
|
|
81
|
+
display: "inline-flex",
|
|
82
|
+
alignItems: "center",
|
|
83
|
+
justifyContent: "center",
|
|
84
|
+
borderRadius: "10px",
|
|
85
|
+
border: "1px solid rgba(255,255,255,.14)",
|
|
86
|
+
background: "rgba(28,27,26,.92)",
|
|
87
|
+
color: "#f5f2ef",
|
|
88
|
+
boxShadow: "0 1px 4px rgba(0,0,0,.5)",
|
|
89
|
+
cursor: "pointer",
|
|
90
|
+
transition: "background 100ms, border-color 100ms",
|
|
91
|
+
});
|
|
92
|
+
const IDLE_BG = "rgba(28,27,26,.92)";
|
|
93
|
+
const IDLE_BORDER = "rgba(255,255,255,.14)";
|
|
94
|
+
const HOVER_BG = "rgba(40,38,36,.95)";
|
|
95
|
+
const HOVER_BORDER = "rgba(255,255,255,.24)";
|
|
96
|
+
const ACTIVE_BG = "rgba(40,38,36,.95)";
|
|
97
|
+
const ACTIVE_BORDER = "rgba(147,184,240,.5)";
|
|
98
|
+
const applyIdle = () => {
|
|
99
|
+
btn.style.background = IDLE_BG;
|
|
100
|
+
btn.style.borderColor = IDLE_BORDER;
|
|
101
|
+
};
|
|
102
|
+
const applyActive = () => {
|
|
103
|
+
btn.style.background = ACTIVE_BG;
|
|
104
|
+
btn.style.borderColor = ACTIVE_BORDER;
|
|
105
|
+
};
|
|
106
|
+
btn.addEventListener("mouseenter", () => {
|
|
107
|
+
if (popover)
|
|
108
|
+
return; // active state wins over hover
|
|
109
|
+
btn.style.background = HOVER_BG;
|
|
110
|
+
btn.style.borderColor = HOVER_BORDER;
|
|
111
|
+
});
|
|
112
|
+
btn.addEventListener("mouseleave", () => {
|
|
113
|
+
if (popover)
|
|
114
|
+
return;
|
|
115
|
+
applyIdle();
|
|
116
|
+
});
|
|
117
|
+
// Focus ring: a brand-blue outline that reads on any background. Set inline on
|
|
118
|
+
// focus/blur since there is no stylesheet on this surface for :focus-visible.
|
|
119
|
+
btn.addEventListener("focus", () => {
|
|
120
|
+
btn.style.outline = "2px solid #93b8f0";
|
|
121
|
+
btn.style.outlineOffset = "2px";
|
|
122
|
+
});
|
|
123
|
+
btn.addEventListener("blur", () => {
|
|
124
|
+
btn.style.outline = "none";
|
|
125
|
+
});
|
|
126
|
+
let popover = null;
|
|
127
|
+
let outsideHandler = null;
|
|
128
|
+
let keyHandler = null;
|
|
129
|
+
const prefersReducedMotion = typeof matchMedia === "function"
|
|
130
|
+
? (() => {
|
|
131
|
+
try {
|
|
132
|
+
return matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
})()
|
|
138
|
+
: false;
|
|
139
|
+
const closePopover = (returnFocus) => {
|
|
140
|
+
if (!popover)
|
|
141
|
+
return;
|
|
142
|
+
popover.remove();
|
|
143
|
+
popover = null;
|
|
144
|
+
if (outsideHandler) {
|
|
145
|
+
document.removeEventListener("mousedown", outsideHandler, true);
|
|
146
|
+
outsideHandler = null;
|
|
147
|
+
}
|
|
148
|
+
if (keyHandler) {
|
|
149
|
+
document.removeEventListener("keydown", keyHandler);
|
|
150
|
+
keyHandler = null;
|
|
151
|
+
}
|
|
152
|
+
btn.setAttribute("aria-expanded", "false");
|
|
153
|
+
applyIdle();
|
|
154
|
+
if (returnFocus)
|
|
155
|
+
btn.focus();
|
|
156
|
+
};
|
|
157
|
+
const buildPopover = () => {
|
|
158
|
+
const pop = document.createElement("div");
|
|
159
|
+
pop.id = "vt-popover";
|
|
160
|
+
pop.setAttribute("role", "dialog");
|
|
161
|
+
pop.setAttribute("aria-label", "Open this preview on your phone");
|
|
162
|
+
Object.assign(pop.style, {
|
|
163
|
+
position: "fixed",
|
|
164
|
+
top: "56px",
|
|
165
|
+
right: "12px",
|
|
166
|
+
zIndex: "2147483647",
|
|
167
|
+
width: "260px",
|
|
168
|
+
padding: "16px",
|
|
169
|
+
borderRadius: "12px",
|
|
170
|
+
border: "1px solid rgba(255,255,255,.14)",
|
|
171
|
+
background: "rgba(23,22,21,.86)",
|
|
172
|
+
backdropFilter: "blur(16px) saturate(1.1)",
|
|
173
|
+
// @ts-expect-error vendor-prefixed for Safari; not in the typed CSSStyleDeclaration
|
|
174
|
+
WebkitBackdropFilter: "blur(16px) saturate(1.1)",
|
|
175
|
+
boxShadow: "0 12px 32px rgba(0,0,0,.45)",
|
|
176
|
+
boxSizing: "border-box",
|
|
177
|
+
transformOrigin: "top right",
|
|
178
|
+
});
|
|
179
|
+
const qrTile = document.createElement("div");
|
|
180
|
+
qrTile.className = "vt-qr";
|
|
181
|
+
Object.assign(qrTile.style, {
|
|
182
|
+
width: "100%",
|
|
183
|
+
aspectRatio: "1 / 1",
|
|
184
|
+
background: "#fff",
|
|
185
|
+
padding: "8px",
|
|
186
|
+
borderRadius: "8px",
|
|
187
|
+
border: "1px solid rgba(0,0,0,.06)",
|
|
188
|
+
boxSizing: "border-box",
|
|
189
|
+
});
|
|
190
|
+
// Async QR render; the tile holds its space via aspect-ratio until it lands.
|
|
191
|
+
void renderQr(url).then((svg) => {
|
|
192
|
+
// Guard against a close-before-resolve race.
|
|
193
|
+
if (qrTile.isConnected)
|
|
194
|
+
qrTile.innerHTML = svg;
|
|
195
|
+
});
|
|
196
|
+
const heading = document.createElement("div");
|
|
197
|
+
heading.className = "vt-heading";
|
|
198
|
+
heading.textContent = HEADING;
|
|
199
|
+
Object.assign(heading.style, {
|
|
200
|
+
font: "600 14px/1.35 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif",
|
|
201
|
+
color: "#f5f2ef",
|
|
202
|
+
margin: "12px 0 2px",
|
|
203
|
+
});
|
|
204
|
+
const instr = document.createElement("div");
|
|
205
|
+
instr.className = "vt-instr";
|
|
206
|
+
instr.textContent = INSTRUCTION;
|
|
207
|
+
Object.assign(instr.style, {
|
|
208
|
+
font: "400 13px/1.45 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif",
|
|
209
|
+
color: "#b8b2a9",
|
|
210
|
+
margin: "0 0 10px",
|
|
211
|
+
});
|
|
212
|
+
const urlField = document.createElement("div");
|
|
213
|
+
urlField.className = "vt-url";
|
|
214
|
+
urlField.textContent = url;
|
|
215
|
+
Object.assign(urlField.style, {
|
|
216
|
+
font: "500 12px/1.5 ui-monospace,monospace",
|
|
217
|
+
color: "#93b8f0",
|
|
218
|
+
background: "rgba(255,255,255,.06)",
|
|
219
|
+
border: "1px solid rgba(255,255,255,.10)",
|
|
220
|
+
borderRadius: "8px",
|
|
221
|
+
padding: "8px 10px",
|
|
222
|
+
wordBreak: "break-all",
|
|
223
|
+
cursor: "text",
|
|
224
|
+
userSelect: "all",
|
|
225
|
+
// @ts-expect-error vendor-prefixed for Safari
|
|
226
|
+
WebkitUserSelect: "all",
|
|
227
|
+
boxSizing: "border-box",
|
|
228
|
+
});
|
|
229
|
+
pop.append(qrTile, heading, instr, urlField);
|
|
230
|
+
if (!prefersReducedMotion) {
|
|
231
|
+
pop.style.opacity = "0";
|
|
232
|
+
pop.style.transform = "translateY(-4px) scale(.98)";
|
|
233
|
+
pop.style.transition =
|
|
234
|
+
"opacity 160ms cubic-bezier(0.16,1,0.3,1), transform 160ms cubic-bezier(0.16,1,0.3,1)";
|
|
235
|
+
// Next frame: animate to the resting state.
|
|
236
|
+
requestAnimationFrame(() => {
|
|
237
|
+
pop.style.opacity = "1";
|
|
238
|
+
pop.style.transform = "translateY(0) scale(1)";
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
return pop;
|
|
242
|
+
};
|
|
243
|
+
const openPopover = () => {
|
|
244
|
+
if (popover)
|
|
245
|
+
return;
|
|
246
|
+
popover = buildPopover();
|
|
247
|
+
container.appendChild(popover);
|
|
248
|
+
btn.setAttribute("aria-expanded", "true");
|
|
249
|
+
applyActive();
|
|
250
|
+
// Click-outside: a capture-phase mousedown outside both popover and button
|
|
251
|
+
// closes it. Registered only while open, removed on close.
|
|
252
|
+
outsideHandler = (e) => {
|
|
253
|
+
const target = e.target;
|
|
254
|
+
if (!target)
|
|
255
|
+
return;
|
|
256
|
+
if (popover && (popover.contains(target) || btn.contains(target)))
|
|
257
|
+
return;
|
|
258
|
+
closePopover(false);
|
|
259
|
+
};
|
|
260
|
+
document.addEventListener("mousedown", outsideHandler, true);
|
|
261
|
+
// Escape closes and returns focus to the Share button.
|
|
262
|
+
keyHandler = (e) => {
|
|
263
|
+
if (e.key === "Escape") {
|
|
264
|
+
e.stopPropagation();
|
|
265
|
+
closePopover(true);
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
document.addEventListener("keydown", keyHandler);
|
|
269
|
+
};
|
|
270
|
+
btn.addEventListener("click", () => {
|
|
271
|
+
if (popover)
|
|
272
|
+
closePopover(false);
|
|
273
|
+
else
|
|
274
|
+
openPopover();
|
|
275
|
+
});
|
|
276
|
+
container.appendChild(btn);
|
|
277
|
+
return () => {
|
|
278
|
+
closePopover(false);
|
|
279
|
+
btn.remove();
|
|
280
|
+
if (!released) {
|
|
281
|
+
released = true;
|
|
282
|
+
releaseClusterContainer();
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
}
|
package/dist/shared/events.d.ts
CHANGED
|
@@ -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;
|
|
@@ -108,6 +129,20 @@ export interface RelayQuery {
|
|
|
108
129
|
* debugging rather than for "whose phone".
|
|
109
130
|
*/
|
|
110
131
|
viewer?: string;
|
|
132
|
+
/**
|
|
133
|
+
* Ask the relay to also report how many connections are attached RIGHT NOW.
|
|
134
|
+
*
|
|
135
|
+
* An OPTIONAL BOOLEAN and not a `kind` value: `kind` is a `DiagEventKind`
|
|
136
|
+
* event-type FILTER shared with `DiagEvent.kind`, so a "presence" member there
|
|
137
|
+
* would invent a meaningless event type. It is also not an `op` discriminator,
|
|
138
|
+
* which would imply a mode the type never had and need a default — a version
|
|
139
|
+
* branch by another name.
|
|
140
|
+
*
|
|
141
|
+
* An unknown field is IGNORED by a relay that predates this, so an old relay
|
|
142
|
+
* answers a valid envelope with no counts and the caller reads none. That is
|
|
143
|
+
* the whole of the version negotiation: there is none.
|
|
144
|
+
*/
|
|
145
|
+
presence?: boolean;
|
|
111
146
|
}
|
|
112
147
|
export interface RelayResult {
|
|
113
148
|
events: DiagEvent[];
|
|
@@ -150,6 +185,31 @@ export interface RelayResult {
|
|
|
150
185
|
* set rather than remembering one.
|
|
151
186
|
*/
|
|
152
187
|
viewers: string[];
|
|
188
|
+
/**
|
|
189
|
+
* How many connections are attached RIGHT NOW. Present only when the query
|
|
190
|
+
* asked for presence, and only from a relay that understands the field.
|
|
191
|
+
*
|
|
192
|
+
* ⚠ THIS IS NOT `viewers.length`, AND THE TWO SIT IN THE SAME OBJECT.
|
|
193
|
+
* `viewers`/`testers` are EVER-SEEN sets that are never pruned by design, so
|
|
194
|
+
* they answer "who has ever connected to this run". A caller that falls back
|
|
195
|
+
* to them when this field is absent prints a phone that closed its tab twenty
|
|
196
|
+
* minutes ago as still watching. ABSENT MEANS UNKNOWN, and the only correct
|
|
197
|
+
* response to unknown is to say nothing.
|
|
198
|
+
*
|
|
199
|
+
* The source is the relay's live socket set, so a socket that closes leaves
|
|
200
|
+
* it. Counts CONNECTIONS, not devices or people: one tab opens several under
|
|
201
|
+
* HTTP/1.1, which is why every surface that prints this says "connections".
|
|
202
|
+
*/
|
|
203
|
+
liveConnections?: number;
|
|
204
|
+
/**
|
|
205
|
+
* Distinct resolved testers among the currently-open connections. Present
|
|
206
|
+
* under the same condition as `liveConnections`.
|
|
207
|
+
*
|
|
208
|
+
* A connection whose key missed the grammar resolves to no tester, so it
|
|
209
|
+
* counts toward `liveConnections` and not toward this — correct, because it
|
|
210
|
+
* is a real attached connection of an unknown tab.
|
|
211
|
+
*/
|
|
212
|
+
liveTesters?: number;
|
|
153
213
|
}
|
|
154
214
|
/** A freehand stroke over the frame, in normalized [0,1] frame coordinates. */
|
|
155
215
|
export interface AnnotationStroke {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vincentt-xr/harness",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.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",
|