reflet-sdk 0.4.2 → 0.5.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/feedback.js CHANGED
@@ -1,932 +1,12 @@
1
1
  "use client";
2
- import { a as REFLET_Z_INDEX, t as RefletContext } from "./react-context-3_IbhIFB.js";
3
- import { t as Reflet } from "./client-BYuSKJw5.js";
4
- import { useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
2
+ import { n as Reflet } from "./client-BWEBWk1k.js";
3
+ import { A as TrashIcon, C as MoreIcon, E as SquareIcon, F as SDK_VERSION, G as capturedFromCanvas, I as collectPageContext, K as releaseCapture, L as MAX_SELECTION_COMMENT_LENGTH, M as isDegenerate, N as normalizeRect, O as TargetIcon, P as DEFAULT_WIDGET_LABELS, R as buildElementSelection, S as HighlightIcon, T as SpotlightIcon, U as WIDGET_MARKER, W as captureViewport, _ as ChatIcon, b as CloseIcon, d as SelectionOutline, f as drawAnnotations, g as CameraIcon, h as BlurIcon, j as UndoIcon, k as TextIcon, l as submitWidgetFeedback, m as ArrowIcon, o as attachPreparedScreenshots, p as listenToKeydown, r as useFloatingPosition, s as createFeedbackTransport, t as ElementPicker, v as CheckIcon, w as PencilIcon, x as GripIcon, y as ChevronDownIcon } from "./picker-DDDpVcfr.js";
4
+ import { n as RefletContext, t as REFLET_Z_INDEX } from "./z-index-CKxBgZTG.js";
5
+ import { Suspense, lazy, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
5
6
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
6
7
  import { Slider } from "@base-ui/react/slider";
7
8
  import { ScrollArea } from "@base-ui/react/scroll-area";
8
9
  import { createPortal } from "react-dom";
9
- //#region src/feedback/core/capture.ts
10
- /** Marks every node the widget owns, so captures never include the panel. */
11
- const WIDGET_MARKER = "data-reflet-widget";
12
- const MAX_PIXEL_RATIO = 2;
13
- const DEFAULT_MAX_WIDTH = 2400;
14
- const MAX_INLINE_BYTES = 3e6;
15
- const TRANSPARENT_PIXEL = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
16
- const EXTERNAL_URL = /^(https?:)?\/\//i;
17
- const CSS_URL = /url\((['"]?)([^)'"]+)\1\)/g;
18
- function isExternalImageUrl(url) {
19
- if (!url || url.startsWith("data:") || url.startsWith("blob:")) return false;
20
- if (!EXTERNAL_URL.test(url)) return false;
21
- try {
22
- return new URL(url, window.location.href).origin !== window.location.origin;
23
- } catch {
24
- return false;
25
- }
26
- }
27
- function toDataUrl(url) {
28
- return new Promise((resolve) => {
29
- fetch(url, { mode: "cors" }).then(async (response) => {
30
- if (!response.ok) {
31
- resolve(null);
32
- return;
33
- }
34
- const blob = await response.blob();
35
- if (blob.size === 0 || blob.size > MAX_INLINE_BYTES) {
36
- resolve(null);
37
- return;
38
- }
39
- const reader = new FileReader();
40
- reader.onload = () => resolve(typeof reader.result === "string" ? reader.result : null);
41
- reader.onerror = () => resolve(null);
42
- reader.readAsDataURL(blob);
43
- }).catch(() => resolve(null));
44
- });
45
- }
46
- const PNG_NORMALIZE_TIMEOUT = 750;
47
- const CAPTURE_TIMEOUT = 1e4;
48
- const ANIMATION_SETTLE_TIMEOUT = 1e3;
49
- /**
50
- * Tiles and sections routinely animate in or reflow (grid drags, entrance
51
- * transitions); a capture taken mid-flight freezes them displaced. Waits for
52
- * animations touching the page to finish, capped so an infinite spinner can
53
- * never stall the report.
54
- */
55
- async function waitForPageAnimations() {
56
- if (typeof document.getAnimations !== "function") return;
57
- const start = performance.now();
58
- while (performance.now() - start < ANIMATION_SETTLE_TIMEOUT) {
59
- const running = document.getAnimations().filter((animation) => animation.playState === "running");
60
- if (running.length === 0) return;
61
- await Promise.race([Promise.allSettled(running.map((animation) => animation.finished)), new Promise((resolve) => setTimeout(resolve, 150))]);
62
- }
63
- }
64
- /**
65
- * snapdom awaits `decode()` on images created with `decoding="sync"` — a
66
- * promise Chrome 151 sometimes never settles (observed for cross-origin
67
- * webp, cached or inlined). Re-encoding through a canvas yields a PNG data
68
- * url, which decodes reliably; the load itself goes through `onload`, never
69
- * `decode()`.
70
- */
71
- function normalizeToPng(dataUrl) {
72
- return new Promise((resolve) => {
73
- const image = new Image();
74
- const timeout = setTimeout(() => resolve(dataUrl), PNG_NORMALIZE_TIMEOUT);
75
- image.onload = () => {
76
- clearTimeout(timeout);
77
- try {
78
- if (image.naturalWidth === 0 || image.naturalHeight === 0) {
79
- resolve(null);
80
- return;
81
- }
82
- const canvas = document.createElement("canvas");
83
- canvas.width = image.naturalWidth;
84
- canvas.height = image.naturalHeight;
85
- const context = canvas.getContext("2d");
86
- if (!context) {
87
- resolve(dataUrl);
88
- return;
89
- }
90
- context.drawImage(image, 0, 0);
91
- resolve(canvas.toDataURL("image/png"));
92
- } catch {
93
- resolve(null);
94
- }
95
- };
96
- image.onerror = () => {
97
- clearTimeout(timeout);
98
- resolve(null);
99
- };
100
- image.src = dataUrl;
101
- });
102
- }
103
- async function toInlineUrl(url) {
104
- const dataUrl = await toDataUrl(url);
105
- if (!dataUrl) return null;
106
- return normalizeToPng(dataUrl);
107
- }
108
- function remember(swaps, element, name) {
109
- swaps.push({
110
- element,
111
- name,
112
- value: element.getAttribute(name)
113
- });
114
- }
115
- async function inlineImgSource(img, swaps) {
116
- const src = img.getAttribute("src");
117
- if (!(src && isExternalImageUrl(src))) return;
118
- const dataUrl = await toInlineUrl(src);
119
- if (dataUrl) {
120
- if (img.hasAttribute("srcset")) {
121
- remember(swaps, img, "srcset");
122
- img.removeAttribute("srcset");
123
- }
124
- if (img.hasAttribute("sizes")) {
125
- remember(swaps, img, "sizes");
126
- img.removeAttribute("sizes");
127
- }
128
- }
129
- remember(swaps, img, "src");
130
- img.setAttribute("src", dataUrl ?? TRANSPARENT_PIXEL);
131
- }
132
- async function inlineBackgroundImages(element, swaps) {
133
- const style = element.getAttribute("style");
134
- if (!style) return;
135
- const urls = [...style.matchAll(CSS_URL)].map((match) => match[2] ?? "");
136
- if (!urls.some(isExternalImageUrl)) return;
137
- let next = style;
138
- for (const url of urls) {
139
- if (!isExternalImageUrl(url)) continue;
140
- const dataUrl = await toInlineUrl(url);
141
- next = next.split(url).join(dataUrl ?? TRANSPARENT_PIXEL);
142
- }
143
- if (next !== style) {
144
- remember(swaps, element, "style");
145
- element.setAttribute("style", next);
146
- }
147
- }
148
- /**
149
- * Swaps every cross-origin image under `root` for a data URL so the capture
150
- * cannot hit snapdom's decode-failure hang. Returns the undo list — always
151
- * run it, even after a failed capture.
152
- */
153
- async function inlineExternalImages(root) {
154
- const swaps = [];
155
- for (const img of [...root.querySelectorAll("img")]) await inlineImgSource(img, swaps);
156
- for (const el of [...root.querySelectorAll("[style*='url(']")]) await inlineBackgroundImages(el, swaps);
157
- return swaps;
158
- }
159
- function restoreInlinedImages(swaps) {
160
- for (const swap of swaps) if (swap.value === null) swap.element.removeAttribute(swap.name);
161
- else swap.element.setAttribute(swap.name, swap.value);
162
- }
163
- function buildSnapdomOptions(options) {
164
- const ratio = options.devicePixelRatio ?? 1;
165
- return {
166
- cache: "disabled",
167
- clip: options.element ? void 0 : "viewport",
168
- dpr: Math.min(Math.max(ratio || 1, 1), MAX_PIXEL_RATIO),
169
- embedFonts: true,
170
- exclude: [`[${WIDGET_MARKER}]`, ...options.excludeSelectors ?? []],
171
- fast: true,
172
- filter: options.filter,
173
- reconcile: true
174
- };
175
- }
176
- function fitWithin(size, maxWidth) {
177
- if (size.width <= maxWidth) return {
178
- height: size.height,
179
- scale: 1,
180
- width: size.width
181
- };
182
- const scale = maxWidth / size.width;
183
- return {
184
- height: Math.max(1, Math.round(size.height * scale)),
185
- scale,
186
- width: maxWidth
187
- };
188
- }
189
- function toBlob(canvas) {
190
- return new Promise((resolve) => {
191
- canvas.toBlob(resolve, "image/png");
192
- });
193
- }
194
- function downscale(canvas, target) {
195
- const resized = document.createElement("canvas");
196
- resized.width = target.width;
197
- resized.height = target.height;
198
- const context = resized.getContext("2d");
199
- if (!context) return canvas;
200
- context.imageSmoothingQuality = "high";
201
- context.drawImage(canvas, 0, 0, target.width, target.height);
202
- return resized;
203
- }
204
- /** Wraps a rendered canvas as an owned capture, downscaled to `maxWidth`. */
205
- async function capturedFromCanvas(source, maxWidth) {
206
- const fitted = fitWithin({
207
- height: source.height,
208
- width: source.width
209
- }, maxWidth);
210
- const output = fitted.scale === 1 ? source : downscale(source, fitted);
211
- const blob = await toBlob(output);
212
- if (!blob) return null;
213
- return {
214
- blob,
215
- height: output.height,
216
- mimeType: "image/png",
217
- objectUrl: URL.createObjectURL(blob),
218
- width: output.width
219
- };
220
- }
221
- /**
222
- * Renders what the user is looking at right now.
223
- *
224
- * Runs off the DOM rather than `getDisplayMedia` on purpose: a screen-share
225
- * permission prompt on every report would cost more reports than it captures.
226
- * Returns null instead of throwing — a screenshot is never worth losing the
227
- * written feedback.
228
- */
229
- async function captureViewport(options = {}) {
230
- try {
231
- const { snapdom } = await import("@zumer/snapdom");
232
- const target = options.element ?? document.documentElement;
233
- await waitForPageAnimations();
234
- const swaps = await inlineExternalImages(target);
235
- const shoot = (withoutImages = false) => {
236
- const attempt = snapdom.toCanvas(target, buildSnapdomOptions({
237
- devicePixelRatio: window.devicePixelRatio,
238
- element: options.element,
239
- excludeSelectors: options.excludeSelectors,
240
- filter: withoutImages ? (element) => element.tagName !== "IMG" : void 0
241
- }));
242
- attempt.catch(() => void 0);
243
- return Promise.race([attempt, new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error("capture timed out")), options.timeout ?? CAPTURE_TIMEOUT))]);
244
- };
245
- let canvas;
246
- try {
247
- canvas = await shoot();
248
- } catch {
249
- canvas = await shoot(true);
250
- } finally {
251
- restoreInlinedImages(swaps);
252
- }
253
- return await capturedFromCanvas(canvas, options.maxWidth ?? DEFAULT_MAX_WIDTH);
254
- } catch {
255
- return null;
256
- }
257
- }
258
- function releaseCapture(image) {
259
- if (image) URL.revokeObjectURL(image.objectUrl);
260
- }
261
- //#endregion
262
- //#region src/feedback/core/react-source.ts
263
- const FIBER_KEY_PREFIXES = ["__reactFiber$", "__reactInternalInstance$"];
264
- const MEMO_TYPE = Symbol.for("react.memo");
265
- const FORWARD_REF_TYPE = Symbol.for("react.forward_ref");
266
- const MAX_STACK_DEPTH = 6;
267
- const MAX_FIBER_WALK = 60;
268
- const MAX_OWNER_WALK = 8;
269
- const MIN_NAME_LENGTH = 4;
270
- const CAPITALIZED = /^[A-Z]/;
271
- const WORD_INSIDE = /[a-z]{2}/;
272
- const ORIGIN_PREFIX = /^[a-z][\w+.-]*:\/\/[^/]*/i;
273
- const WEBPACK_PREFIX = /^webpack-internal:\/{2,}(\.\/)?/;
274
- const STACK_LOCATION = /([^\s()]+):(\d+):(\d+)\)?\s*$/;
275
- const NON_SOURCE_FRAME = /node_modules|\/_next\/|\/chunks?\/|\/\.vite\/|\/dist\/|\/build\/|hot-update/;
276
- const SOURCE_EXTENSION = /\.(?:[cm]?[jt]sx?)$/;
277
- const SOURCE_ROOT_MARKERS = [
278
- "src",
279
- "app",
280
- "pages",
281
- "components",
282
- "features",
283
- "lib"
284
- ];
285
- function isRecord(value) {
286
- return typeof value === "object" && value !== null;
287
- }
288
- /** React attaches the fiber under a randomly suffixed key on the host node. */
289
- function getFiberFromNode(node) {
290
- for (const key of Object.getOwnPropertyNames(node)) for (const prefix of FIBER_KEY_PREFIXES) if (key.startsWith(prefix)) return Reflect.get(node, key) ?? null;
291
- return null;
292
- }
293
- function readName(value) {
294
- if (typeof value !== "function" && !isRecord(value)) return null;
295
- const displayName = Reflect.get(value, "displayName");
296
- if (typeof displayName === "string" && displayName) return displayName;
297
- const name = Reflect.get(value, "name");
298
- return typeof name === "string" && name ? name : null;
299
- }
300
- /**
301
- * Minified bundles hand out names like `Ur` or `Sn`. Reporting those as the
302
- * owning component actively misleads an agent, so only real words get through.
303
- */
304
- function isMeaningfulName(name) {
305
- if (name.includes("/") || name.includes("_")) return true;
306
- return name.length >= MIN_NAME_LENGTH && CAPITALIZED.test(name) && WORD_INSIDE.test(name);
307
- }
308
- function componentName(type, depth = 0) {
309
- if (typeof type === "string" || type === null || depth > 3) return null;
310
- if (isRecord(type)) {
311
- const marker = Reflect.get(type, "$$typeof");
312
- if (marker === MEMO_TYPE) return componentName(Reflect.get(type, "type"), depth + 1);
313
- if (marker === FORWARD_REF_TYPE) return componentName(Reflect.get(type, "render"), depth + 1);
314
- }
315
- const name = readName(type);
316
- return name && isMeaningfulName(name) ? name : null;
317
- }
318
- /** Component names owning the fiber, innermost first. */
319
- function resolveComponentStack(fiber) {
320
- const stack = [];
321
- let current = fiber;
322
- let steps = 0;
323
- while (isRecord(current) && steps < MAX_FIBER_WALK) {
324
- const name = componentName(Reflect.get(current, "type")) ?? componentName(Reflect.get(current, "elementType"));
325
- if (name && stack.at(-1) !== name) {
326
- stack.push(name);
327
- if (stack.length >= MAX_STACK_DEPTH) break;
328
- }
329
- current = Reflect.get(current, "return");
330
- steps++;
331
- }
332
- return stack;
333
- }
334
- function shortenSourcePath(raw) {
335
- const path = raw.replace(WEBPACK_PREFIX, "").replace(ORIGIN_PREFIX, "").split("?")[0];
336
- if (!path) return raw;
337
- for (const marker of SOURCE_ROOT_MARKERS) {
338
- if (path.startsWith(`${marker}/`)) return path;
339
- const index = path.lastIndexOf(`/${marker}/`);
340
- if (index !== -1) return path.slice(index + 1);
341
- }
342
- return path.split("/").filter(Boolean).slice(-3).join("/");
343
- }
344
- function formatLocation(fileName, line, column) {
345
- const base = `${shortenSourcePath(fileName)}:${line}`;
346
- return column === void 0 ? base : `${base}:${column}`;
347
- }
348
- function fromDebugSource(source) {
349
- if (!isRecord(source)) return;
350
- const fileName = Reflect.get(source, "fileName");
351
- const lineNumber = Reflect.get(source, "lineNumber");
352
- if (typeof fileName !== "string" || typeof lineNumber !== "number") return;
353
- const columnNumber = Reflect.get(source, "columnNumber");
354
- return formatLocation(fileName, lineNumber, typeof columnNumber === "number" ? columnNumber : void 0);
355
- }
356
- function fromDebugStack(stack) {
357
- const raw = isRecord(stack) ? Reflect.get(stack, "stack") : stack;
358
- if (typeof raw !== "string") return;
359
- for (const line of raw.split("\n")) {
360
- const match = STACK_LOCATION.exec(line);
361
- const file = match?.[1];
362
- if (!(match && file) || NON_SOURCE_FRAME.test(file)) continue;
363
- const path = file.replace(ORIGIN_PREFIX, "").split("?")[0] ?? "";
364
- if (!SOURCE_EXTENSION.test(path)) continue;
365
- return formatLocation(path, Number(match[2]), Number(match[3]));
366
- }
367
- }
368
- /**
369
- * `src/components/login-form.tsx:46:19` when the React build exposes it.
370
- * Production bundles strip debug info — callers must treat this as optional.
371
- */
372
- function resolveSourceLocation(fiber) {
373
- let current = fiber;
374
- let steps = 0;
375
- while (isRecord(current) && steps < MAX_OWNER_WALK) {
376
- const located = fromDebugSource(Reflect.get(current, "_debugSource")) ?? fromDebugStack(Reflect.get(current, "_debugStack"));
377
- if (located) return located;
378
- current = Reflect.get(current, "_debugOwner");
379
- steps++;
380
- }
381
- }
382
- function inspectReactElement(element) {
383
- const fiber = getFiberFromNode(element);
384
- return {
385
- componentStack: resolveComponentStack(fiber),
386
- sourceLocation: resolveSourceLocation(fiber)
387
- };
388
- }
389
- //#endregion
390
- //#region src/feedback/core/redact.ts
391
- /**
392
- * The picked element's markup leaves the reporter's browser and ends up in an
393
- * agent prompt, so it is scrubbed here: typed-in values, credentials and
394
- * anything marked `data-reflet-redact` never make the trip.
395
- */
396
- const EMAIL = /[\w.%+-]+@[\w.-]+\.[a-z]{2,}/gi;
397
- const LONG_TOKEN = /\b[\w-]{28,}\b/g;
398
- const OPAQUE_URI = /^(?:data|blob|javascript):/i;
399
- const WHITESPACE_RUN = /\s+/g;
400
- const URL_QUERY = /[?#]/;
401
- const REDACTED = "[redacted]";
402
- const ELLIPSIS = "…";
403
- const MAX_ATTRIBUTE_LENGTH = 120;
404
- const MAX_TEXT_LENGTH$1 = 200;
405
- const MAX_CHILD_DEPTH = 3;
406
- const MAX_CLONED_NODES = 400;
407
- const DROPPED_TAGS = /* @__PURE__ */ new Set([
408
- "script",
409
- "style",
410
- "noscript",
411
- "template"
412
- ]);
413
- const VALUE_HOLDERS = /* @__PURE__ */ new Set([
414
- "input",
415
- "textarea",
416
- "select",
417
- "option"
418
- ]);
419
- const URL_ATTRIBUTES = /* @__PURE__ */ new Set([
420
- "src",
421
- "srcset",
422
- "href",
423
- "poster",
424
- "action",
425
- "formaction"
426
- ]);
427
- function truncate$1(value, max) {
428
- return value.length <= max ? value : `${value.slice(0, max - 1)}${ELLIPSIS}`;
429
- }
430
- /** Emails and token-shaped strings, wherever they appear in reported text. */
431
- function maskSecrets(value) {
432
- return value.replace(EMAIL, REDACTED).replace(LONG_TOKEN, REDACTED);
433
- }
434
- const SENSITIVE_PARAM = /token|secret|pass(?:word|wd)?|pwd|auth|jwt|credential|session|sid|key|email|ssn|otp|code|signature|nonce/i;
435
- const LEADING_QUESTION_MARK = /^\?/;
436
- function decoded(value) {
437
- try {
438
- return decodeURIComponent(value);
439
- } catch {
440
- return value;
441
- }
442
- }
443
- /**
444
- * Query strings carry signed-in state (`?token=…`, `?userEmail=…`) straight
445
- * into stored reports and agent prompts. Values of anything credential- or
446
- * identity-shaped are masked; every other byte of the address stays intact.
447
- */
448
- function redactUrl(href) {
449
- let url;
450
- try {
451
- url = new URL(href);
452
- } catch {
453
- return maskSecrets(href);
454
- }
455
- const pairs = url.search.replace(LEADING_QUESTION_MARK, "").split("&").filter(Boolean);
456
- if (!pairs.some((pair) => SENSITIVE_PARAM.test(decoded(pair.split("=")[0] ?? "")))) return href;
457
- const query = pairs.map((pair) => {
458
- const equals = pair.indexOf("=");
459
- if (equals === -1 || !SENSITIVE_PARAM.test(decoded(pair.slice(0, equals)))) return pair;
460
- return `${pair.slice(0, equals)}=${REDACTED}`;
461
- }).join("&");
462
- return `${url.origin}${url.pathname}${query ? `?${query}` : ""}${url.hash}`;
463
- }
464
- function sanitizeUrl(value) {
465
- if (OPAQUE_URI.test(value)) return `${value.slice(0, value.indexOf(":"))}:${ELLIPSIS}`;
466
- return value.split(URL_QUERY)[0] ?? value;
467
- }
468
- function sanitizeAttributes(element) {
469
- const holdsTypedValue = VALUE_HOLDERS.has(element.localName);
470
- for (const { name, value } of Array.from(element.attributes)) {
471
- if (holdsTypedValue && name === "value") {
472
- element.removeAttribute(name);
473
- continue;
474
- }
475
- const cleaned = URL_ATTRIBUTES.has(name) ? sanitizeUrl(value) : maskSecrets(value);
476
- element.setAttribute(name, truncate$1(cleaned, MAX_ATTRIBUTE_LENGTH));
477
- }
478
- }
479
- function collapse(element) {
480
- element.replaceChildren(element.ownerDocument.createTextNode(ELLIPSIS));
481
- }
482
- function sanitizeTree(element, depth) {
483
- sanitizeAttributes(element);
484
- if (element.hasAttribute("data-reflet-redact")) {
485
- element.replaceChildren(element.ownerDocument.createTextNode(REDACTED));
486
- return;
487
- }
488
- if (element.localName === "svg") {
489
- element.replaceChildren();
490
- return;
491
- }
492
- for (const child of Array.from(element.childNodes)) {
493
- if (child.nodeType === Node.COMMENT_NODE) {
494
- child.remove();
495
- continue;
496
- }
497
- if (child.nodeType === Node.TEXT_NODE) {
498
- child.textContent = truncate$1(maskSecrets(child.textContent ?? ""), MAX_TEXT_LENGTH$1);
499
- continue;
500
- }
501
- if (!(child instanceof Element)) continue;
502
- if (DROPPED_TAGS.has(child.localName)) {
503
- child.remove();
504
- continue;
505
- }
506
- if (depth >= MAX_CHILD_DEPTH) {
507
- sanitizeAttributes(child);
508
- collapse(child);
509
- continue;
510
- }
511
- sanitizeTree(child, depth + 1);
512
- }
513
- }
514
- /** Redacted `outerHTML`, shallow-cloned when the subtree is too large to be useful. */
515
- function sanitizeMarkup(element, maxLength) {
516
- const isSmallEnough = element.querySelectorAll("*").length <= MAX_CLONED_NODES;
517
- const clone = element.cloneNode(isSmallEnough);
518
- if (!(clone instanceof Element)) return "";
519
- sanitizeTree(clone, 0);
520
- if (!isSmallEnough) collapse(clone);
521
- return truncate$1(clone.outerHTML.replace(WHITESPACE_RUN, " "), maxLength);
522
- }
523
- //#endregion
524
- //#region src/feedback/core/element-selector.ts
525
- const SAFE_ID = /^[A-Za-z][\w-]*$/;
526
- const WHITESPACE = /\s+/g;
527
- const MAX_LABEL_LENGTH = 80;
528
- const MAX_HTML_LENGTH = 600;
529
- const MAX_TEXT_LENGTH = 300;
530
- const MAX_REGION_LENGTH = 160;
531
- const MAX_HANDLE_VALUE_LENGTH = 80;
532
- const MAX_DEPTH = 12;
533
- const MIN_PATH_SEGMENTS = 2;
534
- const MAX_SELECTOR_LENGTH = 600;
535
- const MAX_SOURCE_LOCATION_LENGTH = 400;
536
- const MAX_SELECTION_COMMENT_LENGTH = 2e3;
537
- const TEST_ATTRIBUTES = [
538
- "data-testid",
539
- "data-test-id",
540
- "data-test",
541
- "data-cy",
542
- "data-qa"
543
- ];
544
- const NAMING_ATTRIBUTES = ["name", "aria-label"];
545
- const LANDMARK_SELECTOR = "dialog, [role=\"dialog\"], [role=\"alertdialog\"], main, nav, aside, header, footer, form, section, article";
546
- const HEADING_SELECTOR = "h1, h2, h3, h4, h5, h6, [role=\"heading\"]";
547
- /** Elements that start a new visual block — their text gets separated by a space. */
548
- const BLOCK_TAGS = /* @__PURE__ */ new Set([
549
- "address",
550
- "article",
551
- "aside",
552
- "blockquote",
553
- "dd",
554
- "details",
555
- "div",
556
- "dl",
557
- "dt",
558
- "fieldset",
559
- "figcaption",
560
- "figure",
561
- "footer",
562
- "form",
563
- "h1",
564
- "h2",
565
- "h3",
566
- "h4",
567
- "h5",
568
- "h6",
569
- "header",
570
- "hr",
571
- "li",
572
- "main",
573
- "nav",
574
- "ol",
575
- "p",
576
- "pre",
577
- "section",
578
- "summary",
579
- "table",
580
- "td",
581
- "th",
582
- "tr",
583
- "ul"
584
- ]);
585
- function visibleText(element) {
586
- const clone = element.cloneNode(true);
587
- if (!(clone instanceof Element)) return "";
588
- for (const node of clone.querySelectorAll("*")) if (BLOCK_TAGS.has(node.localName)) node.before(clone.ownerDocument.createTextNode(" "));
589
- return collapseText(maskSecrets(clone.textContent ?? ""));
590
- }
591
- function isUnique(selector) {
592
- try {
593
- return document.querySelectorAll(selector).length === 1;
594
- } catch {
595
- return false;
596
- }
597
- }
598
- function escapeAttributeValue(value) {
599
- return value.replace(/["\\]/g, "\\$&");
600
- }
601
- function attributeHandle(element, attribute, qualifier) {
602
- const value = element.getAttribute(attribute);
603
- if (!value || value.length > MAX_HANDLE_VALUE_LENGTH) return null;
604
- const selector = `${qualifier}[${attribute}="${escapeAttributeValue(value)}"]`;
605
- return isUnique(selector) ? selector : null;
606
- }
607
- /** Something an engineer would have written on purpose, unlike a positional path. */
608
- function uniqueHandle(element) {
609
- const id = element.getAttribute("id");
610
- if (id && SAFE_ID.test(id) && isUnique(`#${id}`)) return `#${id}`;
611
- for (const attribute of TEST_ATTRIBUTES) {
612
- const handle = attributeHandle(element, attribute, "");
613
- if (handle) return handle;
614
- }
615
- for (const attribute of NAMING_ATTRIBUTES) {
616
- const handle = attributeHandle(element, attribute, element.localName);
617
- if (handle) return handle;
618
- }
619
- return null;
620
- }
621
- function positionalSegment(element) {
622
- const tag = element.localName;
623
- const parent = element.parentElement;
624
- if (!parent) return tag;
625
- const sameTag = Array.from(parent.children).filter((child) => child.localName === tag);
626
- if (sameTag.length <= 1) return tag;
627
- return `${tag}:nth-of-type(${sameTag.indexOf(element) + 1})`;
628
- }
629
- /**
630
- * Shortest selector that still resolves back to `element` from the document.
631
- * Grows the path one ancestor at a time and stops as soon as it is unique, so
632
- * a stable handle anywhere up the tree replaces the whole positional chain.
633
- * A bare tag is never enough: positional paths keep a parent for context.
634
- */
635
- function buildSelector(element) {
636
- if (element === document.documentElement) return "html";
637
- if (element === document.body) return "body";
638
- const handle = uniqueHandle(element);
639
- if (handle) return handle;
640
- const segments = [];
641
- let current = element;
642
- for (let depth = 0; current && depth < MAX_DEPTH; depth++) {
643
- const ancestorHandle = current === element ? null : uniqueHandle(current);
644
- segments.unshift(ancestorHandle ?? positionalSegment(current));
645
- const candidate = segments.join(" > ");
646
- const isAnchored = segments.length >= MIN_PATH_SEGMENTS;
647
- if (ancestorHandle || current === document.body || isAnchored && isUnique(candidate)) return candidate;
648
- current = current.parentElement;
649
- }
650
- return segments.join(" > ");
651
- }
652
- function truncate(value, max) {
653
- return value.length <= max ? value : `${value.slice(0, max - 1)}…`;
654
- }
655
- function collapseText(value) {
656
- return value.replace(WHITESPACE, " ").trim();
657
- }
658
- function accessibleLabel(element) {
659
- const explicit = element.getAttribute("aria-label") ?? element.getAttribute("title") ?? element.getAttribute("alt") ?? element.getAttribute("placeholder");
660
- if (explicit?.trim()) return explicit.trim();
661
- return collapseText(element.textContent ?? "");
662
- }
663
- /** Short human label such as `button "Sign in"` or `div.card`. */
664
- function describeElement(element) {
665
- const tag = element.localName;
666
- const label = maskSecrets(accessibleLabel(element));
667
- if (label) return truncate(`${tag} "${label}"`, MAX_LABEL_LENGTH);
668
- const firstClass = element.classList.item(0);
669
- return truncate(firstClass ? `${tag}.${firstClass}` : tag, MAX_LABEL_LENGTH);
670
- }
671
- function describeLandmark(element) {
672
- const landmark = element.parentElement?.closest(LANDMARK_SELECTOR);
673
- if (!landmark) return null;
674
- const name = landmark.getAttribute("aria-label") ?? landmark.getAttribute("title");
675
- return name?.trim() ? `${landmark.localName} "${collapseText(name)}"` : landmark.localName;
676
- }
677
- function headingAbove(element) {
678
- const scope = element.closest(LANDMARK_SELECTOR) ?? document.body;
679
- const probe = document.createRange();
680
- probe.selectNodeContents(element);
681
- probe.collapse(true);
682
- let closest = null;
683
- for (const heading of scope.querySelectorAll(HEADING_SELECTOR)) if (probe.comparePoint(heading, 0) < 0) closest = heading;
684
- return collapseText(closest?.textContent ?? "") || null;
685
- }
686
- /**
687
- * Where the element sits from a reader's point of view — `dialog "Members" ›
688
- * Danger zone` says more to an agent than any DOM path can.
689
- */
690
- function describeRegion(element) {
691
- const landmark = describeLandmark(element);
692
- const heading = headingAbove(element);
693
- const parts = [landmark];
694
- if (heading && !landmark?.includes(`"${heading}"`)) parts.push(heading);
695
- const region = parts.filter((part) => part !== null).join(" › ");
696
- return region ? truncate(maskSecrets(region), MAX_REGION_LENGTH) : void 0;
697
- }
698
- function getElementRect(element) {
699
- const rect = element.getBoundingClientRect();
700
- return {
701
- height: Math.round(rect.height),
702
- width: Math.round(rect.width),
703
- x: Math.round(rect.x),
704
- y: Math.round(rect.y)
705
- };
706
- }
707
- /** Everything an agent needs to find the picked element back in the codebase. */
708
- function buildElementSelection(element) {
709
- const { componentStack, sourceLocation } = inspectReactElement(element);
710
- const text = truncate(visibleText(element), MAX_TEXT_LENGTH);
711
- return {
712
- componentStack,
713
- html: sanitizeMarkup(element, MAX_HTML_LENGTH),
714
- label: describeElement(element),
715
- rect: getElementRect(element),
716
- region: describeRegion(element),
717
- selector: truncate(buildSelector(element), MAX_SELECTOR_LENGTH),
718
- sourceLocation: sourceLocation ? truncate(sourceLocation, MAX_SOURCE_LOCATION_LENGTH) : sourceLocation,
719
- text: text || void 0
720
- };
721
- }
722
- //#endregion
723
- //#region src/feedback/core/page-context.ts
724
- const BROWSER_PATTERNS = [
725
- ["Edge", /Edg(?:e|A|iOS)?\/([\d.]+)/],
726
- ["Opera", /OPR\/([\d.]+)/],
727
- ["Samsung Internet", /SamsungBrowser\/([\d.]+)/],
728
- ["Firefox", /(?:Firefox|FxiOS)\/([\d.]+)/],
729
- ["Chrome", /(?:Chrome|CriOS)\/([\d.]+)/],
730
- ["Safari", /Version\/([\d.]+).*Safari/]
731
- ];
732
- const OS_PATTERNS = [
733
- ["iOS", /iPhone|iPad|iPod/],
734
- ["Android", /Android/],
735
- ["Windows", /Windows NT/],
736
- ["macOS", /Mac OS X|Macintosh/],
737
- ["ChromeOS", /CrOS/],
738
- ["Linux", /Linux|X11/]
739
- ];
740
- const MOBILE_HINT = /Mobile|iPhone|iPod/;
741
- const TABLET_HINT = /iPad|Tablet/;
742
- const TRAILING_ZERO = /\.0$/;
743
- const ANDROID = /Android/;
744
- function shortenVersion(version) {
745
- return version.split(".").slice(0, 2).join(".").replace(TRAILING_ZERO, "");
746
- }
747
- function detectDevice(userAgent) {
748
- if (TABLET_HINT.test(userAgent)) return "tablet";
749
- if (MOBILE_HINT.test(userAgent)) return "mobile";
750
- return ANDROID.test(userAgent) ? "tablet" : "desktop";
751
- }
752
- function parseUserAgent(userAgent) {
753
- const info = { device: detectDevice(userAgent) };
754
- for (const [name, pattern] of BROWSER_PATTERNS) {
755
- const version = pattern.exec(userAgent)?.[1];
756
- if (version) {
757
- info.browser = `${name} ${shortenVersion(version)}`;
758
- break;
759
- }
760
- }
761
- for (const [name, pattern] of OS_PATTERNS) if (pattern.test(userAgent)) {
762
- info.os = name;
763
- break;
764
- }
765
- return info;
766
- }
767
- const MAX_PAGE_TITLE_LENGTH = 300;
768
- const MAX_URL_LENGTH = 2e3;
769
- const MAX_USER_AGENT_LENGTH = 600;
770
- function clip(value, max) {
771
- return value.length <= max ? value : value.slice(0, max);
772
- }
773
- function optionalText(value, max) {
774
- return value?.trim() ? clip(value, max) : void 0;
775
- }
776
- function resolveTimezone() {
777
- try {
778
- return Intl.DateTimeFormat().resolvedOptions().timeZone;
779
- } catch {}
780
- }
781
- /** Everything about the page that a bug report needs and the DOM already knows. */
782
- function collectPageContext(options) {
783
- const userAgent = navigator.userAgent;
784
- const agent = parseUserAgent(userAgent);
785
- return {
786
- browser: agent.browser,
787
- device: agent.device,
788
- language: navigator.language,
789
- os: agent.os,
790
- pageTitle: optionalText(document.title, MAX_PAGE_TITLE_LENGTH),
791
- referrer: optionalText(document.referrer, MAX_URL_LENGTH),
792
- screen: {
793
- height: window.screen.height,
794
- width: window.screen.width
795
- },
796
- scroll: {
797
- x: window.scrollX,
798
- y: window.scrollY
799
- },
800
- sdkVersion: options?.sdkVersion,
801
- timezone: resolveTimezone(),
802
- url: redactUrl(window.location.href),
803
- userAgent: clip(userAgent, MAX_USER_AGENT_LENGTH),
804
- viewport: {
805
- devicePixelRatio: window.devicePixelRatio,
806
- height: window.innerHeight,
807
- width: window.innerWidth
808
- }
809
- };
810
- }
811
- //#endregion
812
- //#region src/feedback/types.ts
813
- const SDK_VERSION = "0.4.2";
814
- const DEFAULT_WIDGET_LABELS = {
815
- annotateHint: "Draw on the screenshot to point at the problem.",
816
- attachElement: "Attach this element",
817
- attachmentUploadFailed: "Your feedback was saved, but some screenshots are still pending. Retry to attach them.",
818
- attachScreenshot: "Attach a screenshot",
819
- back: "Back",
820
- cancel: "Cancel",
821
- captureFailed: "Screenshot unavailable. Try again or send without it.",
822
- capturing: "Taking screenshot…",
823
- clearAnnotations: "Clear drawing",
824
- descriptionPlaceholder: "What would you like to share?",
825
- dismissForDays: "Hide for {days} days",
826
- done: "Done",
827
- elementNote: "Comment on the picked element",
828
- elementNotePlaceholder: "What is wrong with this?",
829
- emailInvalid: "Enter a valid email address.",
830
- emailLabel: "Email",
831
- emailPlaceholder: "you@company.com",
832
- emailPromptBody: "We only use it to follow up on this report.",
833
- emailPromptTitle: "Add your email?",
834
- errorGeneric: "Something went wrong. Please try again.",
835
- errorTitleRequired: "Tell us a bit about it first.",
836
- minimize: "Minimize feedback",
837
- moreOptions: "More options",
838
- moveFeedback: "Move feedback",
839
- pickAnother: "Pick another",
840
- pickElement: "Point at an element",
841
- pickElementHint: "Pick the element you are talking about",
842
- recapture: "Retake",
843
- removeScreenshot: "Remove",
844
- resume: "Resume feedback",
845
- retakeHint: "Screenshot of this page",
846
- retryAttachments: "Retry attachments",
847
- screenshot: "Screenshot",
848
- sendWithoutEmail: "Send without email",
849
- submit: "Send feedback",
850
- successMessage: "We read every report. Thanks for taking the time.",
851
- successTitle: "Feedback sent",
852
- title: "Send feedback",
853
- titlePlaceholder: "Short summary",
854
- trigger: "Feedback",
855
- undo: "Undo"
856
- };
857
- //#endregion
858
- //#region src/feedback/core/annotations.ts
859
- const BOX_TOOLS = [
860
- "text",
861
- "rectangle",
862
- "spotlight",
863
- "highlight",
864
- "blur"
865
- ];
866
- const MIN_GESTURE_LENGTH = 8;
867
- const DEFAULT_PATH_TOLERANCE = 2.5;
868
- function normalizeRect(start, end) {
869
- return {
870
- height: Math.abs(end.y - start.y),
871
- width: Math.abs(end.x - start.x),
872
- x: Math.min(start.x, end.x),
873
- y: Math.min(start.y, end.y)
874
- };
875
- }
876
- function simplifyPath(points, tolerance = DEFAULT_PATH_TOLERANCE) {
877
- if (points.length < 3) return points;
878
- const [first, ...rest] = points;
879
- if (!first) return points;
880
- const kept = [first];
881
- let anchor = first;
882
- for (const point of rest.slice(0, -1)) if (Math.hypot(point.x - anchor.x, point.y - anchor.y) >= tolerance) {
883
- kept.push(point);
884
- anchor = point;
885
- }
886
- const last = points.at(-1);
887
- if (last) kept.push(last);
888
- return kept;
889
- }
890
- function isDegenerate(annotation) {
891
- if (annotation.tool === "text") return !annotation.text?.trim();
892
- if (annotation.tool === "pen") return (annotation.points?.length ?? 0) < 2;
893
- const { start, end } = annotation;
894
- return Math.hypot(end.x - start.x, end.y - start.y) < MIN_GESTURE_LENGTH;
895
- }
896
- function toWire(annotation) {
897
- const { color, start, end, tool } = annotation;
898
- if (tool === "pen") return {
899
- color,
900
- points: simplifyPath(annotation.points ?? []),
901
- type: tool,
902
- x: start.x,
903
- y: start.y
904
- };
905
- if (BOX_TOOLS.includes(tool)) {
906
- const rect = normalizeRect(start, end);
907
- return {
908
- color,
909
- height: rect.height,
910
- ...tool === "text" ? { text: annotation.text } : {},
911
- type: tool,
912
- width: rect.width,
913
- x: rect.x,
914
- y: rect.y
915
- };
916
- }
917
- return {
918
- color,
919
- endX: end.x,
920
- endY: end.y,
921
- type: tool,
922
- x: start.x,
923
- y: start.y
924
- };
925
- }
926
- function toWireAnnotations(annotations) {
927
- return annotations.filter((item) => !isDegenerate(item)).map(toWire);
928
- }
929
- //#endregion
930
10
  //#region src/feedback/ui/annotation/color-value.ts
931
11
  const DEFAULT_ANNOTATION_COLOR = {
932
12
  baseColor: "#ef4444",
@@ -943,118 +23,6 @@ function annotationColorHex({ baseColor, strengthPercent }) {
943
23
  }).join("")}`;
944
24
  }
945
25
  //#endregion
946
- //#region src/feedback/ui/icons.tsx
947
- function Svg({ children, className, size = 16 }) {
948
- return /* @__PURE__ */ jsx("svg", {
949
- "aria-hidden": "true",
950
- className,
951
- fill: "none",
952
- height: size,
953
- stroke: "currentColor",
954
- strokeLinecap: "round",
955
- strokeLinejoin: "round",
956
- strokeWidth: "1.8",
957
- viewBox: "0 0 24 24",
958
- width: size,
959
- children
960
- });
961
- }
962
- function ChatIcon({ className }) {
963
- return /* @__PURE__ */ jsx(Svg, {
964
- className,
965
- size: 15,
966
- children: /* @__PURE__ */ jsx("path", { d: "M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" })
967
- });
968
- }
969
- function CloseIcon({ className }) {
970
- return /* @__PURE__ */ jsx(Svg, {
971
- className,
972
- children: /* @__PURE__ */ jsx("path", { d: "M18 6 6 18M6 6l12 12" })
973
- });
974
- }
975
- function CameraIcon() {
976
- return /* @__PURE__ */ jsxs(Svg, { children: [/* @__PURE__ */ jsx("path", { d: "M3 8.5A1.5 1.5 0 0 1 4.5 7h2.2l1.2-2h8.2l1.2 2h2.2A1.5 1.5 0 0 1 21 8.5v9A1.5 1.5 0 0 1 19.5 19h-15A1.5 1.5 0 0 1 3 17.5z" }), /* @__PURE__ */ jsx("circle", {
977
- cx: "12",
978
- cy: "13",
979
- r: "3.2"
980
- })] });
981
- }
982
- function PencilIcon() {
983
- return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M16.5 4.5a2.1 2.1 0 0 1 3 3L8 19l-4 1 1-4z" }) });
984
- }
985
- function TargetIcon() {
986
- return /* @__PURE__ */ jsxs(Svg, { children: [/* @__PURE__ */ jsx("path", { d: "M12 3v3M12 18v3M3 12h3M18 12h3" }), /* @__PURE__ */ jsx("circle", {
987
- cx: "12",
988
- cy: "12",
989
- r: "5"
990
- })] });
991
- }
992
- function TrashIcon() {
993
- return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M4 7h16M9 7V5h6v2M6 7l1 13h10l1-13M10 11v5M14 11v5" }) });
994
- }
995
- function ArrowIcon() {
996
- return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M5 19 19 5M11 5h8v8" }) });
997
- }
998
- function UndoIcon() {
999
- return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M4 9h11a5 5 0 0 1 0 10h-5M4 9l4-4M4 9l4 4" }) });
1000
- }
1001
- function CheckIcon({ size = 28 }) {
1002
- return /* @__PURE__ */ jsx("svg", {
1003
- "aria-hidden": "true",
1004
- fill: "none",
1005
- height: size,
1006
- stroke: "currentColor",
1007
- strokeLinecap: "round",
1008
- strokeLinejoin: "round",
1009
- strokeWidth: "2.4",
1010
- viewBox: "0 0 24 24",
1011
- width: size,
1012
- children: /* @__PURE__ */ jsx("path", { d: "m5 13 4 4L19 7" })
1013
- });
1014
- }
1015
- function GripIcon() {
1016
- return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M9 5h.01M15 5h.01M9 12h.01M15 12h.01M9 19h.01M15 19h.01" }) });
1017
- }
1018
- function MoreIcon() {
1019
- return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M5 12h.01M12 12h.01M19 12h.01" }) });
1020
- }
1021
- function ChevronDownIcon() {
1022
- return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "m7 10 5 5 5-5" }) });
1023
- }
1024
- function SquareIcon() {
1025
- return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("rect", {
1026
- height: "14",
1027
- rx: "2",
1028
- width: "14",
1029
- x: "5",
1030
- y: "5"
1031
- }) });
1032
- }
1033
- function TextIcon() {
1034
- return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M5 6V4h14v2M12 4v16M8 20h8" }) });
1035
- }
1036
- function SpotlightIcon() {
1037
- return /* @__PURE__ */ jsxs(Svg, { children: [/* @__PURE__ */ jsx("path", { d: "M8 4H4v4M16 4h4v4M4 16v4h4M20 16v4h-4" }), /* @__PURE__ */ jsx("rect", {
1038
- height: "8",
1039
- rx: "1",
1040
- width: "8",
1041
- x: "8",
1042
- y: "8"
1043
- })] });
1044
- }
1045
- function HighlightIcon() {
1046
- return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M4 19h16M8 15l8-11 4 3-8 11-4-3ZM7 15l-2 3" }) });
1047
- }
1048
- function BlurIcon() {
1049
- return /* @__PURE__ */ jsxs(Svg, { children: [/* @__PURE__ */ jsx("rect", {
1050
- height: "14",
1051
- rx: "2",
1052
- width: "14",
1053
- x: "5",
1054
- y: "5"
1055
- }), /* @__PURE__ */ jsx("path", { d: "M8 8h8v8H8z" })] });
1056
- }
1057
- //#endregion
1058
26
  //#region src/feedback/ui/annotation/text/text-editor.tsx
1059
27
  function TextAnnotationEditor({ draft, onCommit, onCancel }) {
1060
28
  const [text, setText] = useState(draft.annotation.text ?? "");
@@ -1196,11 +164,11 @@ function useDetailsPopover() {
1196
164
  summaryRef.current?.focus();
1197
165
  };
1198
166
  document.addEventListener("pointerdown", dismissOutside);
1199
- document.addEventListener("keydown", dismissWithEscape, true);
167
+ const stopListeningToKeys = listenToKeydown(detailsRef.current, dismissWithEscape, true);
1200
168
  window.addEventListener("resize", reposition);
1201
169
  return () => {
1202
170
  document.removeEventListener("pointerdown", dismissOutside);
1203
- document.removeEventListener("keydown", dismissWithEscape, true);
171
+ stopListeningToKeys();
1204
172
  window.removeEventListener("resize", reposition);
1205
173
  };
1206
174
  }, []);
@@ -1444,207 +412,34 @@ function AnnotationToolbar({ editor, labels, drawing }) {
1444
412
  });
1445
413
  }
1446
414
  //#endregion
1447
- //#region src/feedback/core/arrow-geometry.ts
1448
- function arrowOutline(start, end) {
1449
- const dx = end.x - start.x;
1450
- const dy = end.y - start.y;
1451
- const length = Math.hypot(dx, dy);
1452
- if (length === 0) return {
1453
- points: [],
1454
- strokeWidth: 0
1455
- };
1456
- const shaftWidth = Math.min(20, Math.max(6, length * .025), length * .2);
1457
- const headLength = Math.min(length * .45, shaftWidth * 3);
1458
- const headHalfWidth = headLength * .55;
1459
- const shaftLength = length - headLength;
1460
- const pointOnArrow = (along, across) => ({
1461
- x: start.x + (dx * along - dy * across) / length,
1462
- y: start.y + (dy * along + dx * across) / length
1463
- });
1464
- return {
1465
- points: [
1466
- pointOnArrow(0, 0),
1467
- pointOnArrow(shaftLength, -shaftWidth / 2),
1468
- pointOnArrow(shaftLength, -headHalfWidth),
1469
- {
1470
- x: end.x,
1471
- y: end.y
1472
- },
1473
- pointOnArrow(shaftLength, headHalfWidth),
1474
- pointOnArrow(shaftLength, shaftWidth / 2)
1475
- ],
1476
- strokeWidth: Math.min(4, length * .02)
1477
- };
1478
- }
1479
- //#endregion
1480
- //#region src/feedback/core/annotation-renderer.ts
1481
- const STROKE_WIDTH = 3;
1482
- const HIGHLIGHT_ALPHA = .3;
1483
- const REDACTION_FILL = "#111827";
1484
- const PIXEL_BLOCK = 10;
1485
- function scalePoint(point, scale) {
1486
- return {
1487
- x: point.x * scale,
1488
- y: point.y * scale
1489
- };
1490
- }
1491
- function scaleRect(rect, scale) {
1492
- return {
1493
- height: rect.height * scale,
1494
- width: rect.width * scale,
1495
- x: rect.x * scale,
1496
- y: rect.y * scale
1497
- };
1498
- }
1499
- function isCanvasElement(value) {
1500
- return typeof HTMLCanvasElement !== "undefined" && value instanceof HTMLCanvasElement;
1501
- }
1502
- function redact(context, rect) {
1503
- const source = context.canvas;
1504
- const width = Math.max(1, Math.round(rect.width / PIXEL_BLOCK));
1505
- const height = Math.max(1, Math.round(rect.height / PIXEL_BLOCK));
1506
- if (isCanvasElement(source)) {
1507
- const offscreen = document.createElement("canvas");
1508
- offscreen.width = width;
1509
- offscreen.height = height;
1510
- const offscreenContext = offscreen.getContext("2d");
1511
- if (offscreenContext) {
1512
- offscreenContext.imageSmoothingEnabled = true;
1513
- offscreenContext.drawImage(source, rect.x, rect.y, rect.width, rect.height, 0, 0, width, height);
1514
- context.drawImage(offscreen, rect.x, rect.y, rect.width, rect.height);
1515
- return;
1516
- }
1517
- }
1518
- context.fillStyle = REDACTION_FILL;
1519
- context.fillRect(rect.x, rect.y, rect.width, rect.height);
1520
- }
1521
- function drawArrow(context, annotation, scale) {
1522
- const { points, strokeWidth } = arrowOutline(annotation.start, annotation.end);
1523
- const [first, ...rest] = points.map((point) => scalePoint(point, scale));
1524
- if (!first) return;
1525
- context.beginPath();
1526
- context.moveTo(first.x, first.y);
1527
- for (const point of rest) context.lineTo(point.x, point.y);
1528
- context.closePath();
1529
- context.lineWidth = strokeWidth * scale;
1530
- context.fill();
1531
- context.stroke();
1532
- }
1533
- function drawPen(context, points, scale) {
1534
- const [first, ...rest] = points;
1535
- if (!first) return;
1536
- const head = scalePoint(first, scale);
1537
- context.beginPath();
1538
- context.moveTo(head.x, head.y);
1539
- for (const point of rest) {
1540
- const scaled = scalePoint(point, scale);
1541
- context.lineTo(scaled.x, scaled.y);
1542
- }
1543
- context.stroke();
1544
- }
1545
- function drawSpotlight(context, rect) {
1546
- const { width, height } = context.canvas;
1547
- context.save();
1548
- context.fillStyle = REDACTION_FILL;
1549
- context.globalAlpha = .55;
1550
- context.fillRect(0, 0, width, rect.y);
1551
- context.fillRect(0, rect.y + rect.height, width, height - rect.y - rect.height);
1552
- context.fillRect(0, rect.y, rect.x, rect.height);
1553
- context.fillRect(rect.x + rect.width, rect.y, width - rect.x - rect.width, rect.height);
1554
- context.restore();
1555
- context.strokeRect(rect.x, rect.y, rect.width, rect.height);
1556
- }
1557
- function drawAnnotation(context, annotation, scale) {
1558
- const { color, tool } = annotation;
1559
- const rect = scaleRect(normalizeRect(annotation.start, annotation.end), scale);
1560
- context.save();
1561
- context.lineWidth = STROKE_WIDTH * scale;
1562
- context.lineCap = "round";
1563
- context.lineJoin = "round";
1564
- context.strokeStyle = color;
1565
- context.fillStyle = color;
1566
- switch (tool) {
1567
- case "text": {
1568
- const lines = (annotation.text ?? "").split("\n");
1569
- const lineHeight = rect.height / lines.length;
1570
- context.font = `${lineHeight / 1.4}px ui-sans-serif, system-ui, sans-serif`;
1571
- context.textBaseline = "top";
1572
- for (const [index, line] of lines.entries()) context.fillText(line, rect.x, rect.y + index * lineHeight);
1573
- break;
1574
- }
1575
- case "pen":
1576
- drawPen(context, annotation.points ?? [], scale);
1577
- break;
1578
- case "arrow":
1579
- drawArrow(context, annotation, scale);
1580
- break;
1581
- case "spotlight":
1582
- drawSpotlight(context, rect);
1583
- break;
1584
- case "rectangle":
1585
- context.strokeRect(rect.x, rect.y, rect.width, rect.height);
1586
- break;
1587
- case "highlight":
1588
- context.globalAlpha = HIGHLIGHT_ALPHA;
1589
- context.fillRect(rect.x, rect.y, rect.width, rect.height);
1590
- break;
1591
- default: redact(context, rect);
1592
- }
1593
- context.restore();
1594
- }
1595
- function drawAnnotations(context, annotations, options) {
1596
- const scale = options?.scale ?? 1;
1597
- for (const annotation of annotations) if (!isDegenerate(annotation)) drawAnnotation(context, annotation, scale);
1598
- }
1599
- function loadImage(url) {
1600
- return new Promise((resolve, reject) => {
1601
- const image = new Image();
1602
- image.onload = () => resolve(image);
1603
- image.onerror = () => reject(/* @__PURE__ */ new Error("Screenshot could not be decoded"));
1604
- image.src = url;
1605
- });
1606
- }
1607
- async function renderAnnotatedImage(source, annotations) {
1608
- if (annotations.length === 0) return null;
1609
- const canvas = document.createElement("canvas");
1610
- canvas.width = source.width;
1611
- canvas.height = source.height;
1612
- const context = canvas.getContext("2d");
1613
- if (!context) return null;
1614
- const image = await loadImage(source.objectUrl);
1615
- context.drawImage(image, 0, 0, source.width, source.height);
1616
- drawAnnotations(context, annotations);
1617
- return await capturedFromCanvas(canvas, source.width);
1618
- }
1619
- //#endregion
1620
- //#region src/feedback/ui/annotation/use-annotation-canvas.ts
1621
- function useAnnotationCanvas(capture, annotations) {
1622
- const canvasRef = useRef(null);
1623
- const [bitmap, setBitmap] = useState(null);
1624
- const [failedUrl, setFailedUrl] = useState(null);
1625
- useEffect(() => {
1626
- const image = new Image();
1627
- image.onload = () => setBitmap(image);
1628
- image.onerror = () => setFailedUrl(capture.objectUrl);
1629
- image.src = capture.objectUrl;
1630
- return () => {
1631
- image.onload = null;
1632
- image.onerror = null;
1633
- };
1634
- }, [capture.objectUrl]);
1635
- const isDecoded = bitmap?.src === capture.objectUrl;
1636
- useEffect(() => {
1637
- const canvas = canvasRef.current;
1638
- const context = canvas?.getContext("2d");
1639
- if (!(canvas && context && bitmap && isDecoded)) return;
1640
- context.clearRect(0, 0, canvas.width, canvas.height);
1641
- context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
1642
- drawAnnotations(context, annotations);
1643
- }, [
1644
- annotations,
1645
- bitmap,
1646
- isDecoded
1647
- ]);
415
+ //#region src/feedback/ui/annotation/use-annotation-canvas.ts
416
+ function useAnnotationCanvas(capture, annotations) {
417
+ const canvasRef = useRef(null);
418
+ const [bitmap, setBitmap] = useState(null);
419
+ const [failedUrl, setFailedUrl] = useState(null);
420
+ useEffect(() => {
421
+ const image = new Image();
422
+ image.onload = () => setBitmap(image);
423
+ image.onerror = () => setFailedUrl(capture.objectUrl);
424
+ image.src = capture.objectUrl;
425
+ return () => {
426
+ image.onload = null;
427
+ image.onerror = null;
428
+ };
429
+ }, [capture.objectUrl]);
430
+ const isDecoded = bitmap?.src === capture.objectUrl;
431
+ useEffect(() => {
432
+ const canvas = canvasRef.current;
433
+ const context = canvas?.getContext("2d");
434
+ if (!(canvas && context && bitmap && isDecoded)) return;
435
+ context.clearRect(0, 0, canvas.width, canvas.height);
436
+ context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
437
+ drawAnnotations(context, annotations);
438
+ }, [
439
+ annotations,
440
+ bitmap,
441
+ isDecoded
442
+ ]);
1648
443
  return {
1649
444
  canvasRef,
1650
445
  hasError: failedUrl === capture.objectUrl,
@@ -1870,6 +665,42 @@ function Annotator({ capture, labels, editor, note }) {
1870
665
  });
1871
666
  }
1872
667
  //#endregion
668
+ //#region src/feedback/ui/devtools-owner.ts
669
+ const claims = /* @__PURE__ */ new Map();
670
+ const listeners = /* @__PURE__ */ new Set();
671
+ function notify() {
672
+ for (const listener of listeners) listener();
673
+ }
674
+ function subscribe(listener) {
675
+ listeners.add(listener);
676
+ return () => listeners.delete(listener);
677
+ }
678
+ function currentOwner() {
679
+ for (const [token, claim] of claims) if (claim.hasWidget) return token;
680
+ return claims.keys().next().value;
681
+ }
682
+ /**
683
+ * One devtools bar per page, however many RefletFeedback instances mount:
684
+ * the one with a visible widget wins, so its bar can step aside for its panel.
685
+ */
686
+ function useOwnsDevtools(wantsDevtools, hasWidget) {
687
+ const [token] = useState(() => Symbol("reflet-devtools"));
688
+ useEffect(() => {
689
+ if (!wantsDevtools) return;
690
+ claims.set(token, { hasWidget });
691
+ notify();
692
+ return () => {
693
+ claims.delete(token);
694
+ notify();
695
+ };
696
+ }, [
697
+ hasWidget,
698
+ token,
699
+ wantsDevtools
700
+ ]);
701
+ return useSyncExternalStore(subscribe, () => currentOwner() === token, () => false);
702
+ }
703
+ //#endregion
1873
704
  //#region src/feedback/ui/launcher.tsx
1874
705
  function Launcher({ isOpen, label, onClick, buttonRef }) {
1875
706
  return /* @__PURE__ */ jsxs("button", {
@@ -2410,1813 +1241,1199 @@ function FeedbackPanel({ floatingControls, labels, state }) {
2410
1241
  });
2411
1242
  }
2412
1243
  //#endregion
2413
- //#region src/feedback/ui/selection-outline.tsx
1244
+ //#region src/feedback/core/console-recorder.ts
1245
+ const DEFAULT_LIMIT = 30;
1246
+ const MAX_MESSAGE_LENGTH = 1e3;
1247
+ const RECORDED_METHODS = ["error", "warn"];
1248
+ function serializeArgument(value) {
1249
+ if (typeof value === "string") return value;
1250
+ if (value instanceof Error) return `${value.name}: ${value.message}`;
1251
+ if (value === null || value === void 0) return String(value);
1252
+ if (typeof value === "object") try {
1253
+ return JSON.stringify(value) ?? "[unserializable]";
1254
+ } catch {
1255
+ return "[unserializable]";
1256
+ }
1257
+ if (typeof value === "function") return "[function]";
1258
+ return String(value);
1259
+ }
1260
+ function formatMessage(args) {
1261
+ const message = args.map(serializeArgument).join(" ");
1262
+ return message.length > MAX_MESSAGE_LENGTH ? `${message.slice(0, 999)}…` : message;
1263
+ }
2414
1264
  /**
2415
- * Keeps the picked element ringed while the reporter writes about it, so the
2416
- * composer parked next to it is visibly about that element.
1265
+ * Buffers the console noise a user saw right before reporting a bug.
1266
+ * Wrappers delegate to the previous implementation, so nothing is swallowed.
2417
1267
  */
2418
- function SelectionOutline({ node }) {
2419
- const boxRef = useRef(null);
2420
- useEffect(() => {
2421
- const box = boxRef.current;
2422
- if (!(box && node)) return;
2423
- let frame = 0;
2424
- const draw = () => {
2425
- frame = 0;
2426
- const rect = node.getBoundingClientRect();
2427
- const hidden = rect.width === 0 || rect.height === 0 || rect.bottom < 0 || rect.top > window.innerHeight;
2428
- box.style.opacity = hidden ? "0" : "1";
2429
- box.style.width = `${rect.width}px`;
2430
- box.style.height = `${rect.height}px`;
2431
- box.style.transform = `translate(${rect.left}px, ${rect.top}px)`;
2432
- };
2433
- const schedule = () => {
2434
- frame ||= requestAnimationFrame(draw);
2435
- };
2436
- draw();
2437
- const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(draw);
2438
- observer?.observe(node);
2439
- window.addEventListener("scroll", schedule, {
2440
- capture: true,
2441
- passive: true
1268
+ function startConsoleRecorder(options) {
1269
+ const limit = options?.limit ?? DEFAULT_LIMIT;
1270
+ const buffer = [];
1271
+ let active = true;
1272
+ const push = (level, message) => {
1273
+ if (!(active && message)) return;
1274
+ buffer.push({
1275
+ level,
1276
+ message,
1277
+ timestamp: Date.now()
2442
1278
  });
2443
- window.addEventListener("resize", schedule);
2444
- return () => {
2445
- cancelAnimationFrame(frame);
2446
- observer?.disconnect();
2447
- window.removeEventListener("scroll", schedule, true);
2448
- window.removeEventListener("resize", schedule);
1279
+ if (buffer.length > limit) buffer.splice(0, buffer.length - limit);
1280
+ };
1281
+ const originals = /* @__PURE__ */ new Map();
1282
+ const wrappers = /* @__PURE__ */ new Map();
1283
+ for (const method of RECORDED_METHODS) {
1284
+ const original = console[method];
1285
+ const wrapper = (...args) => {
1286
+ push(method, formatMessage(args));
1287
+ Reflect.apply(original, console, args);
2449
1288
  };
2450
- }, [node]);
2451
- if (!node) return null;
2452
- return /* @__PURE__ */ jsx("div", {
2453
- "aria-hidden": "true",
2454
- className: "selection-outline",
2455
- ref: boxRef
2456
- });
2457
- }
2458
- //#endregion
2459
- //#region src/feedback/ui/visible-viewport.ts
2460
- /**
2461
- * The part of the page the reporter can actually see, in fixed-position
2462
- * coordinates. On mobile the keyboard shrinks this without touching
2463
- * `window.innerHeight`, so anything that must stay reachable measures here.
2464
- */
2465
- function visibleViewport() {
2466
- const viewport = window.visualViewport;
2467
- const left = viewport?.offsetLeft ?? 0;
2468
- const top = viewport?.offsetTop ?? 0;
2469
- const width = viewport?.width ?? window.innerWidth;
2470
- const height = viewport?.height ?? window.innerHeight;
2471
- return {
2472
- bottom: window.innerHeight - top - height,
2473
- height,
2474
- left,
2475
- right: window.innerWidth - left - width,
2476
- top,
2477
- width
1289
+ originals.set(method, original);
1290
+ wrappers.set(method, wrapper);
1291
+ console[method] = wrapper;
1292
+ }
1293
+ const onError = (event) => {
1294
+ const where = event.filename ? ` (${event.filename}:${event.lineno}:${event.colno})` : "";
1295
+ push("error", `${event.message}${where}`);
2478
1296
  };
2479
- }
2480
- function onViewportChange(update) {
2481
- window.addEventListener("resize", update);
2482
- window.visualViewport?.addEventListener("resize", update);
2483
- window.visualViewport?.addEventListener("scroll", update);
2484
- return () => {
2485
- window.removeEventListener("resize", update);
2486
- window.visualViewport?.removeEventListener("resize", update);
2487
- window.visualViewport?.removeEventListener("scroll", update);
1297
+ const onRejection = (event) => {
1298
+ push("error", `Unhandled rejection: ${serializeArgument(event.reason)}`);
2488
1299
  };
2489
- }
2490
- //#endregion
2491
- //#region src/feedback/ui/floating/use-floating-position.ts
2492
- const EDGE_GAP$1 = 12;
2493
- const KEYBOARD_STEP = 24;
2494
- const KEY_DIRECTIONS = {
2495
- ArrowDown: {
2496
- x: 0,
2497
- y: KEYBOARD_STEP
2498
- },
2499
- ArrowLeft: {
2500
- x: -24,
2501
- y: 0
2502
- },
2503
- ArrowRight: {
2504
- x: KEYBOARD_STEP,
2505
- y: 0
2506
- },
2507
- ArrowUp: {
2508
- x: 0,
2509
- y: -24
2510
- }
2511
- };
2512
- function clampPosition(point, bounds, viewport = visibleViewport()) {
1300
+ window.addEventListener("error", onError);
1301
+ window.addEventListener("unhandledrejection", onRejection);
2513
1302
  return {
2514
- x: Math.max(viewport.left + EDGE_GAP$1, Math.min(point.x, viewport.left + viewport.width - bounds.width - EDGE_GAP$1)),
2515
- y: Math.max(viewport.top + EDGE_GAP$1, Math.min(point.y, viewport.top + viewport.height - bounds.height - EDGE_GAP$1))
1303
+ events: () => buffer.slice(),
1304
+ stop: () => {
1305
+ if (!active) return;
1306
+ active = false;
1307
+ buffer.length = 0;
1308
+ for (const method of RECORDED_METHODS) {
1309
+ const original = originals.get(method);
1310
+ if (original && console[method] === wrappers.get(method)) console[method] = original;
1311
+ }
1312
+ window.removeEventListener("error", onError);
1313
+ window.removeEventListener("unhandledrejection", onRejection);
1314
+ }
2516
1315
  };
2517
1316
  }
2518
- function useFloatingPosition(corner = "bottom-right", isPanelOpen = false) {
2519
- const rootRef = useRef(null);
2520
- const gesture = useRef(null);
2521
- const [position, setPosition] = useState(null);
2522
- const [positionedPanelOpen, setPositionedPanelOpen] = useState(isPanelOpen);
2523
- const [viewport, setViewport] = useState(visibleViewport);
2524
- const horizontalEdge = corner.endsWith("right") ? "right" : "left";
2525
- const verticalEdge = corner.startsWith("bottom") ? "bottom" : "top";
2526
- if (positionedPanelOpen !== isPanelOpen) {
2527
- setPositionedPanelOpen(isPanelOpen);
2528
- setPosition(null);
2529
- }
2530
- const moveTo = useCallback((point, bounds) => {
2531
- const fitted = clampPosition(point, bounds);
2532
- const visible = visibleViewport();
2533
- const anchor = {
2534
- x: horizontalEdge === "right" ? visible.left + visible.width - fitted.x - bounds.width : fitted.x - visible.left,
2535
- y: verticalEdge === "bottom" ? visible.top + visible.height - fitted.y - bounds.height : fitted.y - visible.top
2536
- };
2537
- setPosition((current) => current?.x === anchor.x && current.y === anchor.y ? current : anchor);
2538
- }, [horizontalEdge, verticalEdge]);
2539
- const keepInViewport = useCallback(() => {
2540
- const bounds = rootRef.current?.getBoundingClientRect();
2541
- if (!bounds) return;
2542
- const fitted = clampPosition(bounds, bounds, viewport);
2543
- if (Math.abs(fitted.x - bounds.x) > .5 || Math.abs(fitted.y - bounds.y) > .5) moveTo(fitted, bounds);
2544
- }, [moveTo, viewport]);
2545
- useLayoutEffect(() => {
2546
- keepInViewport();
2547
- }, [keepInViewport]);
2548
- useEffect(() => {
2549
- const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(keepInViewport);
2550
- if (rootRef.current) observer?.observe(rootRef.current);
2551
- const stopListening = onViewportChange(() => setViewport(visibleViewport()));
2552
- return () => {
2553
- observer?.disconnect();
2554
- stopListening();
2555
- };
2556
- }, [keepInViewport]);
2557
- const onPointerDown = (event) => {
2558
- const bounds = rootRef.current?.getBoundingClientRect();
2559
- if (!bounds || event.button !== 0) return;
2560
- event.currentTarget.setPointerCapture(event.pointerId);
2561
- gesture.current = {
2562
- x: event.clientX - bounds.x,
2563
- y: event.clientY - bounds.y
2564
- };
2565
- };
2566
- const onPointerMove = (event) => {
2567
- const bounds = rootRef.current?.getBoundingClientRect();
2568
- const grab = gesture.current;
2569
- if (!(bounds && grab && event.currentTarget.hasPointerCapture(event.pointerId))) return;
2570
- moveTo({
2571
- x: event.clientX - grab.x,
2572
- y: event.clientY - grab.y
2573
- }, bounds);
1317
+ //#endregion
1318
+ //#region src/feedback/ui/state/use-feedback-submission.ts
1319
+ function useFeedbackSubmission(options) {
1320
+ const [activeRequest, setActiveRequest] = useState(null);
1321
+ const [pendingReport, setPendingReport] = useState(null);
1322
+ const requestRef = useRef(null);
1323
+ useEffect(() => () => {
1324
+ requestRef.current = null;
1325
+ }, []);
1326
+ const resetSubmission = useCallback(() => {
1327
+ requestRef.current = null;
1328
+ setPendingReport(null);
1329
+ setActiveRequest(null);
1330
+ }, []);
1331
+ const finishSubmission = (result) => {
1332
+ if (result.pendingScreenshots.length > 0) {
1333
+ setPendingReport(result);
1334
+ options.onError(options.partialUploadMessage);
1335
+ return;
1336
+ }
1337
+ setPendingReport(null);
1338
+ options.onSubmitted(result.feedbackId);
2574
1339
  };
2575
- const onKeyDown = (event) => {
2576
- const direction = KEY_DIRECTIONS[event.key];
2577
- const bounds = rootRef.current?.getBoundingClientRect();
2578
- if (!(bounds && direction)) return;
2579
- event.preventDefault();
2580
- moveTo({
2581
- x: bounds.x + direction.x,
2582
- y: bounds.y + direction.y
2583
- }, bounds);
1340
+ const submit = async (draft) => {
1341
+ if (requestRef.current) return;
1342
+ if (!options.client) {
1343
+ options.onError("Reflet is missing a publicKey. Pass one to RefletFeedback or RefletProvider.");
1344
+ return;
1345
+ }
1346
+ const request = {};
1347
+ requestRef.current = request;
1348
+ setActiveRequest(request);
1349
+ options.onError(null);
1350
+ try {
1351
+ const transport = createFeedbackTransport(options.client);
1352
+ const result = pendingReport ? await attachPreparedScreenshots(transport, pendingReport) : await submitWidgetFeedback(transport, draft);
1353
+ if (requestRef.current === request) finishSubmission(result);
1354
+ } catch (error) {
1355
+ if (requestRef.current === request) options.onError(error instanceof Error ? error.message : "Something went wrong. Please try again.");
1356
+ } finally {
1357
+ if (requestRef.current === request) {
1358
+ requestRef.current = null;
1359
+ setActiveRequest(null);
1360
+ }
1361
+ }
2584
1362
  };
2585
- const viewportStyles = `.root { --rf-viewport-width: ${viewport.width}px; --rf-viewport-top: ${viewport.top}px; --rf-viewport-bottom: ${viewport.bottom}px; --rf-viewport-left: ${viewport.left}px; --rf-viewport-right: ${viewport.right}px; }`;
2586
- const anchorStyles = position ? `.root[data-moved="true"] { left: auto; right: auto; top: auto; bottom: auto; ${horizontalEdge}: ${position.x + viewport[horizontalEdge]}px; ${verticalEdge}: ${position.y + viewport[verticalEdge]}px; }` : "";
2587
1363
  return {
2588
- handleProps: {
2589
- onKeyDown,
2590
- onLostPointerCapture: () => {
2591
- gesture.current = null;
2592
- },
2593
- onPointerCancel: () => {
2594
- gesture.current = null;
2595
- },
2596
- onPointerDown,
2597
- onPointerMove,
2598
- onPointerUp: () => {
2599
- gesture.current = null;
2600
- }
2601
- },
2602
- position,
2603
- rootRef,
2604
- styles: viewportStyles + anchorStyles
1364
+ hasPendingAttachments: pendingReport !== null,
1365
+ isEditingDisabled: activeRequest !== null || pendingReport !== null,
1366
+ isSubmitting: activeRequest !== null,
1367
+ resetSubmission,
1368
+ submit
2605
1369
  };
2606
1370
  }
2607
1371
  //#endregion
2608
- //#region src/feedback/ui/floating/floating-widget.tsx
2609
- function FloatingWidget({ labels, position, state }) {
2610
- const [minimized, setMinimized] = useState(false);
2611
- const launcherRef = useRef(null);
2612
- const showPanel = state.isOpen && !minimized;
2613
- const floating = useFloatingPosition(position, showPanel);
2614
- const minimize = () => {
2615
- setMinimized(true);
2616
- requestAnimationFrame(() => launcherRef.current?.focus());
2617
- };
2618
- useEffect(() => {
2619
- if (state.step === "compose") state.annotationTrigger?.button.focus({ preventScroll: true });
2620
- }, [state.step, state.annotationTrigger]);
1372
+ //#region src/feedback/ui/state/use-screenshot-drafts.ts
1373
+ function releaseDraft(draft) {
1374
+ releaseCapture(draft?.image);
1375
+ releaseCapture(draft?.closeUp);
1376
+ }
1377
+ function useScreenshotDrafts(captureFailed, onError) {
1378
+ const [screenshots, setScreenshots] = useState([]);
1379
+ const [activeId, setActiveId] = useState(null);
1380
+ const [pendingCapture, setPendingCapture] = useState(null);
1381
+ const ownedDrafts = useRef(/* @__PURE__ */ new Map());
1382
+ const captureRequest = useRef(null);
2621
1383
  useEffect(() => {
2622
- if (!showPanel || state.step !== "compose") return;
2623
- const onKeyDown = (event) => {
2624
- if (event instanceof KeyboardEvent && event.key === "Escape" && !event.defaultPrevented) {
2625
- setMinimized(true);
2626
- requestAnimationFrame(() => launcherRef.current?.focus());
2627
- }
2628
- };
2629
- const widgetRoot = floating.rootRef.current?.getRootNode();
2630
- document.addEventListener("keydown", onKeyDown);
2631
- widgetRoot?.addEventListener("keydown", onKeyDown);
1384
+ const drafts = ownedDrafts.current;
2632
1385
  return () => {
2633
- document.removeEventListener("keydown", onKeyDown);
2634
- widgetRoot?.removeEventListener("keydown", onKeyDown);
1386
+ captureRequest.current = null;
1387
+ for (const draft of drafts.values()) releaseDraft(draft);
1388
+ drafts.clear();
2635
1389
  };
2636
- }, [
2637
- floating.rootRef,
2638
- showPanel,
2639
- state.step
1390
+ }, []);
1391
+ const storeScreenshot = useCallback((screenshot) => {
1392
+ releaseDraft(ownedDrafts.current.get(screenshot.id));
1393
+ ownedDrafts.current.set(screenshot.id, screenshot);
1394
+ setScreenshots((previous) => {
1395
+ return previous.some(({ id }) => id === screenshot.id) ? previous.map((item) => item.id === screenshot.id ? screenshot : item) : [...previous, screenshot];
1396
+ });
1397
+ setActiveId(screenshot.id);
1398
+ }, []);
1399
+ const cancelPendingCapture = useCallback(() => {
1400
+ captureRequest.current = null;
1401
+ setPendingCapture(null);
1402
+ }, []);
1403
+ const takeScreenshot = useCallback(async (request) => {
1404
+ captureRequest.current = request;
1405
+ setPendingCapture(request);
1406
+ onError(null);
1407
+ const context = collectPageContext({ sdkVersion: SDK_VERSION });
1408
+ const image = await captureViewport().catch(() => null);
1409
+ if (captureRequest.current !== request) {
1410
+ releaseCapture(image);
1411
+ return;
1412
+ }
1413
+ cancelPendingCapture();
1414
+ if (!image) {
1415
+ onError(captureFailed);
1416
+ return;
1417
+ }
1418
+ storeScreenshot({
1419
+ ...request,
1420
+ annotations: [],
1421
+ context,
1422
+ image
1423
+ });
1424
+ }, [
1425
+ cancelPendingCapture,
1426
+ captureFailed,
1427
+ onError,
1428
+ storeScreenshot
2640
1429
  ]);
2641
- return /* @__PURE__ */ jsxs("div", {
2642
- className: "root",
2643
- "data-editing": state.isOpen && (state.step === "picking" || state.step === "annotate"),
2644
- "data-moved": Boolean(floating.position),
2645
- "data-position": position,
2646
- ref: floating.rootRef,
2647
- children: [
2648
- /* @__PURE__ */ jsx("style", { children: floating.styles }),
2649
- showPanel && state.step === "compose" && state.screenshots.map(({ id, selectedNode }) => selectedNode ? /* @__PURE__ */ jsx(SelectionOutline, { node: selectedNode }, id) : null),
2650
- showPanel ? /* @__PURE__ */ jsx(FeedbackPanel, {
2651
- floatingControls: /* @__PURE__ */ jsxs("div", {
2652
- className: "floating-controls",
2653
- children: [/* @__PURE__ */ jsx("button", {
2654
- "aria-label": labels.moveFeedback,
2655
- className: "icon-btn drag-handle",
2656
- title: labels.moveFeedback,
2657
- type: "button",
2658
- ...floating.handleProps,
2659
- children: /* @__PURE__ */ jsx(GripIcon, {})
2660
- }), /* @__PURE__ */ jsx("button", {
2661
- "aria-label": labels.minimize,
2662
- className: "icon-btn",
2663
- onClick: minimize,
2664
- title: labels.minimize,
2665
- type: "button",
2666
- children: /* @__PURE__ */ jsx(CloseIcon, {})
2667
- })]
2668
- }),
2669
- labels,
2670
- state
2671
- }) : /* @__PURE__ */ jsx(Launcher, {
2672
- buttonRef: launcherRef,
2673
- isOpen: false,
2674
- label: state.isOpen ? labels.resume : labels.trigger,
2675
- onClick: () => {
2676
- setMinimized(false);
2677
- if (!state.isOpen) state.open();
2678
- }
2679
- })
2680
- ]
2681
- });
2682
- }
2683
- //#endregion
2684
- //#region src/feedback/ui/floating/element-walk.ts
2685
- const ARROW_SIBLING = {
2686
- ArrowLeft: "previousElementSibling",
2687
- ArrowRight: "nextElementSibling"
2688
- };
2689
- const AIM_KEYS = [
2690
- "Tab",
2691
- "ArrowUp",
2692
- "ArrowDown",
2693
- "ArrowLeft",
2694
- "ArrowRight"
2695
- ];
2696
- function isWidgetOwned(element) {
2697
- return element.closest(`[${WIDGET_MARKER}]`) !== null;
2698
- }
2699
- function isPickable(element) {
2700
- if (!element || element === document.documentElement || isWidgetOwned(element)) return false;
2701
- const rect = element.getBoundingClientRect();
2702
- return rect.width > 0 && rect.height > 0;
2703
- }
2704
- function pickableElements() {
2705
- return Array.from(document.body.querySelectorAll("*")).filter(isPickable);
2706
- }
2707
- function documentStep(from, backwards) {
2708
- const all = pickableElements();
2709
- const index = all.indexOf(from);
2710
- if (all.length === 0) return null;
2711
- if (index === -1) return all[0] ?? null;
2712
- return all[(index + (backwards ? -1 : 1) + all.length) % all.length] ?? null;
2713
- }
2714
- function climb(from) {
2715
- let candidate = from.parentElement;
2716
- while (candidate && !isPickable(candidate)) candidate = candidate.parentElement;
2717
- return candidate;
2718
- }
2719
- function descend(from) {
2720
- let candidate = from.firstElementChild;
2721
- while (candidate && !isPickable(candidate)) candidate = candidate.nextElementSibling;
2722
- return candidate;
2723
- }
2724
- function sibling(from, direction) {
2725
- let candidate = from[direction];
2726
- while (candidate && !isPickable(candidate)) candidate = candidate[direction];
2727
- return candidate;
2728
- }
2729
- function stepAim(from, key, backwards) {
2730
- if (!(from && isPickable(from))) return pickableElements()[0] ?? null;
2731
- if (key === "Tab") return documentStep(from, backwards);
2732
- if (key === "ArrowUp") return climb(from);
2733
- if (key === "ArrowDown") return descend(from);
2734
- const direction = ARROW_SIBLING[key];
2735
- return direction ? sibling(from, direction) : null;
2736
- }
2737
- //#endregion
2738
- //#region src/feedback/ui/picker.tsx
2739
- const LABEL_HEIGHT = 24;
2740
- const LABEL_GAP = 6;
2741
- const NOTE_WIDTH = 300;
2742
- const NOTE_GAP = 10;
2743
- const EDGE_GAP = 12;
2744
- /** Events raised by the picker's own note card must reach it, never be swallowed. */
2745
- function targetsWidget(event) {
2746
- return event.composedPath().some((node) => node instanceof Element && isWidgetOwned(node));
2747
- }
2748
- function elementUnder(x, y) {
2749
- const element = document.elementFromPoint(x, y);
2750
- if (!element || isWidgetOwned(element)) return null;
2751
- return element;
2752
- }
2753
- function describeTarget(element) {
1430
+ const takeCapture = useCallback(() => {
1431
+ if (!captureRequest.current) takeScreenshot({
1432
+ id: crypto.randomUUID(),
1433
+ source: "manual"
1434
+ });
1435
+ }, [takeScreenshot]);
1436
+ const refreshAutomatic = useCallback(() => {
1437
+ takeScreenshot({
1438
+ id: ownedDrafts.current.keys().next().value ?? captureRequest.current?.id ?? crypto.randomUUID(),
1439
+ source: "automatic"
1440
+ });
1441
+ }, [takeScreenshot]);
1442
+ const retakeCapture = useCallback((id) => {
1443
+ if (!captureRequest.current && ownedDrafts.current.has(id)) takeScreenshot({
1444
+ id,
1445
+ source: "manual"
1446
+ });
1447
+ }, [takeScreenshot]);
1448
+ const removeCapture = useCallback((id) => {
1449
+ if (captureRequest.current?.id === id) cancelPendingCapture();
1450
+ releaseDraft(ownedDrafts.current.get(id));
1451
+ ownedDrafts.current.delete(id);
1452
+ setScreenshots((previous) => previous.filter((item) => item.id !== id));
1453
+ setActiveId((current) => current === id ? null : current);
1454
+ }, [cancelPendingCapture]);
1455
+ const resetScreenshots = useCallback(() => {
1456
+ cancelPendingCapture();
1457
+ for (const draft of ownedDrafts.current.values()) releaseDraft(draft);
1458
+ ownedDrafts.current.clear();
1459
+ setScreenshots([]);
1460
+ setActiveId(null);
1461
+ }, [cancelPendingCapture]);
1462
+ const setAnnotations = useCallback((annotations) => {
1463
+ setScreenshots((previous) => previous.map((item) => item.id === activeId ? {
1464
+ ...item,
1465
+ annotations
1466
+ } : item));
1467
+ }, [activeId]);
1468
+ const setSelectionComment = useCallback((id, comment) => {
1469
+ setScreenshots((previous) => previous.map((item) => item.id === id && item.selection ? {
1470
+ ...item,
1471
+ selection: {
1472
+ ...item.selection,
1473
+ comment
1474
+ }
1475
+ } : item));
1476
+ }, []);
2754
1477
  return {
2755
- componentName: resolveComponentStack(getFiberFromNode(element))[0],
2756
- element,
2757
- label: describeElement(element),
2758
- rect: element.getBoundingClientRect(),
2759
- region: describeRegion(element)
1478
+ activeScreenshot: screenshots.find(({ id }) => id === activeId) ?? screenshots.at(-1) ?? null,
1479
+ cancelPendingCapture,
1480
+ pendingCapture,
1481
+ refreshAutomatic,
1482
+ removeCapture,
1483
+ resetScreenshots,
1484
+ retakeCapture,
1485
+ screenshots,
1486
+ selectScreenshot: setActiveId,
1487
+ setAnnotations,
1488
+ setSelectionComment,
1489
+ storeScreenshot,
1490
+ takeCapture
2760
1491
  };
2761
1492
  }
2762
- /**
2763
- * Pins the note card to the element: below it, flipped above when the visible
2764
- * viewport — what is left of the screen once a mobile keyboard is up — has no
2765
- * room there. Measures against the part of the element actually on screen, so
2766
- * picking a full-height section still leaves the card in view.
2767
- */
2768
- function noteStyle(rect, view) {
2769
- const left = Math.min(Math.max(view.left + EDGE_GAP, rect.left), Math.max(view.left + EDGE_GAP, view.left + view.width - NOTE_WIDTH - EDGE_GAP));
2770
- const visibleTop = Math.max(rect.top, view.top + EDGE_GAP);
2771
- const visibleBottom = Math.min(rect.bottom, view.top + view.height - EDGE_GAP);
2772
- if (view.top + view.height - visibleBottom > visibleTop - view.top) return {
2773
- left,
2774
- top: visibleBottom + NOTE_GAP
2775
- };
1493
+ //#endregion
1494
+ //#region src/feedback/ui/state/use-widget-config.ts
1495
+ function dismissalDuration(days) {
1496
+ return days !== void 0 && Number.isFinite(days) && days > 0 ? days : null;
1497
+ }
1498
+ function useWidgetConfig(props) {
1499
+ const context = useContext(RefletContext);
1500
+ const publicKey = props.publicKey ?? context?.publicKey;
1501
+ const baseUrl = props.baseUrl ?? context?.baseUrl;
1502
+ const user = props.user ?? context?.user;
1503
+ const userToken = props.userToken ?? context?.userToken;
2776
1504
  return {
2777
- bottom: window.innerHeight - visibleTop + NOTE_GAP,
2778
- left
1505
+ client: useMemo(() => publicKey ? new Reflet({
1506
+ baseUrl,
1507
+ publicKey,
1508
+ user,
1509
+ userToken
1510
+ }) : null, [
1511
+ baseUrl,
1512
+ publicKey,
1513
+ user,
1514
+ userToken
1515
+ ]),
1516
+ dismissalKey: `reflet-feedback-dismissed:${publicKey ?? "default"}:${user?.id ?? "anonymous"}`,
1517
+ dismissForDays: dismissalDuration(props.dismissForDays),
1518
+ isAnonymous: !(user || userToken)
2779
1519
  };
2780
1520
  }
1521
+ //#endregion
1522
+ //#region src/feedback/ui/use-capture-sync.ts
1523
+ const RESIZE_DEBOUNCE = 400;
2781
1524
  /**
2782
- * Points at any element in the page the way a coding agent needs it: the React
2783
- * component that owns it plus the page region it lives in. Picking never
2784
- * commits on its own — it pins a note card to the element, so the reporter
2785
- * writes about that element right there and confirms. Aiming stays live until
2786
- * then, which is what makes the flow usable without hover, on touch.
1525
+ * Calls `refresh` whenever the page the reporter is looking at changes shape:
1526
+ * SPA routes (which never fire popstate on push/replace — history methods must
1527
+ * be patched) and window resizes, debounced. Keeps the frozen screenshot and
1528
+ * page context describing what the reporter currently sees.
2787
1529
  */
2788
- function ElementPicker({ labels, onCancel, onPick }) {
2789
- const [target, setTarget] = useState(null);
2790
- const [pinned, setPinned] = useState(null);
2791
- const [note, setNote] = useState("");
2792
- const noteRef = useRef(null);
2793
- const pinnedRef = useRef(false);
2794
- const [view, setView] = useState(visibleViewport);
2795
- const aimRef = useRef(null);
2796
- useEffect(() => onViewportChange(() => setView(visibleViewport())), []);
2797
- useEffect(() => {
2798
- pinnedRef.current = pinned !== null;
2799
- if (pinned) noteRef.current?.focus({ preventScroll: true });
2800
- }, [pinned]);
2801
- useEffect(() => {
2802
- aimRef.current = target?.element ?? null;
2803
- }, [target]);
1530
+ function useCaptureSync(enabled, refresh) {
2804
1531
  useEffect(() => {
2805
- const swallow = (event) => {
2806
- if (targetsWidget(event)) return;
2807
- event.preventDefault();
2808
- event.stopPropagation();
2809
- };
2810
- const aimAt = (x, y) => {
2811
- const element = elementUnder(x, y);
2812
- if (!element) return;
2813
- setTarget((current) => current?.element === element ? {
2814
- ...current,
2815
- rect: element.getBoundingClientRect()
2816
- } : describeTarget(element));
2817
- };
2818
- const onMove = (event) => {
2819
- if (pinnedRef.current || targetsWidget(event)) return;
2820
- aimAt(event.clientX, event.clientY);
2821
- };
2822
- const onPointerDown = (event) => {
2823
- if (targetsWidget(event)) return;
2824
- swallow(event);
2825
- if (!pinnedRef.current) aimAt(event.clientX, event.clientY);
2826
- };
2827
- const onPointerUp = (event) => {
2828
- if (targetsWidget(event)) return;
2829
- swallow(event);
2830
- if (pinnedRef.current) return;
2831
- const element = elementUnder(event.clientX, event.clientY);
2832
- if (element) setPinned(describeTarget(element));
2833
- };
2834
- const moveAim = (key, backwards) => {
2835
- const next = stepAim(aimRef.current, key, backwards);
2836
- if (!next) return;
2837
- next.scrollIntoView({
2838
- block: "nearest",
2839
- inline: "nearest"
2840
- });
2841
- aimRef.current = next;
2842
- setTarget(describeTarget(next));
2843
- };
2844
- const onEscape = () => {
2845
- if (pinnedRef.current) {
2846
- setPinned(null);
2847
- setNote("");
2848
- return;
2849
- }
2850
- onCancel();
1532
+ if (!enabled) return;
1533
+ const history = window.history;
1534
+ const originalPush = history.pushState;
1535
+ const originalReplace = history.replaceState;
1536
+ const hadOwnPush = Object.hasOwn(history, "pushState");
1537
+ const hadOwnReplace = Object.hasOwn(history, "replaceState");
1538
+ history.pushState = function pushState(data, unused, url) {
1539
+ const result = originalPush.call(history, data, unused, url);
1540
+ refresh();
1541
+ return result;
2851
1542
  };
2852
- const onKeyDown = (event) => {
2853
- if (event.key === "Escape") {
2854
- swallow(event);
2855
- onEscape();
2856
- return;
2857
- }
2858
- if (pinnedRef.current || targetsWidget(event)) return;
2859
- if (event.key === "Enter") {
2860
- swallow(event);
2861
- if (aimRef.current) setPinned(describeTarget(aimRef.current));
2862
- return;
2863
- }
2864
- if (!AIM_KEYS.includes(event.key)) return;
2865
- swallow(event);
2866
- moveAim(event.key, event.shiftKey);
1543
+ history.replaceState = function replaceState(data, unused, url) {
1544
+ const result = originalReplace.call(history, data, unused, url);
1545
+ refresh();
1546
+ return result;
2867
1547
  };
2868
- const onScroll = () => {
2869
- setTarget((current) => current ? {
2870
- ...current,
2871
- rect: current.element.getBoundingClientRect()
2872
- } : current);
2873
- setPinned((current) => current ? {
2874
- ...current,
2875
- rect: current.element.getBoundingClientRect()
2876
- } : current);
1548
+ window.addEventListener("popstate", refresh);
1549
+ let resizeTimer;
1550
+ const onResize = () => {
1551
+ window.clearTimeout(resizeTimer);
1552
+ resizeTimer = window.setTimeout(refresh, RESIZE_DEBOUNCE);
2877
1553
  };
2878
- document.addEventListener("pointermove", onMove, true);
2879
- document.addEventListener("pointerdown", onPointerDown, true);
2880
- document.addEventListener("pointerup", onPointerUp, true);
2881
- document.addEventListener("mousedown", swallow, true);
2882
- document.addEventListener("mouseup", swallow, true);
2883
- document.addEventListener("click", swallow, true);
2884
- document.addEventListener("keydown", onKeyDown, true);
2885
- window.addEventListener("scroll", onScroll, {
2886
- capture: true,
2887
- passive: true
2888
- });
2889
- const previousCursor = document.body.style.cursor;
2890
- document.body.style.cursor = "crosshair";
1554
+ window.addEventListener("resize", onResize);
2891
1555
  return () => {
2892
- document.removeEventListener("pointermove", onMove, true);
2893
- document.removeEventListener("pointerdown", onPointerDown, true);
2894
- document.removeEventListener("pointerup", onPointerUp, true);
2895
- document.removeEventListener("mousedown", swallow, true);
2896
- document.removeEventListener("mouseup", swallow, true);
2897
- document.removeEventListener("click", swallow, true);
2898
- document.removeEventListener("keydown", onKeyDown, true);
2899
- window.removeEventListener("scroll", onScroll, true);
2900
- document.body.style.cursor = previousCursor;
1556
+ if (hadOwnPush) history.pushState = originalPush;
1557
+ else Reflect.deleteProperty(history, "pushState");
1558
+ if (hadOwnReplace) history.replaceState = originalReplace;
1559
+ else Reflect.deleteProperty(history, "replaceState");
1560
+ window.removeEventListener("popstate", refresh);
1561
+ window.clearTimeout(resizeTimer);
1562
+ window.removeEventListener("resize", onResize);
2901
1563
  };
2902
- }, [onCancel]);
2903
- const rect = (pinned ?? target)?.rect;
2904
- const labelAbove = rect ? rect.top > 30 : true;
2905
- return /* @__PURE__ */ jsxs("div", { children: [
2906
- rect && /* @__PURE__ */ jsx("div", {
2907
- className: "picker-box",
2908
- "data-pinned": pinned !== null,
2909
- style: {
2910
- height: rect.height,
2911
- translate: `${rect.left}px ${rect.top}px`,
2912
- width: rect.width
2913
- }
2914
- }),
2915
- rect && target && !pinned && /* @__PURE__ */ jsxs("div", {
2916
- className: "picker-label",
2917
- style: { translate: `${Math.max(4, rect.left)}px ${labelAbove ? rect.top - LABEL_HEIGHT - LABEL_GAP : rect.bottom + LABEL_GAP}px` },
2918
- children: [/* @__PURE__ */ jsx("strong", { children: target.componentName ? `<${target.componentName}>` : target.label }), target.region && /* @__PURE__ */ jsx("span", { children: target.region })]
2919
- }),
2920
- pinned ? /* @__PURE__ */ jsxs("form", {
2921
- className: "picker-note glass",
2922
- onSubmit: (event) => {
2923
- event.preventDefault();
2924
- onPick(pinned.element, note.trim());
2925
- },
2926
- style: noteStyle(pinned.rect, view),
2927
- children: [
2928
- /* @__PURE__ */ jsxs("p", {
2929
- className: "picker-note-target",
2930
- children: [/* @__PURE__ */ jsx("strong", { children: pinned.componentName ? `<${pinned.componentName}>` : pinned.label }), pinned.region && /* @__PURE__ */ jsx("span", { children: pinned.region })]
2931
- }),
2932
- /* @__PURE__ */ jsx("textarea", {
2933
- "aria-label": labels.elementNote,
2934
- maxLength: MAX_SELECTION_COMMENT_LENGTH,
2935
- onChange: (event) => setNote(event.target.value),
2936
- onKeyDown: (event) => {
2937
- if (event.key === "Enter" && !event.shiftKey) {
2938
- event.preventDefault();
2939
- onPick(pinned.element, note.trim());
2940
- }
2941
- },
2942
- placeholder: labels.elementNotePlaceholder,
2943
- ref: noteRef,
2944
- rows: 2,
2945
- value: note
2946
- }),
2947
- /* @__PURE__ */ jsxs("div", {
2948
- className: "picker-note-actions",
2949
- children: [/* @__PURE__ */ jsx("button", {
2950
- className: "picker-cancel",
2951
- onClick: () => {
2952
- setPinned(null);
2953
- setNote("");
2954
- },
2955
- type: "button",
2956
- children: labels.pickAnother
2957
- }), /* @__PURE__ */ jsx("button", {
2958
- "aria-label": labels.attachElement,
2959
- className: "submit",
2960
- title: labels.attachElement,
2961
- type: "submit",
2962
- children: /* @__PURE__ */ jsx(ArrowIcon, {})
2963
- })]
2964
- })
2965
- ]
2966
- }) : /* @__PURE__ */ jsxs("div", {
2967
- className: "picker-hint",
2968
- children: [/* @__PURE__ */ jsxs("span", {
2969
- className: "picker-instruction",
2970
- children: [
2971
- labels.pickElementHint,
2972
- " ",
2973
- /* @__PURE__ */ jsx("kbd", { children: "Tab" }),
2974
- /* @__PURE__ */ jsx("kbd", { children: "Enter" }),
2975
- /* @__PURE__ */ jsx("kbd", { children: "Esc" })
2976
- ]
2977
- }), /* @__PURE__ */ jsx("button", {
2978
- className: "picker-cancel",
2979
- onClick: onCancel,
2980
- type: "button",
2981
- children: labels.cancel
2982
- })]
2983
- })
2984
- ] });
2985
- }
2986
- //#endregion
2987
- //#region src/feedback/ui/annotation/styles.ts
2988
- const ANNOTATOR_STYLES = `
2989
- .overlay { margin: 0; border: 0; padding: 0; width: 100vw; max-width: none; height: 100dvh; max-height: none; position: fixed; inset: 0; z-index: ${REFLET_Z_INDEX.annotatorOverlay}; display: flex; flex-direction: column; background: transparent; color: var(--rf-text); font: 13px/1.45 ui-sans-serif, system-ui, sans-serif; }
2990
- .overlay::before { content: ""; position: fixed; inset: 0; z-index: -1; pointer-events: none; background: color-mix(in srgb, var(--rf-bg) 94%, transparent); animation: rf-editor-in 260ms var(--rf-ease); transition: opacity 260ms var(--rf-ease); }
2991
- .overlay::backdrop { background: transparent; }
2992
- @keyframes rf-editor-in { from { opacity: 0; } }
2993
- .editor { flex: 1; position: relative; display: flex; align-items: center; justify-content: center; padding: 20px; min-height: 0; container-type: size; }
2994
- .editor-image { width: min(100cqw, calc(100cqh * var(--rf-capture-ratio))); aspect-ratio: var(--rf-capture-ratio); flex: none; position: relative; }
2995
- .editor-visual { position: absolute; inset: 0; border-radius: var(--rf-radius); box-shadow: var(--rf-shadow); transform-origin: top left; overflow: hidden; }
2996
- .editor-visual img, .editor-visual canvas { position: absolute; inset: 0; width: 100%; height: 100%; border-radius: inherit; object-fit: cover; object-position: top center; outline: 1px solid var(--rf-hairline); outline-offset: -1px; }
2997
- .editor-visual canvas { cursor: crosshair; touch-action: none; }
2998
- .overlay[data-opening="true"] .editor { pointer-events: none; }
2999
- .overlay[data-closing="true"] { pointer-events: none; }
3000
- .overlay[data-closing="true"]::before { opacity: 0; }
3001
- .overlay[data-closing="true"] .toolbar, .overlay[data-closing="true"] .element-note { opacity: 0; transform: translateY(8px); }
3002
-
3003
- .element-note { order: 1; display: flex; align-items: flex-start; gap: 10px; width: min(560px, calc(100vw - 32px)); margin: 0 auto 10px; padding: 10px 14px; border-radius: var(--rf-radius); transition: opacity 180ms var(--rf-ease), transform 180ms var(--rf-ease); }
3004
- .element-note svg { flex: none; margin-top: 4px; opacity: 0.7; }
3005
- .element-note textarea { flex: 1; min-width: 0; height: 42px; max-height: 96px; padding: 0; border: 0; background: transparent; color: inherit; font: inherit; resize: none; }
3006
- .element-note textarea:focus { outline: none; }
3007
- .element-note:focus-within { outline: 2px solid var(--rf-accent); outline-offset: 2px; }
3008
-
3009
- .toolbar { order: 2; display: flex; align-items: center; justify-content: center; gap: 8px; flex-wrap: wrap; transition: opacity 180ms var(--rf-ease), transform 180ms var(--rf-ease); padding: 4px 12px max(16px, env(safe-area-inset-bottom)); }
3010
- .toolbar .group { display: flex; align-items: center; gap: 2px; padding: 4px; border-radius: var(--rf-pill); }
3011
- .tool[aria-pressed="true"], .tool[data-selected="true"] { background: var(--rf-primary); color: var(--rf-primary-text); }
3012
- .color-picker { position: relative; }
3013
- .color-picker summary { display: flex; justify-content: center; align-items: center; }
3014
- .color-preview { width: 18px; height: 18px; border-radius: 50%; background: var(--annotation-color); box-shadow: inset 0 0 0 1px var(--rf-border); }
3015
- .color-popover { --rf-color-width: 200px; position: absolute; bottom: calc(100% + 14px); left: clamp(calc(12px - var(--rf-color-anchor)), 0px, calc(100vw - var(--rf-color-anchor) - var(--rf-color-width) - 12px)); display: flex; align-items: center; gap: 6px; width: var(--rf-color-width); padding: 4px 4px 4px 14px; border-radius: var(--rf-pill); animation: rf-in 180ms var(--rf-ease); }
3016
- .color-picker[data-placement="below"] .color-popover { top: calc(100% + 14px); bottom: auto; }
3017
- .color-slider { flex: 1; min-width: 0; }
3018
- .color-slider-control { display: flex; align-items: center; height: 40px; width: 100%; touch-action: none; user-select: none; cursor: pointer; }
3019
- .color-slider-track { height: 12px; width: 100%; border-radius: var(--rf-pill); background: linear-gradient(to right, var(--rf-swatch-base), var(--annotation-base-color)); box-shadow: inset 0 0 0 1px var(--rf-border); }
3020
- .color-slider-thumb { width: 16px; height: 16px; border: 2px solid var(--rf-swatch-base); border-radius: 50%; background: var(--annotation-color); box-shadow: var(--rf-shadow-low); transition: scale 160ms var(--rf-ease); }
3021
- .color-slider-thumb[data-dragging] { scale: 1.12; }
3022
- .color-slider-thumb:has(:focus-visible) { outline: 2px solid var(--rf-accent); outline-offset: 3px; }
3023
- .color-spectrum { position: relative; display: flex; align-items: center; justify-content: center; flex: none; width: 40px; height: 40px; border-radius: 50%; cursor: pointer; }
3024
- .color-spectrum::before { content: ""; width: 16px; height: 16px; border-radius: 50%; background: var(--rf-spectrum); box-shadow: inset 0 0 0 1px var(--rf-glass-highlight); }
3025
- .color-spectrum:hover { background: var(--rf-bg-hover); }
3026
- .color-spectrum button { position: absolute; inset: 0; width: 100%; height: 100%; border-radius: inherit; }
3027
- .color-spectrum button:focus-visible { outline: 2px solid var(--rf-accent); outline-offset: 3px; }
3028
- .color-spectrum input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; pointer-events: none; }
3029
- .done-btn { display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; padding: 0; border-radius: var(--rf-pill); color: var(--rf-primary-text); background: var(--rf-primary); }
3030
- .drawing-menu { position: relative; }
3031
- .drawing-popover { position: absolute; z-index: 2; right: 0; bottom: calc(100% + 12px); width: 200px; max-height: calc(50dvh - 68px); overflow-y: auto; padding: 6px; border-radius: var(--rf-radius); }
3032
- .drawing-menu[data-placement="below"] .drawing-popover { top: calc(100% + 12px); bottom: auto; }
3033
- .drawing-option { display: flex; align-items: center; width: 100%; min-height: 40px; gap: 10px; padding: 0 10px; border-radius: 12px; text-align: left; }
3034
- .drawing-option:hover, .drawing-option[aria-pressed="true"] { background: var(--rf-bg-hover); }
3035
- .drawing-divider { height: 1px; margin: 5px; background: var(--rf-border); }
3036
- .editor canvas[data-tool="text"] { cursor: text; }
3037
- .text-annotation-editor { position: fixed; z-index: 3; width: 240px; padding: 8px; border-radius: var(--rf-radius); }
3038
- .text-annotation-editor:focus-within { outline: 2px solid var(--rf-accent); outline-offset: 2px; }
3039
- .text-annotation-editor textarea { display: block; width: 100%; min-height: 64px; max-height: 112px; padding: 6px; border: 0; background: transparent; resize: vertical; font-size: 16px; line-height: 24px; }
3040
- .text-annotation-editor textarea:focus { outline: none; }
3041
- .text-annotation-actions { display: flex; align-items: center; justify-content: flex-end; gap: 4px; }
3042
- @media (pointer: coarse) {
3043
- .drawing-option { min-height: 44px; }
3044
- .done-btn { width: 44px; height: 44px; }
3045
- .color-popover { --rf-color-width: 232px; padding-left: 16px; }
3046
- .color-slider-control, .color-spectrum { height: 44px; }
3047
- .color-spectrum { width: 44px; }
3048
- .color-slider-thumb, .color-spectrum::before { width: 18px; height: 18px; }
3049
- .editor { padding: 8px; }
3050
- .toolbar { gap: 6px; }
3051
- .toolbar .group { padding: 3px; gap: 0; }
3052
- }
3053
- @media (pointer: coarse) and (max-width: 360px) {
3054
- .toolbar { gap: 4px; padding-inline: 2px; }
3055
- .toolbar .group { padding: 0; }
3056
- }
3057
- `;
3058
- //#endregion
3059
- //#region src/feedback/ui/composer/styles.ts
3060
- const COMPOSER_STYLES = `
3061
- .panel { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 8px; outline: none; animation: rf-in 240ms var(--rf-ease); }
3062
- .success-panel { border-radius: var(--rf-radius); }
3063
- .composer { position: relative; display: flex; flex-direction: column; gap: 8px; }
3064
- .message-row { display: flex; flex-direction: column; gap: 2px; padding: 6px; border-radius: 26px; }
3065
- .message-row textarea {
3066
- width: 100%; height: 76px; max-height: min(140px, 30dvh);
3067
- margin: 0; padding: 6px 6px 0 8px; border: 0; resize: none;
3068
- background: transparent; line-height: 20px;
3069
- }
3070
- .message-actions { display: flex; align-items: center; gap: 2px; }
3071
- .message-row textarea::placeholder { color: var(--rf-text-muted); }
3072
- .message-row textarea:focus { outline: none; }
3073
- .message-row:has(textarea:focus-visible) { border-color: color-mix(in srgb, var(--rf-accent) 40%, var(--rf-glass-stroke)); }
3074
- .submit { display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; flex: none; border-radius: var(--rf-pill); background: var(--rf-primary); color: var(--rf-primary-text); transition: opacity 160ms var(--rf-ease), transform 160ms var(--rf-ease); }
3075
- .submit svg { transform: rotate(-45deg); }
3076
- .submit:hover:enabled { transform: scale(1.05); }
3077
- .submit:disabled { background: var(--rf-bg-hover); color: var(--rf-text-muted); opacity: .65; }
3078
- .composer-toolbar { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
3079
- .composer-options { position: relative; }
3080
- summary { cursor: pointer; list-style: none; }
3081
- summary::-webkit-details-marker { display: none; }
3082
- .options-popover { position: absolute; z-index: 2; right: 0; bottom: calc(100% + 12px); width: min(240px, calc(100vw - 40px)); max-height: calc(50dvh - 68px); overflow-y: auto; display: flex; flex-direction: column; gap: 8px; padding: 12px; border-radius: var(--rf-radius); animation: rf-in 160ms var(--rf-ease); }
3083
- .composer-options[data-placement="below"] .options-popover { top: calc(100% + 12px); bottom: auto; }
3084
- .email-field { display: flex; flex-direction: column; gap: 6px; color: var(--rf-text-muted); font-size: 12px; }
3085
- .email-field [role="alert"] { color: var(--rf-danger); }
3086
- .email-field input { width: 100%; padding: 9px; border: 1px solid var(--rf-border); border-radius: var(--rf-radius-sm); background: var(--rf-bg-subtle); color: var(--rf-text); }
3087
- .dismiss-btn { min-height: 40px; text-align: left; padding: 0 8px; border-radius: var(--rf-radius-sm); color: var(--rf-text-muted); }
3088
- .dismiss-btn:hover { background: var(--rf-bg-hover); }
3089
- .version-info { font-size: 10px; color: var(--rf-text-muted); padding: 4px 8px 0; border-top: 1px solid var(--rf-border); text-decoration: none; }
3090
- .version-info:hover { color: var(--rf-text); }
3091
- .email-scrim { display: none; }
3092
- .email-prompt { display: flex; flex-direction: column; gap: 12px; padding: 16px; border-radius: 22px; animation: rf-in 240ms var(--rf-ease); }
3093
- .email-prompt h2 { margin: 0; font-size: 14px; font-weight: 600; line-height: 1.3; }
3094
- .email-prompt p { margin: 0; font-size: 12px; line-height: 1.45; color: var(--rf-text-muted); }
3095
- .email-prompt .email-field input { min-height: 40px; border-radius: 14px; padding: 0 12px; }
3096
- .email-prompt-actions { display: flex; align-items: center; justify-content: flex-end; gap: 4px; }
3097
- .email-prompt-actions .ghost-btn { padding: 0 12px; color: var(--rf-text-muted); }
3098
- .primary-btn { display: flex; align-items: center; justify-content: center; min-height: 40px; padding: 0 16px; border-radius: var(--rf-pill); background: var(--rf-primary); color: var(--rf-primary-text); font-weight: 500; }
3099
- .primary-btn:hover { opacity: .9; }
3100
- .attachments { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; }
3101
- .attachment-row { display: flex; align-items: flex-end; gap: 8px; min-width: 0; max-width: 100%; }
3102
- .screenshot-scroll-area { position: relative; min-width: 0; margin: -14px -10px -20px; }
3103
- .screenshot-strip { width: 100%; padding: 14px 10px 20px; overscroll-behavior-x: contain; --rf-fade-start: 0px; --rf-fade-end: 0px; mask-image: linear-gradient(to right, transparent, black var(--rf-fade-start), black calc(100% - var(--rf-fade-end)), transparent); mask-repeat: no-repeat; }
3104
- .screenshot-strip[data-overflow-x-start] { --rf-fade-start: calc(10px + min(24px, var(--scroll-area-overflow-x-start))); }
3105
- .screenshot-strip[data-overflow-x-end] { --rf-fade-end: calc(10px + min(24px, var(--scroll-area-overflow-x-end))); }
3106
- .screenshot-strip-content { display: flex; align-items: flex-end; gap: 8px; }
3107
- .screenshot-scroll-area:has(.screenshot-strip-content:empty) { display: none; }
3108
- .capture-slot { display: flex; flex-direction: column; gap: 2px; flex: none; width: 56px; height: var(--rf-attachment-height); padding: 3px; border: 1px dashed var(--rf-border); border-radius: var(--rf-radius); box-shadow: none; }
3109
- .capture-action { display: flex; flex: 1; min-height: 0; align-items: center; justify-content: center; border-radius: 5px; color: var(--rf-text-muted); transition: background 160ms var(--rf-ease), color 160ms var(--rf-ease); }
3110
- .capture-action:first-child { border-radius: 16px 16px 5px 5px; }
3111
- .capture-action:last-child { border-radius: 5px 5px 16px 16px; }
3112
- .capture-action:hover:enabled { background: var(--rf-bg-hover); color: var(--rf-text); }
3113
- .screenshot-scrollbar { height: 4px; margin: 0 8px 2px; opacity: 0; transition: opacity 150ms var(--rf-ease); }
3114
- .screenshot-scrollbar[data-hovering], .screenshot-scrollbar[data-scrolling] { opacity: 1; }
3115
- .screenshot-scroll-thumb { border-radius: var(--rf-pill); background: var(--rf-border); }
3116
- .capture-progress { position: absolute; inset: 0; display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 6px; border-radius: inherit; color: var(--rf-text-muted); font-size: 10px; }
3117
- .attachments:empty { display: none; }
3118
- .screenshot-attachment, .attachment-skeleton { position: relative; flex: none; margin: 0; min-width: 0; width: 128px; height: var(--rf-attachment-height); padding: 0; border-radius: 16px; animation: rf-in 260ms var(--rf-ease); }
3119
- .screenshot-attachment { border: 0; }
3120
- .screenshot-preview { display: block; position: relative; width: 100%; height: 100%; padding: 0; border-radius: inherit; overflow: hidden; }
3121
- .screenshot-preview img, .screenshot-preview canvas { width: 100%; height: 100%; display: block; object-fit: cover; object-position: top center; }
3122
- .screenshot-preview canvas { position: absolute; inset: 0; pointer-events: none; }
3123
- .annotation-count { position: absolute; left: 5px; bottom: 5px; min-width: 20px; padding: 2px 6px; border-radius: var(--rf-pill); font-size: 10px; font-weight: 600; font-variant-numeric: tabular-nums; text-align: center; color: var(--rf-text); }
3124
- .attachment-actions { position: absolute; right: 4px; bottom: 4px; display: flex; gap: 1px; padding: 1px; border-radius: var(--rf-pill); }
3125
- .attachment-actions .icon-btn { width: 28px; height: 28px; }
3126
- .attachment-actions svg { width: 14px; height: 14px; }
3127
- .attachment-skeleton { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; color: var(--rf-text-muted); font-size: 11px; }
3128
- .attachment-skeleton::after { content: ""; position: absolute; inset: 0; border-radius: inherit; background: var(--rf-bg-subtle); animation: rf-fade 800ms alternate infinite; }
3129
- .selection-badge { position: absolute; left: 5px; top: 5px; display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--rf-pill); color: var(--rf-accent); }
3130
- .selection-badge svg { width: 12px; height: 12px; }
3131
- @media (pointer: coarse) {
3132
- .message-row { border-radius: 28px; }
3133
- .capture-slot { width: 64px; }
3134
- .message-row textarea { font-size: 16px; }
3135
- .submit { width: 44px; height: 44px; }
3136
- :host { --rf-attachment-height: 96px; }
3137
- .attachment-actions { right: 5px; bottom: 5px; }
3138
- .attachment-actions .icon-btn { width: 34px; height: 34px; }
3139
- .selection-badge { width: 24px; height: 24px; }
3140
- .email-field input { font-size: 16px; }
3141
- .dismiss-btn { min-height: 44px; }
3142
- }
3143
- @media (max-width: 480px) {
3144
- .email-scrim { display: block; position: fixed; inset: 0; background: rgb(0 0 0 / 40%); animation: rf-fade 200ms var(--rf-ease); }
3145
- .email-prompt { position: fixed; left: 0; right: 0; bottom: 0; gap: 14px; padding: 20px 16px calc(20px + env(safe-area-inset-bottom)); border-radius: 26px 26px 0 0; animation: rf-drawer-in 280ms var(--rf-ease); }
3146
- .email-prompt h2 { font-size: 17px; }
3147
- .email-prompt p { font-size: 13px; }
3148
- .email-prompt-actions { flex-direction: column-reverse; align-items: stretch; gap: 8px; }
3149
- .email-prompt-actions .ghost-btn, .email-prompt-actions .primary-btn { justify-content: center; min-height: 48px; }
1564
+ }, [enabled, refresh]);
3150
1565
  }
