@vincentt-xr/harness 1.2.0 → 1.4.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,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 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.
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
- * Mount a minimal floating "Send feedback" button. On click it collects a message,
52
- * captures the frame, and sends the annotation. Returns an unmount function. This
53
- * is the smallest useful Send action; a richer draw-on-frame overlay can replace
54
- * 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.
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.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;
61
201
  Object.assign(btn.style, {
62
- padding: "10px 14px",
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: "none",
65
- background: "linear-gradient(135deg,#7c3aed,#2563eb)",
66
- color: "#fff",
67
- font: "600 13px/1 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif",
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 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) => {
72
242
  btn.disabled = busy;
73
- btn.textContent = label ?? "Send feedback";
74
243
  btn.style.opacity = busy ? "0.6" : "1";
75
244
  };
76
- btn.addEventListener("click", async () => {
77
- const getMsg = opts.promptMessage ?? (() => window.prompt("Feedback for the agent:"));
78
- const message = await getMsg();
79
- 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)
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
- setBusy(true, "Sending…");
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: message.trim(),
86
- screenshot,
439
+ message,
440
+ screenshot: capture(),
87
441
  spec: opts.spec ?? {},
88
442
  sessionId: opts.sessionId,
89
443
  };
90
- await sendAnnotation(input, { relayHttpUrl: opts.relayHttpUrl });
91
- setBusy(false, "Sent ✓");
92
- 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);
93
453
  }
94
- catch (err) {
95
- setBusy(false, "Failed retry");
96
- 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.
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
- // Shares the top-right cluster with the other bare-DOM controls (Share today);
100
- // the container positions it, so no fixed-position styles here.
101
- getClusterContainer().appendChild(btn);
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
- releaseClusterContainer();
497
+ if (!released) {
498
+ released = true;
499
+ releaseClusterContainer();
500
+ }
105
501
  };
106
502
  }
@@ -0,0 +1,47 @@
1
+ import { type AnnouncedPreset, type ConsoleSetMediaSourceMessage, type HarnessAnnounceMessage } from "../shared/channel.js";
2
+ import { type FramingView } from "./previewOrigin.js";
3
+ /** The minimum of `window` this needs, so the whole module is testable without a DOM. */
4
+ export interface ChannelWindow {
5
+ parent: {
6
+ postMessage(message: unknown, targetOrigin: string): void;
7
+ } | null;
8
+ addEventListener(type: "message", listener: (event: MessageEvent) => void): void;
9
+ removeEventListener(type: "message", listener: (event: MessageEvent) => void): void;
10
+ }
11
+ export interface ChannelOptions {
12
+ /** The presets this app can play. Announced verbatim after validation. */
13
+ presets: AnnouncedPreset[];
14
+ /** Applied when the console names a preset. The app owns the swap; the console never does. */
15
+ onSetMediaSource: (presetId: string) => void;
16
+ /** The console origins commands may come from. Injected; defaults to the production set. */
17
+ allowedOrigins?: readonly string[];
18
+ /** Injectable for tests. Defaults to `window`. */
19
+ view?: ChannelWindow;
20
+ /** Injectable for tests. Defaults to `window`'s own handles. */
21
+ framing?: FramingView;
22
+ }
23
+ /**
24
+ * The announce, built from a preset list.
25
+ *
26
+ * Exported so a test can assert the payload's exact shape without a window, and
27
+ * so `url` never being on the wire is checkable at the boundary that produces it.
28
+ */
29
+ export declare function buildAnnounce(presets: AnnouncedPreset[]): HarnessAnnounceMessage;
30
+ /**
31
+ * Is this a command this app acts on?
32
+ *
33
+ * Shape check only — the origin is checked separately and BOTH must pass. Any
34
+ * other `type` on a well-formed envelope is ignored, which is what keeps `ack`,
35
+ * `error`, `log`, `count`, `re-announce` and `request` from becoming reachable
36
+ * by a console that decides to send one.
37
+ */
38
+ export declare function isSetMediaSourceCommand(data: unknown): data is ConsoleSetMediaSourceMessage;
39
+ /**
40
+ * Open the app's side of the channel. Returns a teardown.
41
+ *
42
+ * UNFRAMED IS A NO-OP. With no console around the app there is nothing to
43
+ * announce to and no chrome to command it, and the app keeps its own in-page
44
+ * controls instead. Nothing is posted and no listener is installed — the same
45
+ * absent-rather-than-inert rule the cluster controls follow.
46
+ */
47
+ export declare function openConsoleChannel(opts: ChannelOptions): () => void;