@vincentt-xr/harness 1.2.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 +4 -5
- package/dist/client/HarnessProvider.js +35 -16
- package/dist/client/annotate.d.ts +75 -6
- package/dist/client/annotate.js +428 -32
- package/dist/client/index.d.ts +2 -1
- package/dist/client/index.js +2 -1
- package/dist/client/previewOrigin.d.ts +50 -0
- package/dist/client/previewOrigin.js +76 -0
- package/dist/client/share.d.ts +1 -26
- package/dist/client/share.js +6 -39
- package/dist/shared/events.d.ts +39 -0
- package/package.json +1 -1
|
@@ -24,11 +24,10 @@ export interface HarnessProviderProps {
|
|
|
24
24
|
captureNetwork?: boolean;
|
|
25
25
|
captureTrace?: boolean;
|
|
26
26
|
/**
|
|
27
|
-
* Mount the
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* takes the adjacent flex slot with no layout move.
|
|
27
|
+
* Mount the Send-feedback chip (the reverse-channel capture overlay) into the
|
|
28
|
+
* shared top-right cluster. Defaults to `enabled` — on in preview, tree-shaken
|
|
29
|
+
* out of production. It is the phone's only way to speak, and f12's central
|
|
30
|
+
* scenario is unreachable without it.
|
|
32
31
|
*/
|
|
33
32
|
feedback?: boolean;
|
|
34
33
|
/**
|
|
@@ -90,13 +90,42 @@ export function HarnessProvider(props) {
|
|
|
90
90
|
captureTrace: props.captureTrace,
|
|
91
91
|
});
|
|
92
92
|
});
|
|
93
|
+
let unmountShare;
|
|
93
94
|
let unmountFeedback;
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
95
|
+
// SHARE MOUNTS FIRST, AND THE ORDER IS SEQUENCED, NOT RACED.
|
|
96
|
+
//
|
|
97
|
+
// Insertion order into the cluster IS flex order, and Share is the cluster's
|
|
98
|
+
// established first member — a new control must take the adjacent slot
|
|
99
|
+
// without moving it. Two independent `import(...).then(...)` chains resolve
|
|
100
|
+
// in whatever order the loader finishes them, so leaving these parallel puts
|
|
101
|
+
// the cluster's layout at the mercy of module-fetch timing. Feedback is
|
|
102
|
+
// therefore mounted from INSIDE Share's continuation.
|
|
103
|
+
//
|
|
104
|
+
// Both remain separate dynamic imports, which is what lets a production
|
|
105
|
+
// build drop each overlay (and `qrcode`, reached only from share.js). The
|
|
106
|
+
// share import is also written FIRST in this file, so the ordering is legible
|
|
107
|
+
// to a reader (and to a source-level assertion) and not only to the runtime.
|
|
108
|
+
//
|
|
109
|
+
// Share defaults to `enabled`. Feedback defaults to `enabled` too (opt OUT
|
|
110
|
+
// with `feedback={false}`); the chip gates itself on the origin at mount, so
|
|
111
|
+
// the flag only decides whether the module is fetched at all.
|
|
112
|
+
if (props.share !== false) {
|
|
113
|
+
void import("./share.js").then(({ mountShareButton }) => {
|
|
114
|
+
if (cancelled)
|
|
115
|
+
return;
|
|
116
|
+
unmountShare = mountShareButton({});
|
|
117
|
+
// Only now — so Share holds slot one even when its module resolves last.
|
|
118
|
+
mountFeedback();
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
// No Share at all: feedback is the cluster's only member, so there is no
|
|
123
|
+
// order to preserve and nothing to wait for.
|
|
124
|
+
mountFeedback();
|
|
125
|
+
}
|
|
126
|
+
function mountFeedback() {
|
|
127
|
+
if (props.feedback === false)
|
|
128
|
+
return;
|
|
100
129
|
void import("./annotate.js").then(({ mountFeedbackButton }) => {
|
|
101
130
|
if (cancelled)
|
|
102
131
|
return;
|
|
@@ -110,16 +139,6 @@ export function HarnessProvider(props) {
|
|
|
110
139
|
});
|
|
111
140
|
});
|
|
112
141
|
}
|
|
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
|
-
}
|
|
123
142
|
return () => {
|
|
124
143
|
cancelled = true;
|
|
125
144
|
teardown?.();
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type FramingView } from "./previewOrigin.js";
|
|
1
2
|
import type { AnnotationInput, AnnotationSpec } from "../shared/events.js";
|
|
2
3
|
/**
|
|
3
4
|
* Capture the current frame as a PNG data URL. Prefers the largest <canvas> (the
|
|
@@ -18,17 +19,85 @@ export declare function sendAnnotation(input: AnnotationInput, opts?: SendAnnota
|
|
|
18
19
|
id: string;
|
|
19
20
|
seq: number;
|
|
20
21
|
}>;
|
|
22
|
+
/** The counter's current value. Exported for tests; not part of the app surface. */
|
|
23
|
+
export declare function getConsecutiveFailures(): number;
|
|
24
|
+
/**
|
|
25
|
+
* ANY failed POST increments — non-2xx, network error, abort alike. The device
|
|
26
|
+
* cannot distinguish a dead tunnel from a dead relay from a dead preview, and
|
|
27
|
+
* must not try.
|
|
28
|
+
*/
|
|
29
|
+
export declare function recordSendFailure(): number;
|
|
30
|
+
/**
|
|
31
|
+
* A successful POST resets to 0, UNCONDITIONALLY. One send getting through proves
|
|
32
|
+
* the channel is alive, so the next failure is a first failure again.
|
|
33
|
+
*
|
|
34
|
+
* Nothing else calls this. Not the panel closing, not the chip being tapped, not
|
|
35
|
+
* a timer, not a visibility change — see the module comment.
|
|
36
|
+
*/
|
|
37
|
+
export declare function recordSendSuccess(): void;
|
|
38
|
+
/** Test-only: restore the module counter to its initial state. */
|
|
39
|
+
export declare function __resetFailureCounterForTests(): void;
|
|
40
|
+
/** At 2+ consecutive failures the channel is gone, not having a bad moment. */
|
|
41
|
+
export declare function isStranded(): boolean;
|
|
21
42
|
export interface FeedbackButtonOptions extends SendAnnotationOptions {
|
|
22
43
|
sessionId?: string;
|
|
23
|
-
/** Collect the creator's message. Default: window.prompt. */
|
|
24
|
-
promptMessage?: () => string | null | Promise<string | null>;
|
|
25
44
|
/** Override the captured spec (strokes/pins/labels). Default: empty. */
|
|
26
45
|
spec?: AnnotationSpec;
|
|
46
|
+
/** Injectable for tests. Defaults to `window.matchMedia`. */
|
|
47
|
+
matchMedia?: typeof window.matchMedia;
|
|
48
|
+
/** Injectable for tests. Defaults to `window.location`. */
|
|
49
|
+
location?: Pick<Location, "origin" | "protocol" | "hostname">;
|
|
50
|
+
/** Injectable for tests. Defaults to the real POST. */
|
|
51
|
+
send?: (input: AnnotationInput) => Promise<unknown>;
|
|
52
|
+
/** Injectable for tests. Defaults to `captureScreenshot`. */
|
|
53
|
+
capture?: () => string;
|
|
54
|
+
/** Injectable for tests. Defaults to the real `window` (self/top identity). */
|
|
55
|
+
view?: FramingView;
|
|
27
56
|
}
|
|
28
57
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
58
|
+
* Is the feedback chip shown? The MIRROR of Share's rule, because the two are
|
|
59
|
+
* opposite handoffs. Share is `!isPhone(mm) && isPreviewOrigin(loc)`; feedback
|
|
60
|
+
* keeps the origin arm and DROPS the phone veto, because a phone is the device
|
|
61
|
+
* this control exists for. So the gate is the origin arm alone:
|
|
62
|
+
*
|
|
63
|
+
* | context | Share | feedback |
|
|
64
|
+
* |-------------------------------|--------|----------|
|
|
65
|
+
* | coarse-pointer / narrow | absent | PRESENT |
|
|
66
|
+
* | desktop | present| present |
|
|
67
|
+
* | real preview origin | req'd | req'd |
|
|
68
|
+
* | plain localhost / LAN | absent | ABSENT — no relay reachable |
|
|
69
|
+
*
|
|
70
|
+
* `matchMedia` is NOT a parameter: no viewport class can change this answer, and
|
|
71
|
+
* taking one would imply a veto that does not exist. Never rendered disabled or
|
|
72
|
+
* explained-away — on localhost nothing is appended at all.
|
|
73
|
+
*/
|
|
74
|
+
export declare function shouldShowFeedback(loc: Pick<Location, "protocol" | "hostname">): boolean;
|
|
75
|
+
/**
|
|
76
|
+
* Mount the icon-only Send-feedback chip into the shared cluster and wire its
|
|
77
|
+
* composing panel.
|
|
78
|
+
*
|
|
79
|
+
* The show-gate is evaluated ONCE, here at mount. If it fails, nothing is
|
|
80
|
+
* appended to the DOM (no disabled state) and the returned unmount is a no-op.
|
|
81
|
+
*
|
|
82
|
+
* Share mounts first from HarnessProvider, and insertion order IS flex order, so
|
|
83
|
+
* appending here always leaves Share as the cluster's first member.
|
|
84
|
+
*
|
|
85
|
+
* THE FRAMED VETO (f13 `D-Feedback-does-not-mount-in-the-frame`, security
|
|
86
|
+
* MUST-FIX 5). Send POSTs to a route whose own comment states it is
|
|
87
|
+
* "unauthenticated and reachable by anyone holding the capability URL", and the
|
|
88
|
+
* write lands in the creator's coding-agent context. Inside a frame that control
|
|
89
|
+
* is positioned by the embedder's CSS on a page the creator did not choose to
|
|
90
|
+
* load it on, so a UI-redress lure (drag-and-paste into the textarea, a
|
|
91
|
+
* "click twice to continue" chain) reaches a text-injection channel needing no
|
|
92
|
+
* browser permission grant at all — the class of harm the frame's absent `allow`
|
|
93
|
+
* was built to stop, arriving through a door `allow` does not cover.
|
|
94
|
+
*
|
|
95
|
+
* THE MOUNT is gated, not the visibility. A hidden-but-mounted chip still holds
|
|
96
|
+
* the write path, and a hidden control is exactly what an overlay attack wants.
|
|
97
|
+
* Nothing is appended; there is no disabled state to re-enable.
|
|
98
|
+
*
|
|
99
|
+
* The cost is small and known: a creator viewing their own preview framed in
|
|
100
|
+
* their own console cannot Send from inside the frame. It is their own agent and
|
|
101
|
+
* the terminal is where they are already talking to it.
|
|
33
102
|
*/
|
|
34
103
|
export declare function mountFeedbackButton(opts?: FeedbackButtonOptions): () => void;
|
package/dist/client/annotate.js
CHANGED
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
// The in-app capture overlay's Send action — the client half of the reverse
|
|
2
|
-
// channel. It captures the current frame, packages the
|
|
2
|
+
// channel. It captures the current frame, packages the viewer's message + spec,
|
|
3
3
|
// and POSTs it to the relay over the same tunnel the app is served on. The relay
|
|
4
4
|
// (see the CLI's relay/annotations) stamps + persists it and unblocks a waiting
|
|
5
5
|
// `vincentt feedback --wait`.
|
|
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
|
+
//
|
|
10
|
+
// THE DEVICE IS TOLD ABOUT ITS OWN SEND AND NOTHING ELSE. The reader may be a
|
|
11
|
+
// client or a stranger, so no string on this surface may name the agent, the
|
|
12
|
+
// developer, the creator, a queue depth, a read receipt, a send history, the
|
|
13
|
+
// project/org/folder, or the state of the creator's machine. `Can't reach the
|
|
14
|
+
// preview.` is legal precisely because it is a fact about THIS DEVICE'S OWN
|
|
15
|
+
// failed request — the only thing the device actually observed.
|
|
9
16
|
import { getClusterContainer, releaseClusterContainer } from "./cluster.js";
|
|
17
|
+
// From the dependency-free leaf, NOT from share.ts — importing share.ts here
|
|
18
|
+
// would statically pull `qrcode` into the feedback chip's chunk.
|
|
19
|
+
import { isFramed, isPreviewOrigin } from "./previewOrigin.js";
|
|
10
20
|
/** Default relay HTTP base: the same origin the app is served on + the harness path. */
|
|
11
21
|
function defaultRelayHttpUrl() {
|
|
12
22
|
return `${window.location.origin}/__harness`;
|
|
@@ -47,60 +57,446 @@ export async function sendAnnotation(input, opts = {}) {
|
|
|
47
57
|
throw new Error(`annotation POST failed: ${res.status}`);
|
|
48
58
|
return (await res.json());
|
|
49
59
|
}
|
|
60
|
+
// ── The two-strike counter ──────────────────────────────────────────────────
|
|
61
|
+
//
|
|
62
|
+
// Module-scoped, because the evidence it holds is "this page's sends", not "this
|
|
63
|
+
// panel's sends" — a counter reset by closing the panel would strand nobody and
|
|
64
|
+
// warn nobody. It dies with the page, which is the one acceptable reset: a
|
|
65
|
+
// reloaded page has genuinely lost its evidence.
|
|
66
|
+
let consecutiveFailures = 0;
|
|
67
|
+
/** The counter's current value. Exported for tests; not part of the app surface. */
|
|
68
|
+
export function getConsecutiveFailures() {
|
|
69
|
+
return consecutiveFailures;
|
|
70
|
+
}
|
|
50
71
|
/**
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
|
|
72
|
+
* ANY failed POST increments — non-2xx, network error, abort alike. The device
|
|
73
|
+
* cannot distinguish a dead tunnel from a dead relay from a dead preview, and
|
|
74
|
+
* must not try.
|
|
75
|
+
*/
|
|
76
|
+
export function recordSendFailure() {
|
|
77
|
+
consecutiveFailures += 1;
|
|
78
|
+
return consecutiveFailures;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* A successful POST resets to 0, UNCONDITIONALLY. One send getting through proves
|
|
82
|
+
* the channel is alive, so the next failure is a first failure again.
|
|
83
|
+
*
|
|
84
|
+
* Nothing else calls this. Not the panel closing, not the chip being tapped, not
|
|
85
|
+
* a timer, not a visibility change — see the module comment.
|
|
86
|
+
*/
|
|
87
|
+
export function recordSendSuccess() {
|
|
88
|
+
consecutiveFailures = 0;
|
|
89
|
+
}
|
|
90
|
+
/** Test-only: restore the module counter to its initial state. */
|
|
91
|
+
export function __resetFailureCounterForTests() {
|
|
92
|
+
consecutiveFailures = 0;
|
|
93
|
+
}
|
|
94
|
+
/** At 2+ consecutive failures the channel is gone, not having a bad moment. */
|
|
95
|
+
export function isStranded() {
|
|
96
|
+
return consecutiveFailures >= 2;
|
|
97
|
+
}
|
|
98
|
+
// ── Copy ────────────────────────────────────────────────────────────────────
|
|
99
|
+
const PANEL_HEADING = "What should change here?";
|
|
100
|
+
const CAPTURE_NOTE = "This screen is attached.";
|
|
101
|
+
const SEND_LABEL = "Send";
|
|
102
|
+
const CHIP_LABEL = "Send feedback";
|
|
103
|
+
const SENDING_LABEL = "Sending…";
|
|
104
|
+
const SENT_LABEL = "Sent ✓";
|
|
105
|
+
const FAILED_LABEL = "Failed — retry";
|
|
106
|
+
// f3's 410-page construction with the phrase that does not apply here removed:
|
|
107
|
+
// the tester is not asking for a new link, they are reporting that sending is
|
|
108
|
+
// broken. Names no cause, offers no button that cannot work, invites no account.
|
|
109
|
+
const STRANDED_LINES = ["Can't reach the preview.", "Ask whoever shared this link."];
|
|
110
|
+
const SENT_REVERT_MS = 1500;
|
|
111
|
+
// A pencil glyph, ~18px, `currentColor` so it inherits the chip's `#f5f2ef`.
|
|
112
|
+
// Icon-only: the chip's meaning is carried by aria-label + title, never a visible
|
|
113
|
+
// text label (which is what the shipped gradient pill used).
|
|
114
|
+
const PENCIL_GLYPH_SVG = `<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25Zm2 .83 9.06-9.06.92.92L5.92 19H5v-.92ZM20.71 5.63l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83a1 1 0 0 0 0-1.41Z"/></svg>`;
|
|
115
|
+
// Chip chrome — the SAME constants as share.ts's Share chip. The two chips sit in
|
|
116
|
+
// one 8px cluster on the creator's own screen; they differ only in glyph.
|
|
117
|
+
const IDLE_BG = "rgba(28,27,26,.92)";
|
|
118
|
+
const IDLE_BORDER = "rgba(255,255,255,.14)";
|
|
119
|
+
const HOVER_BG = "rgba(40,38,36,.95)";
|
|
120
|
+
const HOVER_BORDER = "rgba(255,255,255,.24)";
|
|
121
|
+
const ACTIVE_BG = "rgba(40,38,36,.95)";
|
|
122
|
+
const ACTIVE_BORDER = "rgba(147,184,240,.5)";
|
|
123
|
+
const SANS = "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif";
|
|
124
|
+
/**
|
|
125
|
+
* Is the feedback chip shown? The MIRROR of Share's rule, because the two are
|
|
126
|
+
* opposite handoffs. Share is `!isPhone(mm) && isPreviewOrigin(loc)`; feedback
|
|
127
|
+
* keeps the origin arm and DROPS the phone veto, because a phone is the device
|
|
128
|
+
* this control exists for. So the gate is the origin arm alone:
|
|
129
|
+
*
|
|
130
|
+
* | context | Share | feedback |
|
|
131
|
+
* |-------------------------------|--------|----------|
|
|
132
|
+
* | coarse-pointer / narrow | absent | PRESENT |
|
|
133
|
+
* | desktop | present| present |
|
|
134
|
+
* | real preview origin | req'd | req'd |
|
|
135
|
+
* | plain localhost / LAN | absent | ABSENT — no relay reachable |
|
|
136
|
+
*
|
|
137
|
+
* `matchMedia` is NOT a parameter: no viewport class can change this answer, and
|
|
138
|
+
* taking one would imply a veto that does not exist. Never rendered disabled or
|
|
139
|
+
* explained-away — on localhost nothing is appended at all.
|
|
140
|
+
*/
|
|
141
|
+
export function shouldShowFeedback(loc) {
|
|
142
|
+
return isPreviewOrigin(loc);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Mount the icon-only Send-feedback chip into the shared cluster and wire its
|
|
146
|
+
* composing panel.
|
|
147
|
+
*
|
|
148
|
+
* The show-gate is evaluated ONCE, here at mount. If it fails, nothing is
|
|
149
|
+
* appended to the DOM (no disabled state) and the returned unmount is a no-op.
|
|
150
|
+
*
|
|
151
|
+
* Share mounts first from HarnessProvider, and insertion order IS flex order, so
|
|
152
|
+
* appending here always leaves Share as the cluster's first member.
|
|
153
|
+
*
|
|
154
|
+
* THE FRAMED VETO (f13 `D-Feedback-does-not-mount-in-the-frame`, security
|
|
155
|
+
* MUST-FIX 5). Send POSTs to a route whose own comment states it is
|
|
156
|
+
* "unauthenticated and reachable by anyone holding the capability URL", and the
|
|
157
|
+
* write lands in the creator's coding-agent context. Inside a frame that control
|
|
158
|
+
* is positioned by the embedder's CSS on a page the creator did not choose to
|
|
159
|
+
* load it on, so a UI-redress lure (drag-and-paste into the textarea, a
|
|
160
|
+
* "click twice to continue" chain) reaches a text-injection channel needing no
|
|
161
|
+
* browser permission grant at all — the class of harm the frame's absent `allow`
|
|
162
|
+
* was built to stop, arriving through a door `allow` does not cover.
|
|
163
|
+
*
|
|
164
|
+
* THE MOUNT is gated, not the visibility. A hidden-but-mounted chip still holds
|
|
165
|
+
* the write path, and a hidden control is exactly what an overlay attack wants.
|
|
166
|
+
* Nothing is appended; there is no disabled state to re-enable.
|
|
167
|
+
*
|
|
168
|
+
* The cost is small and known: a creator viewing their own preview framed in
|
|
169
|
+
* their own console cannot Send from inside the frame. It is their own agent and
|
|
170
|
+
* the terminal is where they are already talking to it.
|
|
55
171
|
*/
|
|
56
172
|
export function mountFeedbackButton(opts = {}) {
|
|
57
173
|
if (typeof document === "undefined")
|
|
58
174
|
return () => undefined;
|
|
175
|
+
const matchMedia = opts.matchMedia ?? (typeof window !== "undefined" ? window.matchMedia : undefined);
|
|
176
|
+
const location = opts.location ?? (typeof window !== "undefined" ? window.location : undefined);
|
|
177
|
+
if (!location)
|
|
178
|
+
return () => undefined;
|
|
179
|
+
if (isFramed(opts.view))
|
|
180
|
+
return () => undefined;
|
|
181
|
+
if (!shouldShowFeedback(location))
|
|
182
|
+
return () => undefined;
|
|
183
|
+
const send = opts.send ??
|
|
184
|
+
((input) => sendAnnotation(input, { relayHttpUrl: opts.relayHttpUrl }));
|
|
185
|
+
const capture = opts.capture ?? captureScreenshot;
|
|
186
|
+
const container = getClusterContainer();
|
|
187
|
+
let released = false;
|
|
188
|
+
// The typed text lives HERE, outside the panel, so it survives every panel
|
|
189
|
+
// close and every failure. The words are the expensive part; a tester who must
|
|
190
|
+
// retype them will not.
|
|
191
|
+
let draft = "";
|
|
192
|
+
let revertTimer;
|
|
59
193
|
const btn = document.createElement("button");
|
|
60
|
-
btn.
|
|
194
|
+
btn.type = "button";
|
|
195
|
+
btn.setAttribute("aria-label", CHIP_LABEL);
|
|
196
|
+
btn.title = CHIP_LABEL;
|
|
197
|
+
btn.setAttribute("aria-haspopup", "dialog");
|
|
198
|
+
btn.setAttribute("aria-controls", "vt-feedback-panel");
|
|
199
|
+
btn.setAttribute("aria-expanded", "false");
|
|
200
|
+
btn.innerHTML = PENCIL_GLYPH_SVG;
|
|
61
201
|
Object.assign(btn.style, {
|
|
62
|
-
|
|
202
|
+
width: "36px",
|
|
203
|
+
height: "36px",
|
|
204
|
+
padding: "0",
|
|
205
|
+
display: "inline-flex",
|
|
206
|
+
alignItems: "center",
|
|
207
|
+
justifyContent: "center",
|
|
63
208
|
borderRadius: "10px",
|
|
64
|
-
border:
|
|
65
|
-
background:
|
|
66
|
-
color: "#
|
|
67
|
-
|
|
68
|
-
boxShadow: "0 4px 16px rgba(0,0,0,.25)",
|
|
209
|
+
border: `1px solid ${IDLE_BORDER}`,
|
|
210
|
+
background: IDLE_BG,
|
|
211
|
+
color: "#f5f2ef",
|
|
212
|
+
boxShadow: "0 1px 4px rgba(0,0,0,.5)",
|
|
69
213
|
cursor: "pointer",
|
|
214
|
+
transition: "background 100ms, border-color 100ms",
|
|
70
215
|
});
|
|
71
|
-
const
|
|
216
|
+
const applyIdle = () => {
|
|
217
|
+
btn.style.background = IDLE_BG;
|
|
218
|
+
btn.style.borderColor = IDLE_BORDER;
|
|
219
|
+
};
|
|
220
|
+
const applyActive = () => {
|
|
221
|
+
btn.style.background = ACTIVE_BG;
|
|
222
|
+
btn.style.borderColor = ACTIVE_BORDER;
|
|
223
|
+
};
|
|
224
|
+
/**
|
|
225
|
+
* The chip's three widened text states. `Sending…` and `Sent ✓` are transient;
|
|
226
|
+
* `Failed — retry` does NOT auto-revert — a creator holding the phone at arm's
|
|
227
|
+
* length looks back seconds later.
|
|
228
|
+
*/
|
|
229
|
+
const setChipText = (text) => {
|
|
230
|
+
if (text == null) {
|
|
231
|
+
btn.innerHTML = PENCIL_GLYPH_SVG;
|
|
232
|
+
btn.style.width = "36px";
|
|
233
|
+
btn.style.padding = "0";
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
btn.textContent = text;
|
|
237
|
+
btn.style.width = "auto";
|
|
238
|
+
btn.style.padding = "0 10px";
|
|
239
|
+
btn.style.font = `600 13px/1 ${SANS}`;
|
|
240
|
+
};
|
|
241
|
+
const setBusy = (busy) => {
|
|
72
242
|
btn.disabled = busy;
|
|
73
|
-
btn.textContent = label ?? "Send feedback";
|
|
74
243
|
btn.style.opacity = busy ? "0.6" : "1";
|
|
75
244
|
};
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
245
|
+
let panel = null;
|
|
246
|
+
let field = null;
|
|
247
|
+
let outsideHandler = null;
|
|
248
|
+
let keyHandler = null;
|
|
249
|
+
const prefersReducedMotion = typeof matchMedia === "function"
|
|
250
|
+
? (() => {
|
|
251
|
+
try {
|
|
252
|
+
return matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
})()
|
|
258
|
+
: false;
|
|
259
|
+
const closePanel = (returnFocus) => {
|
|
260
|
+
if (!panel)
|
|
261
|
+
return;
|
|
262
|
+
// Preserve whatever is typed. Closing the panel is NOT a reset of anything.
|
|
263
|
+
if (field)
|
|
264
|
+
draft = field.value;
|
|
265
|
+
panel.remove();
|
|
266
|
+
panel = null;
|
|
267
|
+
field = null;
|
|
268
|
+
if (outsideHandler) {
|
|
269
|
+
document.removeEventListener("mousedown", outsideHandler, true);
|
|
270
|
+
outsideHandler = null;
|
|
271
|
+
}
|
|
272
|
+
if (keyHandler) {
|
|
273
|
+
document.removeEventListener("keydown", keyHandler);
|
|
274
|
+
keyHandler = null;
|
|
275
|
+
}
|
|
276
|
+
btn.setAttribute("aria-expanded", "false");
|
|
277
|
+
applyIdle();
|
|
278
|
+
if (returnFocus)
|
|
279
|
+
btn.focus();
|
|
280
|
+
};
|
|
281
|
+
const buildPanel = () => {
|
|
282
|
+
const pop = document.createElement("div");
|
|
283
|
+
pop.id = "vt-feedback-panel";
|
|
284
|
+
pop.setAttribute("role", "dialog");
|
|
285
|
+
pop.setAttribute("aria-label", PANEL_HEADING);
|
|
286
|
+
Object.assign(pop.style, {
|
|
287
|
+
position: "fixed",
|
|
288
|
+
top: "56px",
|
|
289
|
+
right: "12px",
|
|
290
|
+
zIndex: "2147483647",
|
|
291
|
+
width: "260px",
|
|
292
|
+
padding: "16px",
|
|
293
|
+
borderRadius: "12px",
|
|
294
|
+
border: "1px solid rgba(255,255,255,.14)",
|
|
295
|
+
background: "rgba(23,22,21,.86)",
|
|
296
|
+
backdropFilter: "blur(16px) saturate(1.1)",
|
|
297
|
+
// @ts-expect-error vendor-prefixed for Safari; not in the typed CSSStyleDeclaration
|
|
298
|
+
WebkitBackdropFilter: "blur(16px) saturate(1.1)",
|
|
299
|
+
boxShadow: "0 12px 32px rgba(0,0,0,.45)",
|
|
300
|
+
boxSizing: "border-box",
|
|
301
|
+
transformOrigin: "top right",
|
|
302
|
+
});
|
|
303
|
+
// The stranded line, ABOVE the preserved text, at 2+ consecutive failures.
|
|
304
|
+
// It does not escalate further and there is no third state.
|
|
305
|
+
if (isStranded()) {
|
|
306
|
+
const stranded = document.createElement("div");
|
|
307
|
+
stranded.className = "vt-fb-stranded";
|
|
308
|
+
for (const line of STRANDED_LINES) {
|
|
309
|
+
const p = document.createElement("div");
|
|
310
|
+
p.textContent = line;
|
|
311
|
+
stranded.appendChild(p);
|
|
312
|
+
}
|
|
313
|
+
Object.assign(stranded.style, {
|
|
314
|
+
font: `500 13px/1.45 ${SANS}`,
|
|
315
|
+
color: "#f5f2ef",
|
|
316
|
+
margin: "0 0 12px",
|
|
317
|
+
});
|
|
318
|
+
pop.appendChild(stranded);
|
|
319
|
+
}
|
|
320
|
+
const heading = document.createElement("div");
|
|
321
|
+
heading.className = "vt-fb-heading";
|
|
322
|
+
heading.textContent = PANEL_HEADING;
|
|
323
|
+
Object.assign(heading.style, {
|
|
324
|
+
font: `600 14px/1.35 ${SANS}`,
|
|
325
|
+
color: "#f5f2ef",
|
|
326
|
+
margin: "0 0 8px",
|
|
327
|
+
});
|
|
328
|
+
const input = document.createElement("textarea");
|
|
329
|
+
input.className = "vt-fb-field";
|
|
330
|
+
input.rows = 3;
|
|
331
|
+
input.value = draft;
|
|
332
|
+
input.setAttribute("aria-label", PANEL_HEADING);
|
|
333
|
+
Object.assign(input.style, {
|
|
334
|
+
width: "100%",
|
|
335
|
+
font: `400 13px/1.45 ${SANS}`,
|
|
336
|
+
color: "#f5f2ef",
|
|
337
|
+
background: "rgba(255,255,255,.06)",
|
|
338
|
+
border: "1px solid rgba(255,255,255,.10)",
|
|
339
|
+
borderRadius: "8px",
|
|
340
|
+
padding: "8px 10px",
|
|
341
|
+
boxSizing: "border-box",
|
|
342
|
+
resize: "vertical",
|
|
343
|
+
});
|
|
344
|
+
// States the capture as a fact, once. No thumbnail — it is the screen they
|
|
345
|
+
// are looking at.
|
|
346
|
+
const note = document.createElement("div");
|
|
347
|
+
note.className = "vt-fb-note";
|
|
348
|
+
note.textContent = CAPTURE_NOTE;
|
|
349
|
+
Object.assign(note.style, {
|
|
350
|
+
font: `400 12px/1.45 ${SANS}`,
|
|
351
|
+
color: "#b8b2a9",
|
|
352
|
+
margin: "8px 0 10px",
|
|
353
|
+
});
|
|
354
|
+
const sendBtn = document.createElement("button");
|
|
355
|
+
sendBtn.type = "button";
|
|
356
|
+
sendBtn.className = "vt-fb-send";
|
|
357
|
+
sendBtn.textContent = SEND_LABEL;
|
|
358
|
+
Object.assign(sendBtn.style, {
|
|
359
|
+
width: "100%",
|
|
360
|
+
padding: "8px 12px",
|
|
361
|
+
borderRadius: "8px",
|
|
362
|
+
border: "1px solid rgba(255,255,255,.14)",
|
|
363
|
+
background: "rgba(255,255,255,.06)",
|
|
364
|
+
color: "#f5f2ef",
|
|
365
|
+
font: `600 13px/1 ${SANS}`,
|
|
366
|
+
cursor: "pointer",
|
|
367
|
+
boxSizing: "border-box",
|
|
368
|
+
});
|
|
369
|
+
// Disabled until the field is non-empty — and ONLY for that reason. A
|
|
370
|
+
// stranded tester's Send stays enabled: a channel that has come back will
|
|
371
|
+
// simply succeed and clear the counter.
|
|
372
|
+
const syncSendEnabled = () => {
|
|
373
|
+
const empty = input.value.trim() === "";
|
|
374
|
+
sendBtn.disabled = empty;
|
|
375
|
+
sendBtn.style.opacity = empty ? "0.5" : "1";
|
|
376
|
+
sendBtn.style.cursor = empty ? "default" : "pointer";
|
|
377
|
+
};
|
|
378
|
+
syncSendEnabled();
|
|
379
|
+
input.addEventListener("input", () => {
|
|
380
|
+
draft = input.value;
|
|
381
|
+
syncSendEnabled();
|
|
382
|
+
});
|
|
383
|
+
sendBtn.addEventListener("click", () => void submit());
|
|
384
|
+
pop.append(heading, input, note, sendBtn);
|
|
385
|
+
field = input;
|
|
386
|
+
if (!prefersReducedMotion) {
|
|
387
|
+
pop.style.opacity = "0";
|
|
388
|
+
pop.style.transform = "translateY(-4px) scale(.98)";
|
|
389
|
+
pop.style.transition =
|
|
390
|
+
"opacity 160ms cubic-bezier(0.16,1,0.3,1), transform 160ms cubic-bezier(0.16,1,0.3,1)";
|
|
391
|
+
requestAnimationFrame(() => {
|
|
392
|
+
pop.style.opacity = "1";
|
|
393
|
+
pop.style.transform = "translateY(0) scale(1)";
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
return pop;
|
|
397
|
+
};
|
|
398
|
+
const openPanel = () => {
|
|
399
|
+
if (panel)
|
|
80
400
|
return;
|
|
81
|
-
|
|
401
|
+
// Tapping the chip out of a Failed state opens the panel with the text
|
|
402
|
+
// preserved. It does NOT clear the counter.
|
|
403
|
+
if (revertTimer !== undefined) {
|
|
404
|
+
clearTimeout(revertTimer);
|
|
405
|
+
revertTimer = undefined;
|
|
406
|
+
}
|
|
407
|
+
setChipText(null);
|
|
408
|
+
panel = buildPanel();
|
|
409
|
+
container.appendChild(panel);
|
|
410
|
+
btn.setAttribute("aria-expanded", "true");
|
|
411
|
+
applyActive();
|
|
412
|
+
field?.focus();
|
|
413
|
+
outsideHandler = (e) => {
|
|
414
|
+
const target = e.target;
|
|
415
|
+
if (!target)
|
|
416
|
+
return;
|
|
417
|
+
if (panel && (panel.contains(target) || btn.contains(target)))
|
|
418
|
+
return;
|
|
419
|
+
closePanel(false);
|
|
420
|
+
};
|
|
421
|
+
document.addEventListener("mousedown", outsideHandler, true);
|
|
422
|
+
keyHandler = (e) => {
|
|
423
|
+
if (e.key === "Escape") {
|
|
424
|
+
e.stopPropagation();
|
|
425
|
+
closePanel(true);
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
document.addEventListener("keydown", keyHandler);
|
|
429
|
+
};
|
|
430
|
+
async function submit() {
|
|
431
|
+
const message = draft.trim();
|
|
432
|
+
if (message === "")
|
|
433
|
+
return;
|
|
434
|
+
closePanel(false);
|
|
435
|
+
setChipText(SENDING_LABEL);
|
|
436
|
+
setBusy(true);
|
|
82
437
|
try {
|
|
83
|
-
const screenshot = captureScreenshot();
|
|
84
438
|
const input = {
|
|
85
|
-
message
|
|
86
|
-
screenshot,
|
|
439
|
+
message,
|
|
440
|
+
screenshot: capture(),
|
|
87
441
|
spec: opts.spec ?? {},
|
|
88
442
|
sessionId: opts.sessionId,
|
|
89
443
|
};
|
|
90
|
-
await
|
|
91
|
-
|
|
92
|
-
|
|
444
|
+
await send(input);
|
|
445
|
+
recordSendSuccess();
|
|
446
|
+
draft = "";
|
|
447
|
+
setBusy(false);
|
|
448
|
+
setChipText(SENT_LABEL);
|
|
449
|
+
revertTimer = window.setTimeout(() => {
|
|
450
|
+
revertTimer = undefined;
|
|
451
|
+
setChipText(null);
|
|
452
|
+
}, SENT_REVERT_MS);
|
|
93
453
|
}
|
|
94
|
-
catch
|
|
95
|
-
|
|
96
|
-
|
|
454
|
+
catch {
|
|
455
|
+
// Any non-2xx, and any network/abort error. The draft is untouched.
|
|
456
|
+
recordSendFailure();
|
|
457
|
+
setBusy(false);
|
|
458
|
+
setChipText(FAILED_LABEL);
|
|
459
|
+
// Deliberately NOT logged: the message is viewer-authored, and a console
|
|
460
|
+
// line would put it in the creator's scrollback and their agent's context.
|
|
97
461
|
}
|
|
462
|
+
}
|
|
463
|
+
btn.addEventListener("mouseenter", () => {
|
|
464
|
+
if (panel)
|
|
465
|
+
return; // active state wins over hover
|
|
466
|
+
btn.style.background = HOVER_BG;
|
|
467
|
+
btn.style.borderColor = HOVER_BORDER;
|
|
468
|
+
});
|
|
469
|
+
btn.addEventListener("mouseleave", () => {
|
|
470
|
+
if (panel)
|
|
471
|
+
return;
|
|
472
|
+
applyIdle();
|
|
473
|
+
});
|
|
474
|
+
// Focus ring: a brand-blue outline that reads on any background. Set inline on
|
|
475
|
+
// focus/blur since there is no stylesheet on this surface for :focus-visible.
|
|
476
|
+
btn.addEventListener("focus", () => {
|
|
477
|
+
btn.style.outline = "2px solid #93b8f0";
|
|
478
|
+
btn.style.outlineOffset = "2px";
|
|
479
|
+
});
|
|
480
|
+
btn.addEventListener("blur", () => {
|
|
481
|
+
btn.style.outline = "none";
|
|
98
482
|
});
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
483
|
+
btn.addEventListener("click", () => {
|
|
484
|
+
if (panel)
|
|
485
|
+
closePanel(false);
|
|
486
|
+
else
|
|
487
|
+
openPanel();
|
|
488
|
+
});
|
|
489
|
+
container.appendChild(btn);
|
|
102
490
|
return () => {
|
|
491
|
+
if (revertTimer !== undefined) {
|
|
492
|
+
clearTimeout(revertTimer);
|
|
493
|
+
revertTimer = undefined;
|
|
494
|
+
}
|
|
495
|
+
closePanel(false);
|
|
103
496
|
btn.remove();
|
|
104
|
-
|
|
497
|
+
if (!released) {
|
|
498
|
+
released = true;
|
|
499
|
+
releaseClusterContainer();
|
|
500
|
+
}
|
|
105
501
|
};
|
|
106
502
|
}
|
package/dist/client/index.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
export { HarnessProvider } from "./HarnessProvider.js";
|
|
2
2
|
export type { HarnessProviderProps } from "./HarnessProvider.js";
|
|
3
|
-
export { sendAnnotation, captureScreenshot, mountFeedbackButton, type SendAnnotationOptions, type FeedbackButtonOptions, } from "./annotate.js";
|
|
3
|
+
export { sendAnnotation, captureScreenshot, mountFeedbackButton, shouldShowFeedback, type SendAnnotationOptions, type FeedbackButtonOptions, } from "./annotate.js";
|
|
4
4
|
export { mountShareButton, deriveShareUrl, isPhone, isPreviewOrigin, renderQr, PREVIEW_APEXES, type ShareButtonOptions, } from "./share.js";
|
|
5
|
+
export { isFramed, type FramingView } from "./previewOrigin.js";
|
|
5
6
|
export { getClusterContainer, releaseClusterContainer } from "./cluster.js";
|
|
6
7
|
export type { DiagEvent, LogEvent, NetworkEvent, TraceEvent } from "../shared/events.js";
|
|
7
8
|
export type { Annotation, AnnotationInput, AnnotationSpec, AnnotationStroke, AnnotationPin, } from "../shared/events.js";
|
package/dist/client/index.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// tree-shakes it). The relay and MCP server are NOT exported here; they are
|
|
4
4
|
// run-from-bin, not imported.
|
|
5
5
|
export { HarnessProvider } from "./HarnessProvider.js";
|
|
6
|
-
export { sendAnnotation, captureScreenshot, mountFeedbackButton, } from "./annotate.js";
|
|
6
|
+
export { sendAnnotation, captureScreenshot, mountFeedbackButton, shouldShowFeedback, } from "./annotate.js";
|
|
7
7
|
export { mountShareButton, deriveShareUrl, isPhone, isPreviewOrigin, renderQr, PREVIEW_APEXES, } from "./share.js";
|
|
8
|
+
export { isFramed } from "./previewOrigin.js";
|
|
8
9
|
export { getClusterContainer, releaseClusterContainer } from "./cluster.js";
|
|
@@ -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
|
+
}
|
package/dist/client/share.d.ts
CHANGED
|
@@ -1,35 +1,10 @@
|
|
|
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"];
|
|
1
|
+
export { isPhone, isPreviewOrigin, PREVIEW_APEXES } from "./previewOrigin.js";
|
|
11
2
|
/**
|
|
12
3
|
* The canonical preview URL to encode — `origin + "/"`, stable across in-app SPA
|
|
13
4
|
* navigation. Reads ONLY the origin, so a deep path, query-param secret, or hash is
|
|
14
5
|
* never encoded into the QR or the text field.
|
|
15
6
|
*/
|
|
16
7
|
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
8
|
/**
|
|
34
9
|
* Render the preview URL as an inline SVG QR. Same params as the CLI's `qrPage.ts`
|
|
35
10
|
* (`margin:2`, ECC `M`) so the on-screen QR is identical to the proven-scannable
|
package/dist/client/share.js
CHANGED
|
@@ -11,16 +11,12 @@
|
|
|
11
11
|
// whole module — and `qrcode`, reached only from here — tree-shakes out of prod.
|
|
12
12
|
import QRCode from "qrcode";
|
|
13
13
|
import { getClusterContainer, releaseClusterContainer } from "./cluster.js";
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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"];
|
|
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";
|
|
24
20
|
/**
|
|
25
21
|
* The canonical preview URL to encode — `origin + "/"`, stable across in-app SPA
|
|
26
22
|
* navigation. Reads ONLY the origin, so a deep path, query-param secret, or hash is
|
|
@@ -29,35 +25,6 @@ export const PREVIEW_APEXES = [".vincentt.dev"];
|
|
|
29
25
|
export function deriveShareUrl(location) {
|
|
30
26
|
return location.origin + "/";
|
|
31
27
|
}
|
|
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
28
|
/**
|
|
62
29
|
* Render the preview URL as an inline SVG QR. Same params as the CLI's `qrPage.ts`
|
|
63
30
|
* (`margin:2`, ECC `M`) so the on-screen QR is identical to the proven-scannable
|
package/dist/shared/events.d.ts
CHANGED
|
@@ -129,6 +129,20 @@ export interface RelayQuery {
|
|
|
129
129
|
* debugging rather than for "whose phone".
|
|
130
130
|
*/
|
|
131
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;
|
|
132
146
|
}
|
|
133
147
|
export interface RelayResult {
|
|
134
148
|
events: DiagEvent[];
|
|
@@ -171,6 +185,31 @@ export interface RelayResult {
|
|
|
171
185
|
* set rather than remembering one.
|
|
172
186
|
*/
|
|
173
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;
|
|
174
213
|
}
|
|
175
214
|
/** A freehand stroke over the frame, in normalized [0,1] frame coordinates. */
|
|
176
215
|
export interface AnnotationStroke {
|