3151
- @keyframes rf-drawer-in { from { transform: translateY(100%); } }
3152
- `;
3153
1566
  //#endregion
3154
- //#region src/feedback/ui/floating/picker-styles.ts
3155
- const PICKER_STYLES = `
3156
- .picker-box {
3157
- position: fixed;
3158
- top: 0;
3159
- left: 0;
3160
- pointer-events: none;
3161
- border-radius: 5px;
3162
- background: color-mix(in srgb, var(--rf-accent) 10%, transparent);
3163
- box-shadow: 0 0 0 2px var(--rf-accent), 0 0 0 5px color-mix(in srgb, var(--rf-accent) 18%, transparent);
3164
- will-change: translate;
3165
- transition: translate 130ms var(--rf-ease);
1567
+ //#region src/feedback/ui/use-widget-state.ts
1568
+ const SUCCESS_CLOSE_DELAY = 2400;
1569
+ const IS_APPLE = /Mac|iPhone|iPad/;
1570
+ const MILLISECONDS_PER_DAY = 864e5;
1571
+ function readDismissedUntil(key) {
1572
+ if (typeof window === "undefined") return 0;
1573
+ try {
1574
+ const value = Number.parseInt(window.localStorage.getItem(key) ?? "", 10);
1575
+ if (Number.isFinite(value) && value > Date.now()) return value;
1576
+ window.localStorage.removeItem(key);
1577
+ } catch {
1578
+ return 0;
1579
+ }
1580
+ return 0;
3166
1581
  }
3167
- .picker-box[data-pinned="true"] { transition: none; }
3168
- .picker-label {
3169
- position: fixed;
3170
- top: 0;
3171
- left: 0;
3172
- pointer-events: none;
3173
- display: inline-flex;
3174
- align-items: center;
3175
- gap: 6px;
3176
- max-width: min(420px, 90vw);
3177
- height: 24px;
3178
- padding: 0 8px;
3179
- font-size: 11.5px;
3180
- font-weight: 500;
3181
- color: var(--rf-accent-text);
3182
- background: var(--rf-accent);
3183
- border-radius: 6px;
3184
- box-shadow: var(--rf-shadow-float);
3185
- white-space: nowrap;
3186
- overflow: hidden;
3187
- will-change: translate;
3188
- transition: translate 130ms var(--rf-ease);
1582
+ function writeDismissedUntil(key, until) {
1583
+ try {
1584
+ window.localStorage.setItem(key, String(until));
1585
+ } catch {}
3189
1586
  }
3190
- .picker-label strong { flex: none; font-weight: 600; }
3191
- .picker-label span { min-width: 0; overflow: hidden; text-overflow: ellipsis; opacity: 0.75; }
3192
- .selection-outline {
3193
- position: fixed;
3194
- top: 0;
3195
- left: 0;
3196
- pointer-events: none;
3197
- border-radius: 5px;
3198
- background: color-mix(in srgb, var(--rf-accent) 7%, transparent);
3199
- box-shadow: 0 0 0 2px color-mix(in srgb, var(--rf-accent) 70%, transparent), 0 0 0 5px color-mix(in srgb, var(--rf-accent) 14%, transparent);
3200
- transition: opacity 160ms var(--rf-ease);
1587
+ function matchesHotkey(event, hotkey, platform = typeof navigator === "undefined" ? "" : navigator.platform) {
1588
+ const parts = hotkey.toLowerCase().split("+").map((part) => part.trim());
1589
+ const key = parts.at(-1);
1590
+ if (!key || event.key.toLowerCase() !== key) return false;
1591
+ const isApple = IS_APPLE.test(platform);
1592
+ const wantsMod = parts.includes("mod");
1593
+ const expected = {
1594
+ alt: parts.includes("alt") || parts.includes("option"),
1595
+ ctrl: parts.includes("ctrl") || wantsMod && !isApple,
1596
+ meta: parts.includes("meta") || parts.includes("cmd") || wantsMod && isApple,
1597
+ shift: parts.includes("shift")
1598
+ };
1599
+ return event.altKey === expected.alt && event.ctrlKey === expected.ctrl && event.metaKey === expected.meta && event.shiftKey === expected.shift;
3201
1600
  }
3202
- .picker-hint {
3203
- position: fixed;
3204
- left: 50%;
3205
- bottom: calc(24px + env(safe-area-inset-bottom));
3206
- transform: translateX(-50%);
3207
- display: flex;
3208
- align-items: center;
3209
- gap: 6px;
3210
- max-width: calc(100vw - 24px);
3211
- padding: 5px 6px 5px 12px;
3212
- font-size: 12.5px;
3213
- color: var(--rf-text);
3214
- background: var(--rf-bg);
3215
- border-radius: var(--rf-pill);
3216
- box-shadow: var(--rf-ring), var(--rf-shadow);
3217
- animation: rf-fade 240ms var(--rf-ease);
3218
- }
3219
- .picker-instruction { display: flex; align-items: center; gap: 6px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
3220
- .picker-hint kbd {
3221
- padding: 1px 5px;
3222
- font: inherit;
3223
- font-size: 11px;
3224
- color: var(--rf-text-muted);
3225
- border-radius: 4px;
3226
- background: var(--rf-bg-subtle);
3227
- box-shadow: var(--rf-shadow-inset);
3228
- }
3229
- .picker-cancel {
3230
- display: inline-flex;
3231
- align-items: center;
3232
- height: 40px;
3233
- padding: 0 14px;
3234
- border-radius: var(--rf-pill);
3235
- font-size: 12.5px;
3236
- font-weight: 500;
3237
- white-space: nowrap;
3238
- color: var(--rf-text-muted);
3239
- }
3240
- .picker-cancel:hover { background: var(--rf-bg-hover); color: var(--rf-text); }
3241
- .picker-note {
3242
- position: fixed;
3243
- z-index: ${REFLET_Z_INDEX.annotatorOverlay};
3244
- display: flex;
3245
- flex-direction: column;
3246
- gap: 6px;
3247
- width: 300px;
3248
- max-width: calc(100vw - 24px);
3249
- padding: 6px 6px 6px 8px;
3250
- border-radius: 18px;
3251
- animation: rf-in 180ms var(--rf-ease);
3252
- }
3253
- .picker-note:focus-within { outline: 2px solid var(--rf-accent); outline-offset: 2px; }
3254
- .picker-note-target {
3255
- display: flex;
3256
- align-items: center;
3257
- gap: 6px;
3258
- margin: 0;
3259
- padding: 2px 4px 0;
3260
- font-size: 11px;
3261
- color: var(--rf-text-muted);
3262
- white-space: nowrap;
3263
- }
3264
- .picker-note-target strong { flex: none; font-weight: 600; color: var(--rf-accent); }
3265
- .picker-note-target span { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
3266
- .picker-note textarea {
3267
- width: 100%;
3268
- min-height: 46px;
3269
- max-height: min(160px, 30dvh);
3270
- margin: 0;
3271
- padding: 7px 6px 0;
3272
- border: 0;
3273
- resize: none;
3274
- background: transparent;
3275
- color: var(--rf-text);
3276
- font-size: 13px;
3277
- line-height: 19px;
3278
- }
3279
- .picker-note textarea:focus { outline: none; }
3280
- .picker-note textarea::placeholder { color: var(--rf-text-muted); }
3281
- .picker-note-actions { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
3282
- @media (pointer: coarse) {
3283
- .picker-hint { padding: 6px 8px 6px 14px; }
3284
- .picker-hint kbd { display: none; }
3285
- .picker-cancel { height: 44px; padding: 0 18px; font-size: 14px; }
3286
- .picker-note textarea { font-size: 16px; min-height: 56px; }
3287
- }
3288
-
3289
- `;
3290
- //#endregion
3291
- //#region src/feedback/ui/styles.ts
3292
- const WIDGET_STYLES = `
3293
- :host {
3294
- --rf-offset: 20px;
3295
- --rf-bg: #ffffff;
3296
- --rf-bg-subtle: rgb(55 53 47 / 4%);
3297
- --rf-bg-hover: rgb(55 53 47 / 7%);
3298
- --rf-border: rgb(55 53 47 / 9%);
3299
- --rf-hairline: rgb(15 15 15 / 10%);
3300
- --rf-text: #37352f;
3301
- --rf-text-muted: rgb(55 53 47 / 68%);
3302
- --rf-primary: #37352f;
3303
- --rf-primary-text: #ffffff;
3304
- --rf-accent: #2383e2;
3305
- --rf-accent-text: #ffffff;
3306
- --rf-danger: #d44c47;
3307
- --rf-success: #448361;
3308
- --rf-swatch-base: #ffffff;
3309
- --rf-spectrum: conic-gradient(#ff5757, #ffde59, #6bdb78, #4ac9fa, #8b5cf6, #ef73dc, #ff5757);
3310
- --rf-radius: 20px;
3311
- --rf-pill: 999px;
3312
- --rf-attachment-height: 78px;
3313
- --rf-width: 360px;
3314
- --rf-glass: rgb(255 255 255 / 72%);
3315
- --rf-glass-highlight: rgb(255 255 255 / 90%);
3316
- --rf-glass-stroke: rgb(255 255 255 / 70%);
3317
- --rf-radius-sm: 8px;
3318
- --rf-shadow-inset: inset 0 0 0 1px var(--rf-border);
3319
- --rf-ring: 0 0 0 1px rgb(15 15 15 / 4%);
3320
- --rf-shadow-low: 0 1px 2px rgb(15 15 15 / 6%), 0 4px 10px -4px rgb(15 15 15 / 10%);
3321
- --rf-shadow: 0 2px 5px -1px rgb(15 15 15 / 7%), 0 12px 32px -8px rgb(15 15 15 / 16%);
3322
- --rf-shadow-float: 0 4px 12px -3px rgb(15 15 15 / 35%);
3323
- --rf-ease: cubic-bezier(0.16, 1, 0.3, 1);
3324
- color-scheme: light;
3325
- all: initial;
3326
- font-family: var(--rf-font, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif);
3327
- }
3328
-
3329
- :host([data-theme="dark"]) {
3330
- --rf-bg: #202020;
3331
- --rf-bg-subtle: rgb(255 255 255 / 5%);
3332
- --rf-bg-hover: rgb(255 255 255 / 9%);
3333
- --rf-border: rgb(255 255 255 / 9%);
3334
- --rf-hairline: rgb(255 255 255 / 12%);
3335
- --rf-text: #ebeae8;
3336
- --rf-text-muted: rgb(255 255 255 / 68%);
3337
- --rf-glass: rgb(32 32 32 / 78%);
3338
- --rf-glass-highlight: rgb(255 255 255 / 14%);
3339
- --rf-glass-stroke: rgb(255 255 255 / 12%);
3340
- --rf-primary: #f1efec;
3341
- --rf-primary-text: #1c1c1b;
3342
- --rf-accent: #529cca;
3343
- --rf-danger: #eb5757;
3344
- --rf-success: #4dab74;
3345
- --rf-ring: 0 0 0 1px rgb(255 255 255 / 7%);
3346
- --rf-shadow-low: 0 1px 2px rgb(0 0 0 / 30%), 0 4px 10px -4px rgb(0 0 0 / 40%);
3347
- --rf-shadow: 0 2px 5px -1px rgb(0 0 0 / 35%), 0 14px 36px -10px rgb(0 0 0 / 55%);
3348
- --rf-shadow-float: 0 4px 14px -3px rgb(0 0 0 / 55%);
3349
- color-scheme: dark;
3350
- }
3351
-
3352
- *, *::before, *::after { box-sizing: border-box; }
3353
-
3354
-
3355
- .root {
3356
- position: fixed;
3357
- z-index: ${REFLET_Z_INDEX.widgetRoot};
3358
- display: flex;
3359
- align-items: flex-end;
3360
- gap: 8px;
3361
- font: 13px/1.45 var(--rf-font, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif);
3362
- color: var(--rf-text);
3363
- }
3364
- .root:has(.panel) { width: min(var(--rf-width), calc(var(--rf-viewport-width, 100vw) - 24px)); }
3365
- .root[data-position$="right"] { right: calc(var(--rf-viewport-right, 0px) + max(12px, min(var(--rf-offset), calc((var(--rf-viewport-width, 100vw) - var(--rf-width)) / 2)))); }
3366
- .root[data-position$="left"] { left: calc(var(--rf-viewport-left, 0px) + max(12px, min(var(--rf-offset), calc((var(--rf-viewport-width, 100vw) - var(--rf-width)) / 2)))); }
3367
- .root[data-position^="bottom"] { bottom: calc(var(--rf-viewport-bottom, 0px) + max(var(--rf-offset), env(safe-area-inset-bottom))); }
3368
- .root[data-position^="top"] { top: calc(var(--rf-viewport-top, 0px) + max(var(--rf-offset), env(safe-area-inset-top))); align-items: flex-start; }
3369
- .root[data-position="bottom-right"] .panel { transform-origin: bottom right; }
3370
- .root[data-position="bottom-left"] .panel { transform-origin: bottom left; }
3371
- .root[data-position^="top"] .panel { transform-origin: top center; }
3372
- .root[data-editing="true"] { visibility: hidden; pointer-events: none; }
3373
- button, select, input, textarea { font: inherit; color: inherit; }
3374
- button { margin: 0; cursor: pointer; border: 0; background: none; transition: scale 120ms var(--rf-ease); }
3375
- button:disabled { cursor: not-allowed; opacity: .4; }
3376
- button:active:not(:disabled) { scale: .97; }
3377
- svg { flex-shrink: 0; }
3378
- :focus-visible { outline: 2px solid var(--rf-accent); outline-offset: 3px; }
3379
- .icon-btn, .tool {
3380
- display: inline-flex; align-items: center; justify-content: center;
3381
- width: 40px; height: 40px; flex: none; border-radius: var(--rf-pill); color: var(--rf-text-muted);
3382
- transition: background 160ms var(--rf-ease), color 160ms var(--rf-ease), scale 120ms var(--rf-ease);
3383
- }
3384
- .icon-btn:hover, .tool:hover { background: var(--rf-bg-hover); color: var(--rf-text); }
3385
- .launcher {
3386
- display: inline-flex; align-items: center; gap: 7px; height: 40px; padding: 0 15px;
3387
- border-radius: var(--rf-pill); color: var(--rf-text);
3388
- font-weight: 500;
3389
- transition: transform 200ms var(--rf-ease), box-shadow 200ms var(--rf-ease), scale 120ms var(--rf-ease);
3390
- }
3391
- .launcher:hover { transform: translateY(-2px); box-shadow: var(--rf-shadow); }
3392
- .launcher-icon { display: flex; width: 15px; height: 15px; }
3393
- .launcher-icon .on-close { display: none; }
3394
- .floating-controls {
3395
- display: flex; align-items: center; flex: none;
3396
- margin-right: auto; padding-left: 3px;
3397
- animation: rf-in 220ms var(--rf-ease);
3398
- }
3399
- .drag-handle { touch-action: none; cursor: grab; }
3400
- .drag-handle:active { cursor: grabbing; }
3401
- .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
3402
- .ghost-btn { display: flex; align-items: center; gap: 8px; min-height: 40px; padding: 0 8px; border-radius: var(--rf-radius-sm); }
3403
- .ghost-btn:hover { background: var(--rf-bg-hover); }
3404
- .error { margin: 0; padding: 10px 14px; border-radius: var(--rf-radius); font-size: 12px; color: var(--rf-danger); }
3405
- .done { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 24px; text-align: center; }
3406
- .done svg { color: var(--rf-success); }
3407
- .done h2 { margin: 0; font-size: 14px; font-weight: 600; }
3408
- .done p { margin: 0; font-size: 12px; color: var(--rf-text-muted); }
3409
- .spinner { width: 14px; height: 14px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: rf-spin 700ms linear infinite; }
3410
- .hp { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
3411
- @keyframes rf-spin { to { transform: rotate(360deg); } }
3412
- @keyframes rf-in { from { opacity: 0; transform: translateY(5px) scale(.98); } }
3413
- @keyframes rf-fade { from { opacity: 0; } }
3414
- @media (pointer: coarse) {
3415
- .icon-btn, .tool { width: 44px; height: 44px; }
3416
- .launcher, .ghost-btn { min-height: 44px; }
3417
- }
3418
- .glass {
3419
- position: relative;
3420
- isolation: isolate;
3421
- border: 1px solid var(--rf-glass-stroke);
3422
- box-shadow: inset 0 1px 0 var(--rf-glass-highlight), var(--rf-shadow);
3423
- }
3424
- .glass::before {
3425
- content: "";
3426
- position: absolute;
3427
- inset: 0;
3428
- z-index: -1;
3429
- border-radius: inherit;
3430
- background: var(--rf-bg);
3431
- pointer-events: none;
3432
- }
3433
- @supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) {
3434
- .glass::before {
3435
- background: var(--rf-glass);
3436
- -webkit-backdrop-filter: blur(20px) saturate(1.6);
3437
- backdrop-filter: blur(20px) saturate(1.6);
3438
- }
3439
- }
3440
- @media (prefers-reduced-transparency: reduce), (prefers-contrast: more) {
3441
- .glass::before { background: var(--rf-bg); backdrop-filter: none; -webkit-backdrop-filter: none; }
3442
- }
3443
- .capture-halo { position: fixed; inset: 0; z-index: ${REFLET_Z_INDEX.captureHalo}; pointer-events: none; opacity: 0; transition: opacity 500ms var(--rf-ease); }
3444
- .capture-halo[data-active="true"] { opacity: 1; transition-duration: 140ms; }
3445
- .capture-halo::before { content: ""; position: absolute; inset: 0; border-radius: 20px; box-shadow: inset 14px 0 36px -16px var(--rf-accent), inset -14px 0 36px -16px var(--rf-accent), inset 0 0 4px 1px color-mix(in srgb, var(--rf-accent) 35%, transparent); }
3446
- .capture-halo::after { content: ""; position: absolute; inset: 0; background: linear-gradient(90deg, color-mix(in srgb, var(--rf-accent) 20%, transparent), transparent 5%, transparent 95%, color-mix(in srgb, var(--rf-accent) 20%, transparent)); }
3447
- ${COMPOSER_STYLES}
3448
- ${ANNOTATOR_STYLES}
3449
- ${PICKER_STYLES}
3450
- @media (prefers-reduced-motion: reduce) {
3451
- *, *::before, *::after { animation: none !important; transition: none !important; }
3452
- button:active:not(:disabled) { scale: 1; }
1601
+ function useWidgetState(props) {
1602
+ const { client, dismissalKey, dismissForDays, isAnonymous } = useWidgetConfig(props);
1603
+ const [isOpen, setIsOpen] = useState(false);
1604
+ const [annotationTrigger, setAnnotationTrigger] = useState(null);
1605
+ const [step, setStep] = useState("compose");
1606
+ const [message, setMessage] = useState("");
1607
+ const [email, setEmail] = useState("");
1608
+ const [honeypot, setHoneypot] = useState("");
1609
+ const [isElementCapturing, setIsElementCapturing] = useState(false);
1610
+ const successTimer = useRef(null);
1611
+ const [error, setError] = useState(null);
1612
+ const [dismissedUntil, setDismissedUntil] = useState(() => readDismissedUntil(dismissalKey));
1613
+ const pickRef = useRef(0);
1614
+ useEffect(() => () => {
1615
+ pickRef.current++;
1616
+ if (successTimer.current) clearTimeout(successTimer.current);
1617
+ }, []);
1618
+ const consoleRef = useRef(null);
1619
+ useEffect(() => {
1620
+ setDismissedUntil(readDismissedUntil(dismissalKey));
1621
+ }, [dismissalKey]);
1622
+ useEffect(() => {
1623
+ if (props.captureConsole === false) return;
1624
+ const recorder = startConsoleRecorder();
1625
+ consoleRef.current = recorder;
1626
+ return () => {
1627
+ consoleRef.current = null;
1628
+ recorder.stop();
1629
+ };
1630
+ }, [props.captureConsole]);
1631
+ const captureFailed = props.labels?.captureFailed ?? DEFAULT_WIDGET_LABELS.captureFailed;
1632
+ const gallery = useScreenshotDrafts(captureFailed, setError);
1633
+ const { activeScreenshot, cancelPendingCapture, refreshAutomatic, resetScreenshots, storeScreenshot } = gallery;
1634
+ const capture = activeScreenshot?.image ?? null;
1635
+ const annotations = activeScreenshot?.annotations ?? [];
1636
+ const isCapturing = gallery.pendingCapture !== null || isElementCapturing;
1637
+ const submission = useFeedbackSubmission({
1638
+ client,
1639
+ onError: setError,
1640
+ onSubmitted: (feedbackId) => {
1641
+ setStep("success");
1642
+ props.onSubmit?.({ feedbackId });
1643
+ successTimer.current = setTimeout(close, SUCCESS_CLOSE_DELAY);
1644
+ },
1645
+ partialUploadMessage: props.labels?.attachmentUploadFailed ?? DEFAULT_WIDGET_LABELS.attachmentUploadFailed
1646
+ });
1647
+ const { resetSubmission } = submission;
1648
+ const firstCapture = gallery.pendingCapture ?? gallery.screenshots[0];
1649
+ const canRefreshAutomatically = gallery.screenshots.length <= 1 && firstCapture?.source === "automatic";
1650
+ useCaptureSync(isOpen && !submission.isEditingDisabled && step === "compose" && canRefreshAutomatically && annotations.length === 0 && !gallery.screenshots.some((draft) => draft.selection) && props.captureOnOpen !== false, refreshAutomatic);
1651
+ const reset = useCallback(() => {
1652
+ if (successTimer.current) clearTimeout(successTimer.current);
1653
+ setAnnotationTrigger(null);
1654
+ resetSubmission();
1655
+ pickRef.current++;
1656
+ setIsElementCapturing(false);
1657
+ resetScreenshots();
1658
+ setMessage("");
1659
+ setEmail("");
1660
+ setHoneypot("");
1661
+ setError(null);
1662
+ setStep("compose");
1663
+ }, [resetSubmission, resetScreenshots]);
1664
+ const close = useCallback(() => {
1665
+ setIsOpen(false);
1666
+ reset();
1667
+ props.onClose?.();
1668
+ }, [reset, props.onClose]);
1669
+ const dismiss = useCallback(() => {
1670
+ if (dismissForDays === null) return;
1671
+ const until = Date.now() + dismissForDays * MILLISECONDS_PER_DAY;
1672
+ writeDismissedUntil(dismissalKey, until);
1673
+ setIsOpen(false);
1674
+ reset();
1675
+ setDismissedUntil(until);
1676
+ props.onDismiss?.({ until });
1677
+ }, [
1678
+ dismissForDays,
1679
+ dismissalKey,
1680
+ props.onDismiss,
1681
+ reset
1682
+ ]);
1683
+ const open = useCallback(() => {
1684
+ setIsOpen(true);
1685
+ setStep("compose");
1686
+ props.onOpen?.();
1687
+ if (props.captureOnOpen !== false) refreshAutomatic();
1688
+ }, [
1689
+ props.captureOnOpen,
1690
+ props.onOpen,
1691
+ refreshAutomatic
1692
+ ]);
1693
+ const selectElement = useCallback(async (element, note = "") => {
1694
+ cancelPendingCapture();
1695
+ setIsElementCapturing(true);
1696
+ const picked = note ? {
1697
+ ...buildElementSelection(element),
1698
+ comment: note.slice(0, MAX_SELECTION_COMMENT_LENGTH)
1699
+ } : buildElementSelection(element);
1700
+ setStep("compose");
1701
+ const pick = ++pickRef.current;
1702
+ const context = collectPageContext({ sdkVersion: SDK_VERSION });
1703
+ const [fresh, closeUp] = await Promise.all([captureViewport().catch(() => null), captureElementCloseUp(element).catch(() => null)]);
1704
+ if (pickRef.current !== pick) {
1705
+ releaseCapture(fresh);
1706
+ releaseCapture(closeUp);
1707
+ return;
1708
+ }
1709
+ setIsElementCapturing(false);
1710
+ if (!fresh) {
1711
+ releaseCapture(closeUp);
1712
+ setError(captureFailed);
1713
+ return;
1714
+ }
1715
+ storeScreenshot({
1716
+ annotations: [highlightFor(picked, fresh)],
1717
+ closeUp,
1718
+ context,
1719
+ id: crypto.randomUUID(),
1720
+ image: fresh,
1721
+ selectedNode: element,
1722
+ selection: picked,
1723
+ source: "manual"
1724
+ });
1725
+ }, [
1726
+ cancelPendingCapture,
1727
+ captureFailed,
1728
+ storeScreenshot
1729
+ ]);
1730
+ const selections = gallery.screenshots.flatMap(({ selection }) => selection ? [selection] : []);
1731
+ const firstElementNote = selections.find(({ comment }) => comment)?.comment;
1732
+ const reportedMessage = message.trim() || firstElementNote || "";
1733
+ const submit = () => {
1734
+ if (honeypot) {
1735
+ setStep("success");
1736
+ return;
1737
+ }
1738
+ setStep("compose");
1739
+ return submission.submit({
1740
+ context: {
1741
+ ...activeScreenshot?.context ?? collectPageContext({ sdkVersion: "0.5.0" }),
1742
+ consoleEvents: consoleRef.current?.events(),
1743
+ metadata: props.metadata,
1744
+ selections
1745
+ },
1746
+ email,
1747
+ isAnonymous,
1748
+ message: reportedMessage,
1749
+ screenshots: gallery.screenshots
1750
+ });
1751
+ };
1752
+ const requestSubmit = () => {
1753
+ if (isAnonymous && email.length === 0 && !submission.hasPendingAttachments && !honeypot) {
1754
+ setStep("email");
1755
+ return;
1756
+ }
1757
+ return submit();
1758
+ };
1759
+ return {
1760
+ activeScreenshot,
1761
+ annotateScreenshot: (id, trigger) => {
1762
+ setAnnotationTrigger(trigger);
1763
+ gallery.selectScreenshot(id);
1764
+ setStep("annotate");
1765
+ },
1766
+ annotations,
1767
+ annotationTrigger,
1768
+ canDismiss: dismissForDays !== null,
1769
+ canSubmit: reportedMessage.length > 0,
1770
+ capture,
1771
+ close,
1772
+ dismiss,
1773
+ dismissForDays,
1774
+ email,
1775
+ error,
1776
+ hasPendingAttachments: submission.hasPendingAttachments,
1777
+ honeypot,
1778
+ isAnonymous,
1779
+ isCapturing,
1780
+ isDismissed: dismissedUntil > Date.now(),
1781
+ isEditingDisabled: submission.isEditingDisabled,
1782
+ isOpen,
1783
+ isSubmitting: submission.isSubmitting,
1784
+ message,
1785
+ open,
1786
+ pendingCapture: gallery.pendingCapture,
1787
+ removeCapture: gallery.removeCapture,
1788
+ requestSubmit,
1789
+ retakeCapture: gallery.retakeCapture,
1790
+ screenshots: gallery.screenshots,
1791
+ selectElement,
1792
+ selections,
1793
+ setAnnotations: gallery.setAnnotations,
1794
+ setEmail,
1795
+ setHoneypot,
1796
+ setMessage,
1797
+ setSelectionComment: gallery.setSelectionComment,
1798
+ setStep,
1799
+ step,
1800
+ submit,
1801
+ takeCapture: gallery.takeCapture
1802
+ };
3453
1803
  }
3454
- `;
3455
1804
  //#endregion
3456
- //#region src/feedback/ui/shadow-portal.tsx
3457
- const DARK_QUERY = "(prefers-color-scheme: dark)";
3458
- const KEY_EVENTS = [
3459
- "keydown",
3460
- "keyup",
3461
- "keypress"
3462
- ];
3463
- function useResolvedTheme(theme) {
3464
- const [systemDark, setSystemDark] = useState(false);
1805
+ //#region src/feedback/ui/floating/floating-widget.tsx
1806
+ function FloatingWidget({ hotkey, labels, position, state }) {
1807
+ const [minimized, setMinimized] = useState(false);
1808
+ const launcherRef = useRef(null);
1809
+ const showPanel = state.isOpen && !minimized;
1810
+ const floating = useFloatingPosition(position, showPanel);
1811
+ const minimize = () => {
1812
+ setMinimized(true);
1813
+ requestAnimationFrame(() => launcherRef.current?.focus());
1814
+ };
3465
1815
  useEffect(() => {
3466
- if (theme !== "auto" || typeof window.matchMedia !== "function") return;
3467
- const query = window.matchMedia(DARK_QUERY);
3468
- setSystemDark(query.matches);
3469
- const onChange = (event) => setSystemDark(event.matches);
3470
- query.addEventListener("change", onChange);
3471
- return () => query.removeEventListener("change", onChange);
3472
- }, [theme]);
3473
- if (theme === "auto") return systemDark ? "dark" : "light";
3474
- return theme;
3475
- }
3476
- /**
3477
- * Hosts the widget in a shadow root: the app's CSS cannot reach the panel and
3478
- * the panel's CSS cannot reach the app. Direct DOM work is unavoidable here —
3479
- * React has no declarative way to create a shadow root.
3480
- */
3481
- function ShadowPortal({ children, offset, primaryColor, theme }) {
3482
- const [shadowRoot, setShadowRoot] = useState(null);
3483
- const resolvedTheme = useResolvedTheme(theme);
1816
+ if (state.step === "compose") state.annotationTrigger?.button.focus({ preventScroll: true });
1817
+ }, [state.step, state.annotationTrigger]);
3484
1818
  useEffect(() => {
3485
- const host = document.createElement("div");
3486
- host.setAttribute(WIDGET_MARKER, "");
3487
- const root = host.attachShadow({ mode: "open" });
3488
- for (const type of KEY_EVENTS) root.addEventListener(type, (event) => event.stopPropagation());
3489
- const style = document.createElement("style");
3490
- style.textContent = WIDGET_STYLES;
3491
- root.append(style);
3492
- document.body.append(host);
3493
- setShadowRoot(root);
3494
- return () => host.remove();
3495
- }, []);
1819
+ if (!showPanel || state.step !== "compose") return;
1820
+ const onKeyDown = (event) => {
1821
+ if (event.key === "Escape" && !event.defaultPrevented) {
1822
+ setMinimized(true);
1823
+ requestAnimationFrame(() => launcherRef.current?.focus());
1824
+ }
1825
+ };
1826
+ return listenToKeydown(floating.rootRef.current, onKeyDown);
1827
+ }, [
1828
+ floating.rootRef,
1829
+ showPanel,
1830
+ state.step
1831
+ ]);
1832
+ const { close, isOpen, open } = state;
3496
1833
  useEffect(() => {
3497
- const host = shadowRoot?.host;
3498
- if (!(host instanceof HTMLElement)) return;
3499
- host.setAttribute("data-theme", resolvedTheme);
3500
- host.style.setProperty("--rf-offset", `${offset}px`);
3501
- if (primaryColor) host.style.setProperty("--rf-primary", primaryColor);
1834
+ if (!hotkey) return;
1835
+ const toggleOnHotkey = (event) => {
1836
+ if (matchesHotkey(event, hotkey)) {
1837
+ event.preventDefault();
1838
+ if (isOpen) close();
1839
+ else open();
1840
+ }
1841
+ };
1842
+ return listenToKeydown(floating.rootRef.current, toggleOnHotkey, true);
3502
1843
  }, [
3503
- shadowRoot,
3504
- resolvedTheme,
3505
- primaryColor,
3506
- offset
1844
+ close,
1845
+ floating.rootRef,
1846
+ hotkey,
1847
+ isOpen,
1848
+ open
3507
1849
  ]);
3508
- if (!shadowRoot) return null;
3509
- return createPortal(children, shadowRoot);
1850
+ return /* @__PURE__ */ jsxs("div", {
1851
+ className: "root",
1852
+ "data-editing": state.isOpen && (state.step === "picking" || state.step === "annotate"),
1853
+ "data-moved": Boolean(floating.position),
1854
+ "data-position": position,
1855
+ ref: floating.rootRef,
1856
+ children: [
1857
+ /* @__PURE__ */ jsx("style", { children: floating.styles }),
1858
+ showPanel && state.step === "compose" && state.screenshots.map(({ id, selectedNode }) => selectedNode ? /* @__PURE__ */ jsx(SelectionOutline, { node: selectedNode }, id) : null),
1859
+ showPanel ? /* @__PURE__ */ jsx(FeedbackPanel, {
1860
+ floatingControls: /* @__PURE__ */ jsxs("div", {
1861
+ className: "floating-controls",
1862
+ children: [/* @__PURE__ */ jsx("button", {
1863
+ "aria-label": labels.moveFeedback,
1864
+ className: "icon-btn drag-handle",
1865
+ title: labels.moveFeedback,
1866
+ type: "button",
1867
+ ...floating.handleProps,
1868
+ children: /* @__PURE__ */ jsx(GripIcon, {})
1869
+ }), /* @__PURE__ */ jsx("button", {
1870
+ "aria-label": labels.minimize,
1871
+ className: "icon-btn",
1872
+ onClick: minimize,
1873
+ title: labels.minimize,
1874
+ type: "button",
1875
+ children: /* @__PURE__ */ jsx(CloseIcon, {})
1876
+ })]
1877
+ }),
1878
+ labels,
1879
+ state
1880
+ }) : /* @__PURE__ */ jsx(Launcher, {
1881
+ buttonRef: launcherRef,
1882
+ isOpen: false,
1883
+ label: state.isOpen ? labels.resume : labels.trigger,
1884
+ onClick: () => {
1885
+ setMinimized(false);
1886
+ if (!state.isOpen) state.open();
1887
+ }
1888
+ })
1889
+ ]
1890
+ });
1891
+ }
1892
+ //#endregion
1893
+ //#region src/feedback/ui/annotation/styles.ts
1894
+ const ANNOTATOR_STYLES = `
1895
+ .overlay { margin: 0; border: 0; padding: 0; width: 100vw; max-width: none; height: 100dvh; max-height: none; position: fixed; inset: 0; z-index: ${REFLET_Z_INDEX.annotatorOverlay}; display: flex; flex-direction: column; background: transparent; color: var(--rf-text); font: 13px/1.45 ui-sans-serif, system-ui, sans-serif; }
1896
+ .overlay::before { content: ""; position: fixed; inset: 0; z-index: -1; pointer-events: none; background: color-mix(in srgb, var(--rf-bg) 94%, transparent); animation: rf-editor-in 260ms var(--rf-ease); transition: opacity 260ms var(--rf-ease); }
1897
+ .overlay::backdrop { background: transparent; }
1898
+ @keyframes rf-editor-in { from { opacity: 0; } }
1899
+ .editor { flex: 1; position: relative; display: flex; align-items: center; justify-content: center; padding: 20px; min-height: 0; container-type: size; }
1900
+ .editor-image { width: min(100cqw, calc(100cqh * var(--rf-capture-ratio))); aspect-ratio: var(--rf-capture-ratio); flex: none; position: relative; }
1901
+ .editor-visual { position: absolute; inset: 0; border-radius: var(--rf-radius); box-shadow: var(--rf-shadow); transform-origin: top left; overflow: hidden; }
1902
+ .editor-visual img, .editor-visual canvas { position: absolute; inset: 0; width: 100%; height: 100%; border-radius: inherit; object-fit: cover; object-position: top center; outline: 1px solid var(--rf-hairline); outline-offset: -1px; }
1903
+ .editor-visual canvas { cursor: crosshair; touch-action: none; }
1904
+ .overlay[data-opening="true"] .editor { pointer-events: none; }
1905
+ .overlay[data-closing="true"] { pointer-events: none; }
1906
+ .overlay[data-closing="true"]::before { opacity: 0; }
1907
+ .overlay[data-closing="true"] .toolbar, .overlay[data-closing="true"] .element-note { opacity: 0; transform: translateY(8px); }
1908
+
1909
+ .element-note { order: 1; display: flex; align-items: flex-start; gap: 10px; width: min(560px, calc(100vw - 32px)); margin: 0 auto 10px; padding: 10px 14px; border-radius: var(--rf-radius); transition: opacity 180ms var(--rf-ease), transform 180ms var(--rf-ease); }
1910
+ .element-note svg { flex: none; margin-top: 4px; opacity: 0.7; }
1911
+ .element-note textarea { flex: 1; min-width: 0; height: 42px; max-height: 96px; padding: 0; border: 0; background: transparent; color: inherit; font: inherit; resize: none; }
1912
+ .element-note textarea:focus { outline: none; }
1913
+ .element-note:focus-within { outline: 2px solid var(--rf-accent); outline-offset: 2px; }
1914
+
1915
+ .toolbar { order: 2; display: flex; align-items: center; justify-content: center; gap: 8px; flex-wrap: wrap; transition: opacity 180ms var(--rf-ease), transform 180ms var(--rf-ease); padding: 4px 12px max(16px, env(safe-area-inset-bottom)); }
1916
+ .toolbar .group { display: flex; align-items: center; gap: 2px; padding: 4px; border-radius: var(--rf-pill); }
1917
+ .tool[aria-pressed="true"], .tool[data-selected="true"] { background: var(--rf-primary); color: var(--rf-primary-text); }
1918
+ .color-picker { position: relative; }
1919
+ .color-picker summary { display: flex; justify-content: center; align-items: center; }
1920
+ .color-preview { width: 18px; height: 18px; border-radius: 50%; background: var(--annotation-color); box-shadow: inset 0 0 0 1px var(--rf-border); }
1921
+ .color-popover { --rf-color-width: 200px; position: absolute; bottom: calc(100% + 14px); left: clamp(calc(12px - var(--rf-color-anchor)), 0px, calc(100vw - var(--rf-color-anchor) - var(--rf-color-width) - 12px)); display: flex; align-items: center; gap: 6px; width: var(--rf-color-width); padding: 4px 4px 4px 14px; border-radius: var(--rf-pill); animation: rf-in 180ms var(--rf-ease); }
1922
+ .color-picker[data-placement="below"] .color-popover { top: calc(100% + 14px); bottom: auto; }
1923
+ .color-slider { flex: 1; min-width: 0; }
1924
+ .color-slider-control { display: flex; align-items: center; height: 40px; width: 100%; touch-action: none; user-select: none; cursor: pointer; }
1925
+ .color-slider-track { height: 12px; width: 100%; border-radius: var(--rf-pill); background: linear-gradient(to right, var(--rf-swatch-base), var(--annotation-base-color)); box-shadow: inset 0 0 0 1px var(--rf-border); }
1926
+ .color-slider-thumb { width: 16px; height: 16px; border: 2px solid var(--rf-swatch-base); border-radius: 50%; background: var(--annotation-color); box-shadow: var(--rf-shadow-low); transition: scale 160ms var(--rf-ease); }
1927
+ .color-slider-thumb[data-dragging] { scale: 1.12; }
1928
+ .color-slider-thumb:has(:focus-visible) { outline: 2px solid var(--rf-accent); outline-offset: 3px; }
1929
+ .color-spectrum { position: relative; display: flex; align-items: center; justify-content: center; flex: none; width: 40px; height: 40px; border-radius: 50%; cursor: pointer; }
1930
+ .color-spectrum::before { content: ""; width: 16px; height: 16px; border-radius: 50%; background: var(--rf-spectrum); box-shadow: inset 0 0 0 1px var(--rf-glass-highlight); }
1931
+ .color-spectrum:hover { background: var(--rf-bg-hover); }
1932
+ .color-spectrum button { position: absolute; inset: 0; width: 100%; height: 100%; border-radius: inherit; }
1933
+ .color-spectrum button:focus-visible { outline: 2px solid var(--rf-accent); outline-offset: 3px; }
1934
+ .color-spectrum input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; pointer-events: none; }
1935
+ .done-btn { display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; padding: 0; border-radius: var(--rf-pill); color: var(--rf-primary-text); background: var(--rf-primary); }
1936
+ .drawing-menu { position: relative; }
1937
+ .drawing-popover { position: absolute; z-index: 2; right: 0; bottom: calc(100% + 12px); width: 200px; max-height: calc(50dvh - 68px); overflow-y: auto; padding: 6px; border-radius: var(--rf-radius); }
1938
+ .drawing-menu[data-placement="below"] .drawing-popover { top: calc(100% + 12px); bottom: auto; }
1939
+ .drawing-option { display: flex; align-items: center; width: 100%; min-height: 40px; gap: 10px; padding: 0 10px; border-radius: 12px; text-align: left; }
1940
+ .drawing-option:hover, .drawing-option[aria-pressed="true"] { background: var(--rf-bg-hover); }
1941
+ .drawing-divider { height: 1px; margin: 5px; background: var(--rf-border); }
1942
+ .editor canvas[data-tool="text"] { cursor: text; }
1943
+ .text-annotation-editor { position: fixed; z-index: 3; width: 240px; padding: 8px; border-radius: var(--rf-radius); }
1944
+ .text-annotation-editor:focus-within { outline: 2px solid var(--rf-accent); outline-offset: 2px; }
1945
+ .text-annotation-editor textarea { display: block; width: 100%; min-height: 64px; max-height: 112px; padding: 6px; border: 0; background: transparent; resize: vertical; font-size: 16px; line-height: 24px; }
1946
+ .text-annotation-editor textarea:focus { outline: none; }
1947
+ .text-annotation-actions { display: flex; align-items: center; justify-content: flex-end; gap: 4px; }
1948
+ @media (pointer: coarse) {
1949
+ .drawing-option { min-height: 44px; }
1950
+ .done-btn { width: 44px; height: 44px; }
1951
+ .color-popover { --rf-color-width: 232px; padding-left: 16px; }
1952
+ .color-slider-control, .color-spectrum { height: 44px; }
1953
+ .color-spectrum { width: 44px; }
1954
+ .color-slider-thumb, .color-spectrum::before { width: 18px; height: 18px; }
1955
+ .editor { padding: 8px; }
1956
+ .toolbar { gap: 6px; }
1957
+ .toolbar .group { padding: 3px; gap: 0; }
1958
+ }
1959
+ @media (pointer: coarse) and (max-width: 360px) {
1960
+ .toolbar { gap: 4px; padding-inline: 2px; }
1961
+ .toolbar .group { padding: 0; }
3510
1962
  }
1963
+ `;
3511
1964
  //#endregion
3512
- //#region src/feedback/core/console-recorder.ts
3513
- const DEFAULT_LIMIT = 30;
3514
- const MAX_MESSAGE_LENGTH = 1e3;
3515
- const RECORDED_METHODS = ["error", "warn"];
3516
- function serializeArgument(value) {
3517
- if (typeof value === "string") return value;
3518
- if (value instanceof Error) return `${value.name}: ${value.message}`;
3519
- if (value === null || value === void 0) return String(value);
3520
- if (typeof value === "object") try {
3521
- return JSON.stringify(value) ?? "[unserializable]";
3522
- } catch {
3523
- return "[unserializable]";
3524
- }
3525
- if (typeof value === "function") return "[function]";
3526
- return String(value);
1965
+ //#region src/feedback/ui/composer/styles.ts
1966
+ const COMPOSER_STYLES = `
1967
+ .panel { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 8px; outline: none; animation: rf-in 240ms var(--rf-ease); }
1968
+ .success-panel { border-radius: var(--rf-radius); }
1969
+ .composer { position: relative; display: flex; flex-direction: column; gap: 8px; }
1970
+ .message-row { display: flex; flex-direction: column; gap: 2px; padding: 6px; border-radius: 26px; }
1971
+ .message-row textarea {
1972
+ width: 100%; height: 76px; max-height: min(140px, 30dvh);
1973
+ margin: 0; padding: 6px 6px 0 8px; border: 0; resize: none;
1974
+ background: transparent; line-height: 20px;
3527
1975
  }
3528
- function formatMessage(args) {
3529
- const message = args.map(serializeArgument).join(" ");
3530
- return message.length > MAX_MESSAGE_LENGTH ? `${message.slice(0, 999)}…` : message;
1976
+ .message-actions { display: flex; align-items: center; gap: 2px; }
1977
+ .message-row textarea::placeholder { color: var(--rf-text-muted); }
1978
+ .message-row textarea:focus { outline: none; }
1979
+ .message-row:has(textarea:focus-visible) { border-color: color-mix(in srgb, var(--rf-accent) 40%, var(--rf-glass-stroke)); }
1980
+ .submit { display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; flex: none; border-radius: var(--rf-pill); background: var(--rf-primary); color: var(--rf-primary-text); transition: opacity 160ms var(--rf-ease), transform 160ms var(--rf-ease); }
1981
+ .submit svg { transform: rotate(-45deg); }
1982
+ .submit:hover:enabled { transform: scale(1.05); }
1983
+ .submit:disabled { background: var(--rf-bg-hover); color: var(--rf-text-muted); opacity: .65; }
1984
+ .composer-toolbar { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
1985
+ .composer-options { position: relative; }
1986
+ summary { cursor: pointer; list-style: none; }
1987
+ summary::-webkit-details-marker { display: none; }
1988
+ .options-popover { position: absolute; z-index: 2; right: 0; bottom: calc(100% + 12px); width: min(240px, calc(100vw - 40px)); max-height: calc(50dvh - 68px); overflow-y: auto; display: flex; flex-direction: column; gap: 8px; padding: 12px; border-radius: var(--rf-radius); animation: rf-in 160ms var(--rf-ease); }
1989
+ .composer-options[data-placement="below"] .options-popover { top: calc(100% + 12px); bottom: auto; }
1990
+ .email-field { display: flex; flex-direction: column; gap: 6px; color: var(--rf-text-muted); font-size: 12px; }
1991
+ .email-field [role="alert"] { color: var(--rf-danger); }
1992
+ .email-field input { width: 100%; padding: 9px; border: 1px solid var(--rf-border); border-radius: var(--rf-radius-sm); background: var(--rf-bg-subtle); color: var(--rf-text); }
1993
+ .dismiss-btn { min-height: 40px; text-align: left; padding: 0 8px; border-radius: var(--rf-radius-sm); color: var(--rf-text-muted); }
1994
+ .dismiss-btn:hover { background: var(--rf-bg-hover); }
1995
+ .version-info { font-size: 10px; color: var(--rf-text-muted); padding: 4px 8px 0; border-top: 1px solid var(--rf-border); text-decoration: none; }
1996
+ .version-info:hover { color: var(--rf-text); }
1997
+ .email-scrim { display: none; }
1998
+ .email-prompt { display: flex; flex-direction: column; gap: 12px; padding: 16px; border-radius: 22px; animation: rf-in 240ms var(--rf-ease); }
1999
+ .email-prompt h2 { margin: 0; font-size: 14px; font-weight: 600; line-height: 1.3; }
2000
+ .email-prompt p { margin: 0; font-size: 12px; line-height: 1.45; color: var(--rf-text-muted); }
2001
+ .email-prompt .email-field input { min-height: 40px; border-radius: 14px; padding: 0 12px; }
2002
+ .email-prompt-actions { display: flex; align-items: center; justify-content: flex-end; gap: 4px; }
2003
+ .email-prompt-actions .ghost-btn { padding: 0 12px; color: var(--rf-text-muted); }
2004
+ .primary-btn { display: flex; align-items: center; justify-content: center; min-height: 40px; padding: 0 16px; border-radius: var(--rf-pill); background: var(--rf-primary); color: var(--rf-primary-text); font-weight: 500; }
2005
+ .primary-btn:hover { opacity: .9; }
2006
+ .attachments { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; }
2007
+ .attachment-row { display: flex; align-items: flex-end; gap: 8px; min-width: 0; max-width: 100%; }
2008
+ .screenshot-scroll-area { position: relative; min-width: 0; margin: -14px -10px -20px; }
2009
+ .screenshot-strip { width: 100%; padding: 14px 10px 20px; overscroll-behavior-x: contain; --rf-fade-start: 0px; --rf-fade-end: 0px; mask-image: linear-gradient(to right, transparent, black var(--rf-fade-start), black calc(100% - var(--rf-fade-end)), transparent); mask-repeat: no-repeat; }
2010
+ .screenshot-strip[data-overflow-x-start] { --rf-fade-start: calc(10px + min(24px, var(--scroll-area-overflow-x-start))); }
2011
+ .screenshot-strip[data-overflow-x-end] { --rf-fade-end: calc(10px + min(24px, var(--scroll-area-overflow-x-end))); }
2012
+ .screenshot-strip-content { display: flex; align-items: flex-end; gap: 8px; }
2013
+ .screenshot-scroll-area:has(.screenshot-strip-content:empty) { display: none; }
2014
+ .capture-slot { display: flex; flex-direction: column; gap: 2px; flex: none; width: 56px; height: var(--rf-attachment-height); padding: 3px; border: 1px dashed var(--rf-border); border-radius: var(--rf-radius); box-shadow: none; }
2015
+ .capture-action { display: flex; flex: 1; min-height: 0; align-items: center; justify-content: center; border-radius: 5px; color: var(--rf-text-muted); transition: background 160ms var(--rf-ease), color 160ms var(--rf-ease); }
2016
+ .capture-action:first-child { border-radius: 16px 16px 5px 5px; }
2017
+ .capture-action:last-child { border-radius: 5px 5px 16px 16px; }
2018
+ .capture-action:hover:enabled { background: var(--rf-bg-hover); color: var(--rf-text); }
2019
+ .screenshot-scrollbar { height: 4px; margin: 0 8px 2px; opacity: 0; transition: opacity 150ms var(--rf-ease); }
2020
+ .screenshot-scrollbar[data-hovering], .screenshot-scrollbar[data-scrolling] { opacity: 1; }
2021
+ .screenshot-scroll-thumb { border-radius: var(--rf-pill); background: var(--rf-border); }
2022
+ .capture-progress { position: absolute; inset: 0; display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 6px; border-radius: inherit; color: var(--rf-text-muted); font-size: 10px; }
2023
+ .attachments:empty { display: none; }
2024
+ .screenshot-attachment, .attachment-skeleton { position: relative; flex: none; margin: 0; min-width: 0; width: 128px; height: var(--rf-attachment-height); padding: 0; border-radius: 16px; animation: rf-in 260ms var(--rf-ease); }
2025
+ .screenshot-attachment { border: 0; }
2026
+ .screenshot-preview { display: block; position: relative; width: 100%; height: 100%; padding: 0; border-radius: inherit; overflow: hidden; }
2027
+ .screenshot-preview img, .screenshot-preview canvas { width: 100%; height: 100%; display: block; object-fit: cover; object-position: top center; }
2028
+ .screenshot-preview canvas { position: absolute; inset: 0; pointer-events: none; }
2029
+ .annotation-count { position: absolute; left: 5px; bottom: 5px; min-width: 20px; padding: 2px 6px; border-radius: var(--rf-pill); font-size: 10px; font-weight: 600; font-variant-numeric: tabular-nums; text-align: center; color: var(--rf-text); }
2030
+ .attachment-actions { position: absolute; right: 4px; bottom: 4px; display: flex; gap: 1px; padding: 1px; border-radius: var(--rf-pill); }
2031
+ .attachment-actions .icon-btn { width: 28px; height: 28px; }
2032
+ .attachment-actions svg { width: 14px; height: 14px; }
2033
+ .attachment-skeleton { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; color: var(--rf-text-muted); font-size: 11px; }
2034
+ .attachment-skeleton::after { content: ""; position: absolute; inset: 0; border-radius: inherit; background: var(--rf-bg-subtle); animation: rf-fade 800ms alternate infinite; }
2035
+ .selection-badge { position: absolute; left: 5px; top: 5px; display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--rf-pill); color: var(--rf-accent); }
2036
+ .selection-badge svg { width: 12px; height: 12px; }
2037
+ @media (pointer: coarse) {
2038
+ .message-row { border-radius: 28px; }
2039
+ .capture-slot { width: 64px; }
2040
+ .message-row textarea { font-size: 16px; }
2041
+ .submit { width: 44px; height: 44px; }
2042
+ :host { --rf-attachment-height: 96px; }
2043
+ .attachment-actions { right: 5px; bottom: 5px; }
2044
+ .attachment-actions .icon-btn { width: 34px; height: 34px; }
2045
+ .selection-badge { width: 24px; height: 24px; }
2046
+ .email-field input { font-size: 16px; }
2047
+ .dismiss-btn { min-height: 44px; }
3531
2048
  }
3532
- /**
3533
- * Buffers the console noise a user saw right before reporting a bug.
3534
- * Wrappers delegate to the previous implementation, so nothing is swallowed.
3535
- */
3536
- function startConsoleRecorder(options) {
3537
- const limit = options?.limit ?? DEFAULT_LIMIT;
3538
- const buffer = [];
3539
- let active = true;
3540
- const push = (level, message) => {
3541
- if (!(active && message)) return;
3542
- buffer.push({
3543
- level,
3544
- message,
3545
- timestamp: Date.now()
3546
- });
3547
- if (buffer.length > limit) buffer.splice(0, buffer.length - limit);
3548
- };
3549
- const originals = /* @__PURE__ */ new Map();
3550
- const wrappers = /* @__PURE__ */ new Map();
3551
- for (const method of RECORDED_METHODS) {
3552
- const original = console[method];
3553
- const wrapper = (...args) => {
3554
- push(method, formatMessage(args));
3555
- Reflect.apply(original, console, args);
3556
- };
3557
- originals.set(method, original);
3558
- wrappers.set(method, wrapper);
3559
- console[method] = wrapper;
3560
- }
3561
- const onError = (event) => {
3562
- const where = event.filename ? ` (${event.filename}:${event.lineno}:${event.colno})` : "";
3563
- push("error", `${event.message}${where}`);
3564
- };
3565
- const onRejection = (event) => {
3566
- push("error", `Unhandled rejection: ${serializeArgument(event.reason)}`);
3567
- };
3568
- window.addEventListener("error", onError);
3569
- window.addEventListener("unhandledrejection", onRejection);
3570
- return {
3571
- events: () => buffer.slice(),
3572
- stop: () => {
3573
- if (!active) return;
3574
- active = false;
3575
- buffer.length = 0;
3576
- for (const method of RECORDED_METHODS) {
3577
- const original = originals.get(method);
3578
- if (original && console[method] === wrappers.get(method)) console[method] = original;
3579
- }
3580
- window.removeEventListener("error", onError);
3581
- window.removeEventListener("unhandledrejection", onRejection);
3582
- }
3583
- };
2049
+ @media (max-width: 480px) {
2050
+ .email-scrim { display: block; position: fixed; inset: 0; background: rgb(0 0 0 / 40%); animation: rf-fade 200ms var(--rf-ease); }
2051
+ .email-prompt { position: fixed; left: 0; right: 0; bottom: 0; gap: 14px; padding: 20px 16px calc(20px + env(safe-area-inset-bottom)); border-radius: 26px 26px 0 0; animation: rf-drawer-in 280ms var(--rf-ease); }
2052
+ .email-prompt h2 { font-size: 17px; }
2053
+ .email-prompt p { font-size: 13px; }
2054
+ .email-prompt-actions { flex-direction: column-reverse; align-items: stretch; gap: 8px; }
2055
+ .email-prompt-actions .ghost-btn, .email-prompt-actions .primary-btn { justify-content: center; min-height: 48px; }
3584
2056
  }
2057
+ @keyframes rf-drawer-in { from { transform: translateY(100%); } }
2058
+ `;
3585
2059
  //#endregion
3586
- //#region src/feedback/core/submission/upload-screenshots.ts
3587
- async function uploadImage(transport, image) {
3588
- const { uploadUrl } = await transport.getScreenshotUploadUrl();
3589
- return transport.uploadImage(uploadUrl, image);
3590
- }
3591
- async function prepareScreenshot(transport, screenshot, index) {
3592
- const { closeUp, image, annotations, context } = screenshot;
3593
- const annotated = annotations.length > 0 ? await renderAnnotatedImage(image, annotations) : null;
3594
- if (annotations.length > 0 && !annotated) throw new Error("Could not render your annotations. Please try again.");
3595
- try {
3596
- const storageId = await uploadImage(transport, image);
3597
- const prepared = [{
3598
- annotatedStorageId: annotated ? await uploadImage(transport, annotated) : void 0,
3599
- annotations: annotations.length > 0 ? toWireAnnotations(annotations) : void 0,
3600
- captureSource: "widget",
3601
- filename: `screenshot-${index + 1}.png`,
3602
- height: image.height,
3603
- mimeType: image.mimeType,
3604
- pageUrl: context.url,
3605
- size: image.blob.size,
3606
- storageId,
3607
- width: image.width
3608
- }];
3609
- if (closeUp) prepared.push({
3610
- captureSource: "element",
3611
- filename: `element-${index + 1}.png`,
3612
- height: closeUp.height,
3613
- mimeType: closeUp.mimeType,
3614
- pageUrl: context.url,
3615
- size: closeUp.blob.size,
3616
- storageId: await uploadImage(transport, closeUp),
3617
- width: closeUp.width
3618
- });
3619
- return prepared;
3620
- } finally {
3621
- releaseCapture(annotated);
3622
- }
2060
+ //#region src/feedback/ui/floating/picker-styles.ts
2061
+ const PICKER_STYLES = `
2062
+ .picker-box {
2063
+ position: fixed;
2064
+ top: 0;
2065
+ left: 0;
2066
+ pointer-events: none;
2067
+ border-radius: 5px;
2068
+ background: color-mix(in srgb, var(--rf-accent) 10%, transparent);
2069
+ box-shadow: 0 0 0 2px var(--rf-accent), 0 0 0 5px color-mix(in srgb, var(--rf-accent) 18%, transparent);
2070
+ will-change: translate;
2071
+ transition: translate 130ms var(--rf-ease);
3623
2072
  }
3624
- async function uploadScreenshots(transport, submission) {
3625
- return (await Promise.allSettled(submission.screenshots.map((screenshot, index) => prepareScreenshot(transport, screenshot, index)))).flatMap((result) => {
3626
- if (result.status === "rejected") throw result.reason;
3627
- return result.value;
3628
- });
2073
+ .picker-box[data-pinned="true"] { transition: none; }
2074
+ .picker-label {
2075
+ position: fixed;
2076
+ top: 0;
2077
+ left: 0;
2078
+ pointer-events: none;
2079
+ display: inline-flex;
2080
+ align-items: center;
2081
+ gap: 6px;
2082
+ max-width: min(420px, 90vw);
2083
+ height: 24px;
2084
+ padding: 0 8px;
2085
+ font-size: 11.5px;
2086
+ font-weight: 500;
2087
+ color: var(--rf-accent-text);
2088
+ background: var(--rf-accent);
2089
+ border-radius: 6px;
2090
+ box-shadow: var(--rf-shadow-float);
2091
+ white-space: nowrap;
2092
+ overflow: hidden;
2093
+ will-change: translate;
2094
+ transition: translate 130ms var(--rf-ease);
3629
2095
  }
3630
- //#endregion
3631
- //#region src/feedback/core/submit.ts
3632
- const MAX_TITLE_LENGTH = 100;
3633
- const NEWLINE = /\r?\n/;
3634
- function deriveTitle(message) {
3635
- const firstLine = message.split(NEWLINE).map((line) => line.trim()).find(Boolean) ?? "";
3636
- if (firstLine.length <= MAX_TITLE_LENGTH) return firstLine;
3637
- const clipped = firstLine.slice(0, 99);
3638
- const lastSpace = clipped.lastIndexOf(" ");
3639
- return `${(lastSpace > 0 ? clipped.slice(0, lastSpace) : clipped).trimEnd()}…`;
2096
+ .picker-label strong { flex: none; font-weight: 600; }
2097
+ .picker-label span { min-width: 0; overflow: hidden; text-overflow: ellipsis; opacity: 0.75; }
2098
+ .selection-outline {
2099
+ position: fixed;
2100
+ top: 0;
2101
+ left: 0;
2102
+ pointer-events: none;
2103
+ border-radius: 5px;
2104
+ background: color-mix(in srgb, var(--rf-accent) 7%, transparent);
2105
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--rf-accent) 70%, transparent), 0 0 0 5px color-mix(in srgb, var(--rf-accent) 14%, transparent);
2106
+ transition: opacity 160ms var(--rf-ease);
3640
2107
  }
3641
- function buildDescription(params) {
3642
- const { email, isAnonymous, message } = params;
3643
- const contact = isAnonymous && email ? `\n\n---\nContact: ${email}` : "";
3644
- return `${message.trim()}${contact}`;
2108
+ .picker-hint {
2109
+ position: fixed;
2110
+ left: 50%;
2111
+ bottom: calc(24px + env(safe-area-inset-bottom));
2112
+ transform: translateX(-50%);
2113
+ display: flex;
2114
+ align-items: center;
2115
+ gap: 6px;
2116
+ max-width: calc(100vw - 24px);
2117
+ padding: 5px 6px 5px 12px;
2118
+ font-size: 12.5px;
2119
+ color: var(--rf-text);
2120
+ background: var(--rf-bg);
2121
+ border-radius: var(--rf-pill);
2122
+ box-shadow: var(--rf-ring), var(--rf-shadow);
2123
+ animation: rf-fade 240ms var(--rf-ease);
3645
2124
  }
3646
- async function attachPreparedScreenshots(transport, result) {
3647
- const results = await Promise.allSettled(result.pendingScreenshots.map((screenshot) => transport.saveScreenshot({
3648
- ...screenshot,
3649
- feedbackId: result.feedbackId
3650
- })));
3651
- const pendingScreenshots = result.pendingScreenshots.filter((_, index) => results[index]?.status === "rejected");
3652
- return {
3653
- feedbackId: result.feedbackId,
3654
- pendingScreenshots
3655
- };
2125
+ .picker-instruction { display: flex; align-items: center; gap: 6px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2126
+ .picker-hint kbd {
2127
+ padding: 1px 5px;
2128
+ font: inherit;
2129
+ font-size: 11px;
2130
+ color: var(--rf-text-muted);
2131
+ border-radius: 4px;
2132
+ background: var(--rf-bg-subtle);
2133
+ box-shadow: var(--rf-shadow-inset);
3656
2134
  }
3657
- async function createReportWithScreenshots(transport, submission, pendingScreenshots) {
3658
- const { feedbackId } = await transport.create({
3659
- context: submission.context,
3660
- description: buildDescription(submission),
3661
- title: deriveTitle(submission.message)
3662
- });
3663
- return attachPreparedScreenshots(transport, {
3664
- feedbackId,
3665
- pendingScreenshots
3666
- });
2135
+ .picker-cancel {
2136
+ display: inline-flex;
2137
+ align-items: center;
2138
+ height: 40px;
2139
+ padding: 0 14px;
2140
+ border-radius: var(--rf-pill);
2141
+ font-size: 12.5px;
2142
+ font-weight: 500;
2143
+ white-space: nowrap;
2144
+ color: var(--rf-text-muted);
3667
2145
  }
3668
- async function submitWidgetFeedback(transport, submission) {
3669
- if (!deriveTitle(submission.message)) throw new Error("Please describe your feedback before sending it.");
3670
- return createReportWithScreenshots(transport, submission, await uploadScreenshots(transport, submission));
2146
+ .picker-cancel:hover { background: var(--rf-bg-hover); color: var(--rf-text); }
2147
+ .picker-note {
2148
+ position: fixed;
2149
+ z-index: ${REFLET_Z_INDEX.annotatorOverlay};
2150
+ display: flex;
2151
+ flex-direction: column;
2152
+ gap: 6px;
2153
+ width: 300px;
2154
+ max-width: calc(100vw - 24px);
2155
+ padding: 6px 6px 6px 8px;
2156
+ border-radius: 18px;
2157
+ animation: rf-in 180ms var(--rf-ease);
3671
2158
  }
3672
- function isStorageResponse(value) {
3673
- return typeof value === "object" && value !== null && typeof Reflect.get(value, "storageId") === "string";
2159
+ .picker-note:focus-within { outline: 2px solid var(--rf-accent); outline-offset: 2px; }
2160
+ .picker-note-target {
2161
+ display: flex;
2162
+ align-items: center;
2163
+ gap: 6px;
2164
+ margin: 0;
2165
+ padding: 2px 4px 0;
2166
+ font-size: 11px;
2167
+ color: var(--rf-text-muted);
2168
+ white-space: nowrap;
3674
2169
  }
3675
- function createFeedbackTransport(client) {
3676
- return {
3677
- create: (params) => client.create(params),
3678
- getScreenshotUploadUrl: () => client.getScreenshotUploadUrl(),
3679
- saveScreenshot: (params) => client.saveScreenshot(params),
3680
- uploadImage: async (uploadUrl, image) => {
3681
- const response = await fetch(uploadUrl, {
3682
- body: image.blob,
3683
- headers: { "Content-Type": image.mimeType },
3684
- method: "POST"
3685
- });
3686
- if (!response.ok) throw new Error(`Screenshot upload failed (${response.status})`);
3687
- const payload = await response.json();
3688
- if (!isStorageResponse(payload)) throw new Error("Screenshot upload returned no storage id");
3689
- return payload.storageId;
3690
- }
3691
- };
2170
+ .picker-note-target strong { flex: none; font-weight: 600; color: var(--rf-accent); }
2171
+ .picker-note-target span { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
2172
+ .picker-note textarea {
2173
+ width: 100%;
2174
+ min-height: 46px;
2175
+ max-height: min(160px, 30dvh);
2176
+ margin: 0;
2177
+ padding: 7px 6px 0;
2178
+ border: 0;
2179
+ resize: none;
2180
+ background: transparent;
2181
+ color: var(--rf-text);
2182
+ font-size: 13px;
2183
+ line-height: 19px;
3692
2184
  }
3693
- //#endregion
3694
- //#region src/feedback/ui/state/use-feedback-submission.ts
3695
- function useFeedbackSubmission(options) {
3696
- const [activeRequest, setActiveRequest] = useState(null);
3697
- const [pendingReport, setPendingReport] = useState(null);
3698
- const requestRef = useRef(null);
3699
- useEffect(() => () => {
3700
- requestRef.current = null;
3701
- }, []);
3702
- const resetSubmission = useCallback(() => {
3703
- requestRef.current = null;
3704
- setPendingReport(null);
3705
- setActiveRequest(null);
3706
- }, []);
3707
- const finishSubmission = (result) => {
3708
- if (result.pendingScreenshots.length > 0) {
3709
- setPendingReport(result);
3710
- options.onError(options.partialUploadMessage);
3711
- return;
3712
- }
3713
- setPendingReport(null);
3714
- options.onSubmitted(result.feedbackId);
3715
- };
3716
- const submit = async (draft) => {
3717
- if (requestRef.current) return;
3718
- if (!options.client) {
3719
- options.onError("Reflet is missing a publicKey. Pass one to RefletFeedback or RefletProvider.");
3720
- return;
3721
- }
3722
- const request = {};
3723
- requestRef.current = request;
3724
- setActiveRequest(request);
3725
- options.onError(null);
3726
- try {
3727
- const transport = createFeedbackTransport(options.client);
3728
- const result = pendingReport ? await attachPreparedScreenshots(transport, pendingReport) : await submitWidgetFeedback(transport, draft);
3729
- if (requestRef.current === request) finishSubmission(result);
3730
- } catch (error) {
3731
- if (requestRef.current === request) options.onError(error instanceof Error ? error.message : "Something went wrong. Please try again.");
3732
- } finally {
3733
- if (requestRef.current === request) {
3734
- requestRef.current = null;
3735
- setActiveRequest(null);
3736
- }
3737
- }
3738
- };
3739
- return {
3740
- hasPendingAttachments: pendingReport !== null,
3741
- isEditingDisabled: activeRequest !== null || pendingReport !== null,
3742
- isSubmitting: activeRequest !== null,
3743
- resetSubmission,
3744
- submit
3745
- };
2185
+ .picker-note textarea:focus { outline: none; }
2186
+ .picker-note textarea::placeholder { color: var(--rf-text-muted); }
2187
+ .picker-note-actions { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
2188
+ @media (pointer: coarse) {
2189
+ .picker-hint { padding: 6px 8px 6px 14px; }
2190
+ .picker-hint kbd { display: none; }
2191
+ .picker-cancel { height: 44px; padding: 0 18px; font-size: 14px; }
2192
+ .picker-note textarea { font-size: 16px; min-height: 56px; }
3746
2193
  }
2194
+
2195
+ `;
3747
2196
  //#endregion
3748
- //#region src/feedback/ui/state/use-screenshot-drafts.ts
3749
- function releaseDraft(draft) {
3750
- releaseCapture(draft?.image);
3751
- releaseCapture(draft?.closeUp);
3752
- }
3753
- function useScreenshotDrafts(captureFailed, onError) {
3754
- const [screenshots, setScreenshots] = useState([]);
3755
- const [activeId, setActiveId] = useState(null);
3756
- const [pendingCapture, setPendingCapture] = useState(null);
3757
- const ownedDrafts = useRef(/* @__PURE__ */ new Map());
3758
- const captureRequest = useRef(null);
3759
- useEffect(() => {
3760
- const drafts = ownedDrafts.current;
3761
- return () => {
3762
- captureRequest.current = null;
3763
- for (const draft of drafts.values()) releaseDraft(draft);
3764
- drafts.clear();
3765
- };
3766
- }, []);
3767
- const storeScreenshot = useCallback((screenshot) => {
3768
- releaseDraft(ownedDrafts.current.get(screenshot.id));
3769
- ownedDrafts.current.set(screenshot.id, screenshot);
3770
- setScreenshots((previous) => {
3771
- return previous.some(({ id }) => id === screenshot.id) ? previous.map((item) => item.id === screenshot.id ? screenshot : item) : [...previous, screenshot];
3772
- });
3773
- setActiveId(screenshot.id);
3774
- }, []);
3775
- const cancelPendingCapture = useCallback(() => {
3776
- captureRequest.current = null;
3777
- setPendingCapture(null);
3778
- }, []);
3779
- const takeScreenshot = useCallback(async (request) => {
3780
- captureRequest.current = request;
3781
- setPendingCapture(request);
3782
- onError(null);
3783
- const context = collectPageContext({ sdkVersion: SDK_VERSION });
3784
- const image = await captureViewport().catch(() => null);
3785
- if (captureRequest.current !== request) {
3786
- releaseCapture(image);
3787
- return;
3788
- }
3789
- cancelPendingCapture();
3790
- if (!image) {
3791
- onError(captureFailed);
3792
- return;
3793
- }
3794
- storeScreenshot({
3795
- ...request,
3796
- annotations: [],
3797
- context,
3798
- image
3799
- });
3800
- }, [
3801
- cancelPendingCapture,
3802
- captureFailed,
3803
- onError,
3804
- storeScreenshot
3805
- ]);
3806
- const takeCapture = useCallback(() => {
3807
- if (!captureRequest.current) takeScreenshot({
3808
- id: crypto.randomUUID(),
3809
- source: "manual"
3810
- });
3811
- }, [takeScreenshot]);
3812
- const refreshAutomatic = useCallback(() => {
3813
- takeScreenshot({
3814
- id: ownedDrafts.current.keys().next().value ?? captureRequest.current?.id ?? crypto.randomUUID(),
3815
- source: "automatic"
3816
- });
3817
- }, [takeScreenshot]);
3818
- const retakeCapture = useCallback((id) => {
3819
- if (!captureRequest.current && ownedDrafts.current.has(id)) takeScreenshot({
3820
- id,
3821
- source: "manual"
3822
- });
3823
- }, [takeScreenshot]);
3824
- const removeCapture = useCallback((id) => {
3825
- if (captureRequest.current?.id === id) cancelPendingCapture();
3826
- releaseDraft(ownedDrafts.current.get(id));
3827
- ownedDrafts.current.delete(id);
3828
- setScreenshots((previous) => previous.filter((item) => item.id !== id));
3829
- setActiveId((current) => current === id ? null : current);
3830
- }, [cancelPendingCapture]);
3831
- const resetScreenshots = useCallback(() => {
3832
- cancelPendingCapture();
3833
- for (const draft of ownedDrafts.current.values()) releaseDraft(draft);
3834
- ownedDrafts.current.clear();
3835
- setScreenshots([]);
3836
- setActiveId(null);
3837
- }, [cancelPendingCapture]);
3838
- const setAnnotations = useCallback((annotations) => {
3839
- setScreenshots((previous) => previous.map((item) => item.id === activeId ? {
3840
- ...item,
3841
- annotations
3842
- } : item));
3843
- }, [activeId]);
3844
- const setSelectionComment = useCallback((id, comment) => {
3845
- setScreenshots((previous) => previous.map((item) => item.id === id && item.selection ? {
3846
- ...item,
3847
- selection: {
3848
- ...item.selection,
3849
- comment
3850
- }
3851
- } : item));
3852
- }, []);
3853
- return {
3854
- activeScreenshot: screenshots.find(({ id }) => id === activeId) ?? screenshots.at(-1) ?? null,
3855
- cancelPendingCapture,
3856
- pendingCapture,
3857
- refreshAutomatic,
3858
- removeCapture,
3859
- resetScreenshots,
3860
- retakeCapture,
3861
- screenshots,
3862
- selectScreenshot: setActiveId,
3863
- setAnnotations,
3864
- setSelectionComment,
3865
- storeScreenshot,
3866
- takeCapture
3867
- };
2197
+ //#region src/feedback/ui/styles.ts
2198
+ const WIDGET_STYLES = `
2199
+ :host {
2200
+ --rf-offset: 20px;
2201
+ --rf-bg: #ffffff;
2202
+ --rf-bg-subtle: rgb(55 53 47 / 4%);
2203
+ --rf-bg-hover: rgb(55 53 47 / 7%);
2204
+ --rf-border: rgb(55 53 47 / 9%);
2205
+ --rf-hairline: rgb(15 15 15 / 10%);
2206
+ --rf-text: #37352f;
2207
+ --rf-text-muted: rgb(55 53 47 / 68%);
2208
+ --rf-primary: #37352f;
2209
+ --rf-primary-text: #ffffff;
2210
+ --rf-accent: #2383e2;
2211
+ --rf-accent-text: #ffffff;
2212
+ --rf-danger: #d44c47;
2213
+ --rf-success: #448361;
2214
+ --rf-swatch-base: #ffffff;
2215
+ --rf-spectrum: conic-gradient(#ff5757, #ffde59, #6bdb78, #4ac9fa, #8b5cf6, #ef73dc, #ff5757);
2216
+ --rf-radius: 20px;
2217
+ --rf-pill: 999px;
2218
+ --rf-attachment-height: 78px;
2219
+ --rf-width: 360px;
2220
+ --rf-glass: rgb(255 255 255 / 72%);
2221
+ --rf-glass-highlight: rgb(255 255 255 / 90%);
2222
+ --rf-glass-stroke: rgb(255 255 255 / 70%);
2223
+ --rf-radius-sm: 8px;
2224
+ --rf-shadow-inset: inset 0 0 0 1px var(--rf-border);
2225
+ --rf-ring: 0 0 0 1px rgb(15 15 15 / 4%);
2226
+ --rf-shadow-low: 0 1px 2px rgb(15 15 15 / 6%), 0 4px 10px -4px rgb(15 15 15 / 10%);
2227
+ --rf-shadow: 0 2px 5px -1px rgb(15 15 15 / 7%), 0 12px 32px -8px rgb(15 15 15 / 16%);
2228
+ --rf-shadow-float: 0 4px 12px -3px rgb(15 15 15 / 35%);
2229
+ --rf-ease: cubic-bezier(0.16, 1, 0.3, 1);
2230
+ color-scheme: light;
2231
+ all: initial;
2232
+ font-family: var(--rf-font, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif);
3868
2233
  }
3869
- //#endregion
3870
- //#region src/feedback/ui/state/use-widget-config.ts
3871
- function dismissalDuration(days) {
3872
- return days !== void 0 && Number.isFinite(days) && days > 0 ? days : null;
2234
+
2235
+ :host([data-theme="dark"]) {
2236
+ --rf-bg: #202020;
2237
+ --rf-bg-subtle: rgb(255 255 255 / 5%);
2238
+ --rf-bg-hover: rgb(255 255 255 / 9%);
2239
+ --rf-border: rgb(255 255 255 / 9%);
2240
+ --rf-hairline: rgb(255 255 255 / 12%);
2241
+ --rf-text: #ebeae8;
2242
+ --rf-text-muted: rgb(255 255 255 / 68%);
2243
+ --rf-glass: rgb(32 32 32 / 78%);
2244
+ --rf-glass-highlight: rgb(255 255 255 / 14%);
2245
+ --rf-glass-stroke: rgb(255 255 255 / 12%);
2246
+ --rf-primary: #f1efec;
2247
+ --rf-primary-text: #1c1c1b;
2248
+ --rf-accent: #529cca;
2249
+ --rf-danger: #eb5757;
2250
+ --rf-success: #4dab74;
2251
+ --rf-ring: 0 0 0 1px rgb(255 255 255 / 7%);
2252
+ --rf-shadow-low: 0 1px 2px rgb(0 0 0 / 30%), 0 4px 10px -4px rgb(0 0 0 / 40%);
2253
+ --rf-shadow: 0 2px 5px -1px rgb(0 0 0 / 35%), 0 14px 36px -10px rgb(0 0 0 / 55%);
2254
+ --rf-shadow-float: 0 4px 14px -3px rgb(0 0 0 / 55%);
2255
+ color-scheme: dark;
3873
2256
  }
3874
- function useWidgetConfig(props) {
3875
- const context = useContext(RefletContext);
3876
- const publicKey = props.publicKey ?? context?.publicKey;
3877
- const baseUrl = props.baseUrl ?? context?.baseUrl;
3878
- const user = props.user ?? context?.user;
3879
- const userToken = props.userToken ?? context?.userToken;
3880
- return {
3881
- client: useMemo(() => publicKey ? new Reflet({
3882
- baseUrl,
3883
- publicKey,
3884
- user,
3885
- userToken
3886
- }) : null, [
3887
- baseUrl,
3888
- publicKey,
3889
- user,
3890
- userToken
3891
- ]),
3892
- dismissalKey: `reflet-feedback-dismissed:${publicKey ?? "default"}:${user?.id ?? "anonymous"}`,
3893
- dismissForDays: dismissalDuration(props.dismissForDays),
3894
- isAnonymous: !(user || userToken)
3895
- };
2257
+
2258
+ *, *::before, *::after { box-sizing: border-box; }
2259
+
2260
+
2261
+ .root {
2262
+ position: fixed;
2263
+ z-index: ${REFLET_Z_INDEX.widgetRoot};
2264
+ display: flex;
2265
+ align-items: flex-end;
2266
+ gap: 8px;
2267
+ font: 13px/1.45 var(--rf-font, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif);
2268
+ color: var(--rf-text);
3896
2269
  }
3897
- //#endregion
3898
- //#region src/feedback/ui/use-capture-sync.ts
3899
- const RESIZE_DEBOUNCE = 400;
3900
- /**
3901
- * Calls `refresh` whenever the page the reporter is looking at changes shape:
3902
- * SPA routes (which never fire popstate on push/replace — history methods must
3903
- * be patched) and window resizes, debounced. Keeps the frozen screenshot and
3904
- * page context describing what the reporter currently sees.
3905
- */
3906
- function useCaptureSync(enabled, refresh) {
3907
- useEffect(() => {
3908
- if (!enabled) return;
3909
- const history = window.history;
3910
- const originalPush = history.pushState;
3911
- const originalReplace = history.replaceState;
3912
- const hadOwnPush = Object.hasOwn(history, "pushState");
3913
- const hadOwnReplace = Object.hasOwn(history, "replaceState");
3914
- history.pushState = function pushState(data, unused, url) {
3915
- const result = originalPush.call(history, data, unused, url);
3916
- refresh();
3917
- return result;
3918
- };
3919
- history.replaceState = function replaceState(data, unused, url) {
3920
- const result = originalReplace.call(history, data, unused, url);
3921
- refresh();
3922
- return result;
3923
- };
3924
- window.addEventListener("popstate", refresh);
3925
- let resizeTimer;
3926
- const onResize = () => {
3927
- window.clearTimeout(resizeTimer);
3928
- resizeTimer = window.setTimeout(refresh, RESIZE_DEBOUNCE);
3929
- };
3930
- window.addEventListener("resize", onResize);
3931
- return () => {
3932
- if (hadOwnPush) history.pushState = originalPush;
3933
- else Reflect.deleteProperty(history, "pushState");
3934
- if (hadOwnReplace) history.replaceState = originalReplace;
3935
- else Reflect.deleteProperty(history, "replaceState");
3936
- window.removeEventListener("popstate", refresh);
3937
- window.clearTimeout(resizeTimer);
3938
- window.removeEventListener("resize", onResize);
3939
- };
3940
- }, [enabled, refresh]);
2270
+ .root:has(.panel) { width: min(var(--rf-width), calc(var(--rf-viewport-width, 100vw) - 24px)); }
2271
+ .root[data-position$="right"] { right: calc(var(--rf-viewport-right, 0px) + max(12px, min(var(--rf-offset), calc((var(--rf-viewport-width, 100vw) - var(--rf-width)) / 2)))); }
2272
+ .root[data-position$="left"] { left: calc(var(--rf-viewport-left, 0px) + max(12px, min(var(--rf-offset), calc((var(--rf-viewport-width, 100vw) - var(--rf-width)) / 2)))); }
2273
+ .root[data-position^="bottom"] { bottom: calc(var(--rf-viewport-bottom, 0px) + max(var(--rf-offset), env(safe-area-inset-bottom))); }
2274
+ .root[data-position^="top"] { top: calc(var(--rf-viewport-top, 0px) + max(var(--rf-offset), env(safe-area-inset-top))); align-items: flex-start; }
2275
+ .root[data-position="bottom-right"] .panel { transform-origin: bottom right; }
2276
+ .root[data-position="bottom-left"] .panel { transform-origin: bottom left; }
2277
+ .root[data-position^="top"] .panel { transform-origin: top center; }
2278
+ .root[data-editing="true"] { visibility: hidden; pointer-events: none; }
2279
+ button, select, input, textarea { font: inherit; color: inherit; }
2280
+ button { margin: 0; cursor: pointer; border: 0; background: none; transition: scale 120ms var(--rf-ease); }
2281
+ button:disabled { cursor: not-allowed; opacity: .4; }
2282
+ button:active:not(:disabled) { scale: .97; }
2283
+ svg { flex-shrink: 0; }
2284
+ :focus-visible { outline: 2px solid var(--rf-accent); outline-offset: 3px; }
2285
+ .icon-btn, .tool {
2286
+ display: inline-flex; align-items: center; justify-content: center;
2287
+ width: 40px; height: 40px; flex: none; border-radius: var(--rf-pill); color: var(--rf-text-muted);
2288
+ transition: background 160ms var(--rf-ease), color 160ms var(--rf-ease), scale 120ms var(--rf-ease);
3941
2289
  }
3942
- //#endregion
3943
- //#region src/feedback/ui/use-widget-state.ts
3944
- const SUCCESS_CLOSE_DELAY = 2400;
3945
- const IS_APPLE = /Mac|iPhone|iPad/;
3946
- const MILLISECONDS_PER_DAY = 864e5;
3947
- function readDismissedUntil(key) {
3948
- if (typeof window === "undefined") return 0;
3949
- try {
3950
- const value = Number.parseInt(window.localStorage.getItem(key) ?? "", 10);
3951
- if (Number.isFinite(value) && value > Date.now()) return value;
3952
- window.localStorage.removeItem(key);
3953
- } catch {
3954
- return 0;
3955
- }
3956
- return 0;
2290
+ .icon-btn:hover, .tool:hover { background: var(--rf-bg-hover); color: var(--rf-text); }
2291
+ .launcher {
2292
+ display: inline-flex; align-items: center; gap: 7px; height: 40px; padding: 0 15px;
2293
+ border-radius: var(--rf-pill); color: var(--rf-text);
2294
+ font-weight: 500;
2295
+ transition: transform 200ms var(--rf-ease), box-shadow 200ms var(--rf-ease), scale 120ms var(--rf-ease);
3957
2296
  }
3958
- function writeDismissedUntil(key, until) {
3959
- try {
3960
- window.localStorage.setItem(key, String(until));
3961
- } catch {}
2297
+ .launcher:hover { transform: translateY(-2px); box-shadow: var(--rf-shadow); }
2298
+ .launcher-icon { display: flex; width: 15px; height: 15px; }
2299
+ .launcher-icon .on-close { display: none; }
2300
+ .floating-controls {
2301
+ display: flex; align-items: center; flex: none;
2302
+ margin-right: auto; padding-left: 3px;
2303
+ animation: rf-in 220ms var(--rf-ease);
3962
2304
  }
3963
- function matchesHotkey(event, hotkey, platform = typeof navigator === "undefined" ? "" : navigator.platform) {
3964
- const parts = hotkey.toLowerCase().split("+").map((part) => part.trim());
3965
- const key = parts.at(-1);
3966
- if (!key || event.key.toLowerCase() !== key) return false;
3967
- const isApple = IS_APPLE.test(platform);
3968
- const wantsMod = parts.includes("mod");
3969
- const expected = {
3970
- alt: parts.includes("alt") || parts.includes("option"),
3971
- ctrl: parts.includes("ctrl") || wantsMod && !isApple,
3972
- meta: parts.includes("meta") || parts.includes("cmd") || wantsMod && isApple,
3973
- shift: parts.includes("shift")
3974
- };
3975
- return event.altKey === expected.alt && event.ctrlKey === expected.ctrl && event.metaKey === expected.meta && event.shiftKey === expected.shift;
2305
+ .drag-handle { touch-action: none; cursor: grab; }
2306
+ .drag-handle:active { cursor: grabbing; }
2307
+ .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2308
+ .ghost-btn { display: flex; align-items: center; gap: 8px; min-height: 40px; padding: 0 8px; border-radius: var(--rf-radius-sm); }
2309
+ .ghost-btn:hover { background: var(--rf-bg-hover); }
2310
+ .error { margin: 0; padding: 10px 14px; border-radius: var(--rf-radius); font-size: 12px; color: var(--rf-danger); }
2311
+ .done { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 24px; text-align: center; }
2312
+ .done svg { color: var(--rf-success); }
2313
+ .done h2 { margin: 0; font-size: 14px; font-weight: 600; }
2314
+ .done p { margin: 0; font-size: 12px; color: var(--rf-text-muted); }
2315
+ .spinner { width: 14px; height: 14px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: rf-spin 700ms linear infinite; }
2316
+ .hp { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
2317
+ @keyframes rf-spin { to { transform: rotate(360deg); } }
2318
+ @keyframes rf-in { from { opacity: 0; transform: translateY(5px) scale(.98); } }
2319
+ @keyframes rf-fade { from { opacity: 0; } }
2320
+ @media (pointer: coarse) {
2321
+ .icon-btn, .tool { width: 44px; height: 44px; }
2322
+ .launcher, .ghost-btn { min-height: 44px; }
2323
+ }
2324
+ .glass {
2325
+ position: relative;
2326
+ isolation: isolate;
2327
+ border: 1px solid var(--rf-glass-stroke);
2328
+ box-shadow: inset 0 1px 0 var(--rf-glass-highlight), var(--rf-shadow);
2329
+ }
2330
+ .glass::before {
2331
+ content: "";
2332
+ position: absolute;
2333
+ inset: 0;
2334
+ z-index: -1;
2335
+ border-radius: inherit;
2336
+ background: var(--rf-bg);
2337
+ pointer-events: none;
2338
+ }
2339
+ @supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) {
2340
+ .glass::before {
2341
+ background: var(--rf-glass);
2342
+ -webkit-backdrop-filter: blur(20px) saturate(1.6);
2343
+ backdrop-filter: blur(20px) saturate(1.6);
2344
+ }
2345
+ }
2346
+ @media (prefers-reduced-transparency: reduce), (prefers-contrast: more) {
2347
+ .glass::before { background: var(--rf-bg); backdrop-filter: none; -webkit-backdrop-filter: none; }
2348
+ }
2349
+ .capture-halo { position: fixed; inset: 0; z-index: ${REFLET_Z_INDEX.captureHalo}; pointer-events: none; opacity: 0; transition: opacity 500ms var(--rf-ease); }
2350
+ .capture-halo[data-active="true"] { opacity: 1; transition-duration: 140ms; }
2351
+ .capture-halo::before { content: ""; position: absolute; inset: 0; border-radius: 20px; box-shadow: inset 14px 0 36px -16px var(--rf-accent), inset -14px 0 36px -16px var(--rf-accent), inset 0 0 4px 1px color-mix(in srgb, var(--rf-accent) 35%, transparent); }
2352
+ .capture-halo::after { content: ""; position: absolute; inset: 0; background: linear-gradient(90deg, color-mix(in srgb, var(--rf-accent) 20%, transparent), transparent 5%, transparent 95%, color-mix(in srgb, var(--rf-accent) 20%, transparent)); }
2353
+ ${COMPOSER_STYLES}
2354
+ ${ANNOTATOR_STYLES}
2355
+ ${PICKER_STYLES}
2356
+ @media (prefers-reduced-motion: reduce) {
2357
+ *, *::before, *::after { animation: none !important; transition: none !important; }
2358
+ button:active:not(:disabled) { scale: 1; }
2359
+ }
2360
+ `;
2361
+ //#endregion
2362
+ //#region src/feedback/ui/shadow-portal.tsx
2363
+ const DARK_QUERY = "(prefers-color-scheme: dark)";
2364
+ function useResolvedTheme(theme) {
2365
+ const [systemDark, setSystemDark] = useState(false);
2366
+ useEffect(() => {
2367
+ if (theme !== "auto" || typeof window.matchMedia !== "function") return;
2368
+ const query = window.matchMedia(DARK_QUERY);
2369
+ setSystemDark(query.matches);
2370
+ const onChange = (event) => setSystemDark(event.matches);
2371
+ query.addEventListener("change", onChange);
2372
+ return () => query.removeEventListener("change", onChange);
2373
+ }, [theme]);
2374
+ if (theme === "auto") return systemDark ? "dark" : "light";
2375
+ return theme;
3976
2376
  }
3977
- function useWidgetState(props) {
3978
- const { client, dismissalKey, dismissForDays, isAnonymous } = useWidgetConfig(props);
3979
- const [isOpen, setIsOpen] = useState(false);
3980
- const [annotationTrigger, setAnnotationTrigger] = useState(null);
3981
- const [step, setStep] = useState("compose");
3982
- const [message, setMessage] = useState("");
3983
- const [email, setEmail] = useState("");
3984
- const [honeypot, setHoneypot] = useState("");
3985
- const [isElementCapturing, setIsElementCapturing] = useState(false);
3986
- const successTimer = useRef(null);
3987
- const [error, setError] = useState(null);
3988
- const [dismissedUntil, setDismissedUntil] = useState(() => readDismissedUntil(dismissalKey));
3989
- const pickRef = useRef(0);
3990
- useEffect(() => () => {
3991
- pickRef.current++;
3992
- if (successTimer.current) clearTimeout(successTimer.current);
3993
- }, []);
3994
- const consoleRef = useRef(null);
2377
+ /**
2378
+ * Hosts the widget in a shadow root: the app's CSS cannot reach the panel and
2379
+ * the panel's CSS cannot reach the app. Direct DOM work is unavoidable here —
2380
+ * React has no declarative way to create a shadow root.
2381
+ */
2382
+ function ShadowPortal({ children, offset, primaryColor, theme }) {
2383
+ const [shadowRoot, setShadowRoot] = useState(null);
2384
+ const resolvedTheme = useResolvedTheme(theme);
3995
2385
  useEffect(() => {
3996
- setDismissedUntil(readDismissedUntil(dismissalKey));
3997
- }, [dismissalKey]);
2386
+ const host = document.createElement("div");
2387
+ host.setAttribute(WIDGET_MARKER, "");
2388
+ const root = host.attachShadow({ mode: "open" });
2389
+ const style = document.createElement("style");
2390
+ style.textContent = WIDGET_STYLES;
2391
+ root.append(style);
2392
+ document.body.append(host);
2393
+ setShadowRoot(root);
2394
+ return () => host.remove();
2395
+ }, []);
3998
2396
  useEffect(() => {
3999
- if (props.captureConsole === false) return;
4000
- const recorder = startConsoleRecorder();
4001
- consoleRef.current = recorder;
4002
- return () => {
4003
- consoleRef.current = null;
4004
- recorder.stop();
4005
- };
4006
- }, [props.captureConsole]);
4007
- const captureFailed = props.labels?.captureFailed ?? DEFAULT_WIDGET_LABELS.captureFailed;
4008
- const gallery = useScreenshotDrafts(captureFailed, setError);
4009
- const { activeScreenshot, cancelPendingCapture, refreshAutomatic, resetScreenshots, storeScreenshot } = gallery;
4010
- const capture = activeScreenshot?.image ?? null;
4011
- const annotations = activeScreenshot?.annotations ?? [];
4012
- const isCapturing = gallery.pendingCapture !== null || isElementCapturing;
4013
- const submission = useFeedbackSubmission({
4014
- client,
4015
- onError: setError,
4016
- onSubmitted: (feedbackId) => {
4017
- setStep("success");
4018
- props.onSubmit?.({ feedbackId });
4019
- successTimer.current = setTimeout(close, SUCCESS_CLOSE_DELAY);
4020
- },
4021
- partialUploadMessage: props.labels?.attachmentUploadFailed ?? DEFAULT_WIDGET_LABELS.attachmentUploadFailed
4022
- });
4023
- const { resetSubmission } = submission;
4024
- const firstCapture = gallery.pendingCapture ?? gallery.screenshots[0];
4025
- const canRefreshAutomatically = gallery.screenshots.length <= 1 && firstCapture?.source === "automatic";
4026
- useCaptureSync(isOpen && !submission.isEditingDisabled && step === "compose" && canRefreshAutomatically && annotations.length === 0 && !gallery.screenshots.some((draft) => draft.selection) && props.captureOnOpen !== false, refreshAutomatic);
4027
- const reset = useCallback(() => {
4028
- if (successTimer.current) clearTimeout(successTimer.current);
4029
- setAnnotationTrigger(null);
4030
- resetSubmission();
4031
- pickRef.current++;
4032
- setIsElementCapturing(false);
4033
- resetScreenshots();
4034
- setMessage("");
4035
- setEmail("");
4036
- setHoneypot("");
4037
- setError(null);
4038
- setStep("compose");
4039
- }, [resetSubmission, resetScreenshots]);
4040
- const close = useCallback(() => {
4041
- setIsOpen(false);
4042
- reset();
4043
- props.onClose?.();
4044
- }, [reset, props.onClose]);
4045
- const dismiss = useCallback(() => {
4046
- if (dismissForDays === null) return;
4047
- const until = Date.now() + dismissForDays * MILLISECONDS_PER_DAY;
4048
- writeDismissedUntil(dismissalKey, until);
4049
- setIsOpen(false);
4050
- reset();
4051
- setDismissedUntil(until);
4052
- props.onDismiss?.({ until });
4053
- }, [
4054
- dismissForDays,
4055
- dismissalKey,
4056
- props.onDismiss,
4057
- reset
4058
- ]);
4059
- const open = useCallback(() => {
4060
- setIsOpen(true);
4061
- setStep("compose");
4062
- props.onOpen?.();
4063
- if (props.captureOnOpen !== false) refreshAutomatic();
4064
- }, [
4065
- props.captureOnOpen,
4066
- props.onOpen,
4067
- refreshAutomatic
4068
- ]);
4069
- const selectElement = useCallback(async (element, note = "") => {
4070
- cancelPendingCapture();
4071
- setIsElementCapturing(true);
4072
- const picked = note ? {
4073
- ...buildElementSelection(element),
4074
- comment: note.slice(0, MAX_SELECTION_COMMENT_LENGTH)
4075
- } : buildElementSelection(element);
4076
- setStep("compose");
4077
- const pick = ++pickRef.current;
4078
- const context = collectPageContext({ sdkVersion: SDK_VERSION });
4079
- const [fresh, closeUp] = await Promise.all([captureViewport().catch(() => null), captureElementCloseUp(element).catch(() => null)]);
4080
- if (pickRef.current !== pick) {
4081
- releaseCapture(fresh);
4082
- releaseCapture(closeUp);
4083
- return;
4084
- }
4085
- setIsElementCapturing(false);
4086
- if (!fresh) {
4087
- releaseCapture(closeUp);
4088
- setError(captureFailed);
4089
- return;
4090
- }
4091
- storeScreenshot({
4092
- annotations: [highlightFor(picked, fresh)],
4093
- closeUp,
4094
- context,
4095
- id: crypto.randomUUID(),
4096
- image: fresh,
4097
- selectedNode: element,
4098
- selection: picked,
4099
- source: "manual"
4100
- });
2397
+ const host = shadowRoot?.host;
2398
+ if (!(host instanceof HTMLElement)) return;
2399
+ host.setAttribute("data-theme", resolvedTheme);
2400
+ host.style.setProperty("--rf-offset", `${offset}px`);
2401
+ if (primaryColor) host.style.setProperty("--rf-primary", primaryColor);
4101
2402
  }, [
4102
- cancelPendingCapture,
4103
- captureFailed,
4104
- storeScreenshot
2403
+ shadowRoot,
2404
+ resolvedTheme,
2405
+ primaryColor,
2406
+ offset
4105
2407
  ]);
4106
- const selections = gallery.screenshots.flatMap(({ selection }) => selection ? [selection] : []);
4107
- const firstElementNote = selections.find(({ comment }) => comment)?.comment;
4108
- const reportedMessage = message.trim() || firstElementNote || "";
4109
- const submit = () => {
4110
- if (honeypot) {
4111
- setStep("success");
4112
- return;
4113
- }
4114
- setStep("compose");
4115
- return submission.submit({
4116
- context: {
4117
- ...activeScreenshot?.context ?? collectPageContext({ sdkVersion: "0.4.2" }),
4118
- consoleEvents: consoleRef.current?.events(),
4119
- metadata: props.metadata,
4120
- selections
4121
- },
4122
- email,
4123
- isAnonymous,
4124
- message: reportedMessage,
4125
- screenshots: gallery.screenshots
4126
- });
4127
- };
4128
- const requestSubmit = () => {
4129
- if (isAnonymous && email.length === 0 && !submission.hasPendingAttachments && !honeypot) {
4130
- setStep("email");
4131
- return;
4132
- }
4133
- return submit();
4134
- };
4135
- return {
4136
- activeScreenshot,
4137
- annotateScreenshot: (id, trigger) => {
4138
- setAnnotationTrigger(trigger);
4139
- gallery.selectScreenshot(id);
4140
- setStep("annotate");
4141
- },
4142
- annotations,
4143
- annotationTrigger,
4144
- canDismiss: dismissForDays !== null,
4145
- canSubmit: reportedMessage.length > 0,
4146
- capture,
4147
- close,
4148
- dismiss,
4149
- dismissForDays,
4150
- email,
4151
- error,
4152
- hasPendingAttachments: submission.hasPendingAttachments,
4153
- honeypot,
4154
- isAnonymous,
4155
- isCapturing,
4156
- isDismissed: dismissedUntil > Date.now(),
4157
- isEditingDisabled: submission.isEditingDisabled,
4158
- isOpen,
4159
- isSubmitting: submission.isSubmitting,
4160
- message,
4161
- open,
4162
- pendingCapture: gallery.pendingCapture,
4163
- removeCapture: gallery.removeCapture,
4164
- requestSubmit,
4165
- retakeCapture: gallery.retakeCapture,
4166
- screenshots: gallery.screenshots,
4167
- selectElement,
4168
- selections,
4169
- setAnnotations: gallery.setAnnotations,
4170
- setEmail,
4171
- setHoneypot,
4172
- setMessage,
4173
- setSelectionComment: gallery.setSelectionComment,
4174
- setStep,
4175
- step,
4176
- submit,
4177
- takeCapture: gallery.takeCapture
4178
- };
2408
+ if (!shadowRoot) return null;
2409
+ return createPortal(children, shadowRoot);
4179
2410
  }
4180
2411
  //#endregion
4181
2412
  //#region src/feedback/widget.tsx
2413
+ const DevtoolsLayer = process.env.NODE_ENV === "development" ? lazy(() => import("./devtools-layer-BYGuFmhg.js").then((module) => ({ default: module.DevtoolsLayer }))) : null;
4182
2414
  function RefletFeedback(props) {
4183
- const { enabled = true, hotkey = null, offset = 20, position = "bottom-right", theme = "auto" } = props;
2415
+ const { devtools = true, enabled = true, hotkey = null, offset = 20, position = "bottom-right", theme = "auto" } = props;
4184
2416
  const labels = {
4185
2417
  ...DEFAULT_WIDGET_LABELS,
4186
2418
  ...props.labels
4187
2419
  };
4188
2420
  const state = useWidgetState(props);
4189
- const { close, isOpen, open } = state;
4190
- useEffect(() => {
4191
- if (!(hotkey && enabled)) return;
4192
- const onKeyDown = (event) => {
4193
- if (matchesHotkey(event, hotkey)) {
4194
- event.preventDefault();
4195
- if (isOpen) close();
4196
- else open();
4197
- }
4198
- };
4199
- document.addEventListener("keydown", onKeyDown, true);
4200
- return () => document.removeEventListener("keydown", onKeyDown, true);
4201
- }, [
4202
- close,
4203
- enabled,
4204
- hotkey,
4205
- isOpen,
4206
- open
4207
- ]);
4208
- if (!enabled || state.isDismissed) return null;
2421
+ const { isOpen } = state;
2422
+ const showsWidget = enabled && !state.isDismissed;
2423
+ const showsDevtools = useOwnsDevtools(devtools && DevtoolsLayer !== null, showsWidget) && DevtoolsLayer !== null;
2424
+ if (!(showsWidget || showsDevtools)) return null;
4209
2425
  return /* @__PURE__ */ jsxs(ShadowPortal, {
4210
2426
  offset,
4211
2427
  primaryColor: props.primaryColor,
4212
2428
  theme,
4213
- children: [
2429
+ children: [showsWidget && /* @__PURE__ */ jsxs(Fragment, { children: [
4214
2430
  /* @__PURE__ */ jsx("div", {
4215
2431
  "aria-hidden": "true",
4216
2432
  className: "capture-halo",
4217
2433
  "data-active": state.isCapturing
4218
2434
  }),
4219
2435
  /* @__PURE__ */ jsx(FloatingWidget, {
2436
+ hotkey,
4220
2437
  labels,
4221
2438
  position,
4222
2439
  state
@@ -4246,7 +2463,13 @@ function RefletFeedback(props) {
4246
2463
  onCancel: () => state.setStep("compose"),
4247
2464
  onPick: state.selectElement
4248
2465
  })
4249
- ]
2466
+ ] }), showsDevtools && /* @__PURE__ */ jsx(Suspense, {
2467
+ fallback: null,
2468
+ children: /* @__PURE__ */ jsx(DevtoolsLayer, {
2469
+ isWidgetOpen: showsWidget && isOpen,
2470
+ position
2471
+ })
2472
+ })]
4250
2473
  });
4251
2474
  }
4252
2475
  //#endregion