@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.
- package/dist/client/HarnessProvider.d.ts +4 -5
- package/dist/client/HarnessProvider.js +47 -16
- package/dist/client/annotate.d.ts +75 -6
- package/dist/client/annotate.js +428 -32
- package/dist/client/channel.d.ts +47 -0
- package/dist/client/channel.js +135 -0
- package/dist/client/consoleOrigin.d.ts +16 -0
- package/dist/client/consoleOrigin.js +39 -0
- package/dist/client/index.d.ts +5 -1
- package/dist/client/index.js +5 -1
- package/dist/client/previewOrigin.d.ts +50 -0
- package/dist/client/previewOrigin.js +76 -0
- package/dist/client/share.d.ts +15 -29
- package/dist/client/share.js +18 -43
- package/dist/shared/channel.d.ts +60 -0
- package/dist/shared/channel.js +45 -0
- package/dist/shared/events.d.ts +39 -0
- package/package.json +1 -1
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
|
}
|
|
@@ -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;
|