@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.
@@ -1,11 +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 creator's message + spec,
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.
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";
9
20
  /** Default relay HTTP base: the same origin the app is served on + the harness path. */
10
21
  function defaultRelayHttpUrl() {
11
22
  return `${window.location.origin}/__harness`;
@@ -46,59 +57,446 @@ export async function sendAnnotation(input, opts = {}) {
46
57
  throw new Error(`annotation POST failed: ${res.status}`);
47
58
  return (await res.json());
48
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
+ }
49
71
  /**
50
- * Mount a minimal floating "Send feedback" button. On click it collects a message,
51
- * captures the frame, and sends the annotation. Returns an unmount function. This
52
- * is the smallest useful Send action; a richer draw-on-frame overlay can replace
53
- * the capture/prompt without changing the wire contract.
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.
54
171
  */
55
172
  export function mountFeedbackButton(opts = {}) {
56
173
  if (typeof document === "undefined")
57
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;
58
193
  const btn = document.createElement("button");
59
- btn.textContent = "Send feedback";
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;
60
201
  Object.assign(btn.style, {
61
- position: "fixed",
62
- right: "12px",
63
- bottom: "12px",
64
- zIndex: "2147483647",
65
- padding: "10px 14px",
202
+ width: "36px",
203
+ height: "36px",
204
+ padding: "0",
205
+ display: "inline-flex",
206
+ alignItems: "center",
207
+ justifyContent: "center",
66
208
  borderRadius: "10px",
67
- border: "none",
68
- background: "linear-gradient(135deg,#7c3aed,#2563eb)",
69
- color: "#fff",
70
- font: "600 13px/1 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif",
71
- 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)",
72
213
  cursor: "pointer",
214
+ transition: "background 100ms, border-color 100ms",
73
215
  });
74
- const setBusy = (busy, label) => {
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) => {
75
242
  btn.disabled = busy;
76
- btn.textContent = label ?? "Send feedback";
77
243
  btn.style.opacity = busy ? "0.6" : "1";
78
244
  };
79
- btn.addEventListener("click", async () => {
80
- const getMsg = opts.promptMessage ?? (() => window.prompt("Feedback for the agent:"));
81
- const message = await getMsg();
82
- if (message == null || message.trim() === "")
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)
83
261
  return;
84
- setBusy(true, "Sending…");
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)
400
+ return;
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);
85
437
  try {
86
- const screenshot = captureScreenshot();
87
438
  const input = {
88
- message: message.trim(),
89
- screenshot,
439
+ message,
440
+ screenshot: capture(),
90
441
  spec: opts.spec ?? {},
91
442
  sessionId: opts.sessionId,
92
443
  };
93
- await sendAnnotation(input, { relayHttpUrl: opts.relayHttpUrl });
94
- setBusy(false, "Sent ✓");
95
- window.setTimeout(() => setBusy(false), 1500);
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);
96
453
  }
97
- catch (err) {
98
- setBusy(false, "Failed retry");
99
- console.error("[harness] annotation send failed:", err);
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.
100
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();
101
473
  });
102
- document.body.appendChild(btn);
103
- return () => btn.remove();
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";
482
+ });
483
+ btn.addEventListener("click", () => {
484
+ if (panel)
485
+ closePanel(false);
486
+ else
487
+ openPanel();
488
+ });
489
+ container.appendChild(btn);
490
+ return () => {
491
+ if (revertTimer !== undefined) {
492
+ clearTimeout(revertTimer);
493
+ revertTimer = undefined;
494
+ }
495
+ closePanel(false);
496
+ btn.remove();
497
+ if (!released) {
498
+ released = true;
499
+ releaseClusterContainer();
500
+ }
501
+ };
104
502
  }
@@ -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,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
+ export { mountShareButton, deriveShareUrl, isPhone, isPreviewOrigin, renderQr, PREVIEW_APEXES, type ShareButtonOptions, } from "./share.js";
5
+ export { isFramed, type FramingView } from "./previewOrigin.js";
6
+ export { getClusterContainer, releaseClusterContainer } from "./cluster.js";
4
7
  export type { DiagEvent, LogEvent, NetworkEvent, TraceEvent } from "../shared/events.js";
5
8
  export type { Annotation, AnnotationInput, AnnotationSpec, AnnotationStroke, AnnotationPin, } from "../shared/events.js";
@@ -3,4 +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
+ export { mountShareButton, deriveShareUrl, isPhone, isPreviewOrigin, renderQr, PREVIEW_APEXES, } from "./share.js";
8
+ export { isFramed } from "./previewOrigin.js";
9
+ 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)