reflet-sdk 0.2.0 → 0.3.1

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
@@ -17,7 +17,8 @@ function buildSnapdomOptions(options) {
17
17
  dpr: Math.min(Math.max(ratio || 1, 1), MAX_PIXEL_RATIO),
18
18
  embedFonts: true,
19
19
  exclude: [`[${WIDGET_MARKER}]`, ...options.excludeSelectors ?? []],
20
- fast: true
20
+ fast: true,
21
+ reconcile: true
21
22
  };
22
23
  }
23
24
  function fitWithin(size, maxWidth) {
@@ -48,6 +49,23 @@ function downscale(canvas, target) {
48
49
  context.drawImage(canvas, 0, 0, target.width, target.height);
49
50
  return resized;
50
51
  }
52
+ /** Wraps a rendered canvas as an owned capture, downscaled to `maxWidth`. */
53
+ async function capturedFromCanvas(source, maxWidth) {
54
+ const fitted = fitWithin({
55
+ height: source.height,
56
+ width: source.width
57
+ }, maxWidth);
58
+ const output = fitted.scale === 1 ? source : downscale(source, fitted);
59
+ const blob = await toBlob$1(output);
60
+ if (!blob) return null;
61
+ return {
62
+ blob,
63
+ height: output.height,
64
+ mimeType: "image/png",
65
+ objectUrl: URL.createObjectURL(blob),
66
+ width: output.width
67
+ };
68
+ }
51
69
  /**
52
70
  * Renders what the user is looking at right now.
53
71
  *
@@ -60,25 +78,11 @@ async function captureViewport(options = {}) {
60
78
  try {
61
79
  const { snapdom } = await import("@zumer/snapdom");
62
80
  const target = options.element ?? document.documentElement;
63
- const canvas = await snapdom.toCanvas(target, buildSnapdomOptions({
81
+ return await capturedFromCanvas(await snapdom.toCanvas(target, buildSnapdomOptions({
64
82
  devicePixelRatio: window.devicePixelRatio,
65
83
  element: options.element,
66
84
  excludeSelectors: options.excludeSelectors
67
- }));
68
- const fitted = fitWithin({
69
- height: canvas.height,
70
- width: canvas.width
71
- }, options.maxWidth ?? DEFAULT_MAX_WIDTH);
72
- const output = fitted.scale === 1 ? canvas : downscale(canvas, fitted);
73
- const blob = await toBlob$1(output);
74
- if (!blob) return null;
75
- return {
76
- blob,
77
- height: output.height,
78
- mimeType: "image/png",
79
- objectUrl: URL.createObjectURL(blob),
80
- width: output.width
81
- };
85
+ })), options.maxWidth ?? DEFAULT_MAX_WIDTH);
82
86
  } catch {
83
87
  return null;
84
88
  }
@@ -94,6 +98,9 @@ const FORWARD_REF_TYPE = Symbol.for("react.forward_ref");
94
98
  const MAX_STACK_DEPTH = 6;
95
99
  const MAX_FIBER_WALK = 60;
96
100
  const MAX_OWNER_WALK = 8;
101
+ const MIN_NAME_LENGTH = 4;
102
+ const CAPITALIZED = /^[A-Z]/;
103
+ const WORD_INSIDE = /[a-z]{2}/;
97
104
  const ORIGIN_PREFIX = /^[a-z][\w+.-]*:\/\/[^/]*/i;
98
105
  const WEBPACK_PREFIX = /^webpack-internal:\/{2,}(\.\/)?/;
99
106
  const STACK_LOCATION = /([^\s()]+):(\d+):(\d+)\)?\s*$/;
@@ -122,6 +129,14 @@ function readName(value) {
122
129
  const name = Reflect.get(value, "name");
123
130
  return typeof name === "string" && name ? name : null;
124
131
  }
132
+ /**
133
+ * Minified bundles hand out names like `Ur` or `Sn`. Reporting those as the
134
+ * owning component actively misleads an agent, so only real words get through.
135
+ */
136
+ function isMeaningfulName(name) {
137
+ if (name.includes("/") || name.includes("_")) return true;
138
+ return name.length >= MIN_NAME_LENGTH && CAPITALIZED.test(name) && WORD_INSIDE.test(name);
139
+ }
125
140
  function componentName(type, depth = 0) {
126
141
  if (typeof type === "string" || type === null || depth > 3) return null;
127
142
  if (isRecord(type)) {
@@ -130,8 +145,7 @@ function componentName(type, depth = 0) {
130
145
  if (marker === FORWARD_REF_TYPE) return componentName(Reflect.get(type, "render"), depth + 1);
131
146
  }
132
147
  const name = readName(type);
133
- if (!name || name.length < 2) return null;
134
- return name[0] === name[0]?.toUpperCase() || name.includes("/") ? name : null;
148
+ return name && isMeaningfulName(name) ? name : null;
135
149
  }
136
150
  /** Component names owning the fiber, innermost first. */
137
151
  function resolveComponentStack(fiber) {
@@ -205,14 +219,206 @@ function inspectReactElement(element) {
205
219
  };
206
220
  }
207
221
  //#endregion
222
+ //#region src/feedback/core/redact.ts
223
+ /**
224
+ * The picked element's markup leaves the reporter's browser and ends up in an
225
+ * agent prompt, so it is scrubbed here: typed-in values, credentials and
226
+ * anything marked `data-reflet-redact` never make the trip.
227
+ */
228
+ const EMAIL = /[\w.%+-]+@[\w.-]+\.[a-z]{2,}/gi;
229
+ const LONG_TOKEN = /\b[\w-]{28,}\b/g;
230
+ const OPAQUE_URI = /^(?:data|blob|javascript):/i;
231
+ const WHITESPACE_RUN = /\s+/g;
232
+ const URL_QUERY = /[?#]/;
233
+ const REDACTED = "[redacted]";
234
+ const ELLIPSIS = "…";
235
+ const MAX_ATTRIBUTE_LENGTH = 120;
236
+ const MAX_TEXT_LENGTH$1 = 200;
237
+ const MAX_CHILD_DEPTH = 3;
238
+ const MAX_CLONED_NODES = 400;
239
+ const DROPPED_TAGS = /* @__PURE__ */ new Set([
240
+ "script",
241
+ "style",
242
+ "noscript",
243
+ "template"
244
+ ]);
245
+ const VALUE_HOLDERS = /* @__PURE__ */ new Set([
246
+ "input",
247
+ "textarea",
248
+ "select",
249
+ "option"
250
+ ]);
251
+ const URL_ATTRIBUTES = /* @__PURE__ */ new Set([
252
+ "src",
253
+ "srcset",
254
+ "href",
255
+ "poster",
256
+ "action",
257
+ "formaction"
258
+ ]);
259
+ function truncate$1(value, max) {
260
+ return value.length <= max ? value : `${value.slice(0, max - 1)}${ELLIPSIS}`;
261
+ }
262
+ /** Emails and token-shaped strings, wherever they appear in reported text. */
263
+ function maskSecrets(value) {
264
+ return value.replace(EMAIL, REDACTED).replace(LONG_TOKEN, REDACTED);
265
+ }
266
+ const SENSITIVE_PARAM = /token|secret|pass(?:word|wd)?|pwd|auth|jwt|credential|session|sid|key|email|ssn|otp|code|signature|nonce/i;
267
+ const LEADING_QUESTION_MARK = /^\?/;
268
+ function decoded(value) {
269
+ try {
270
+ return decodeURIComponent(value);
271
+ } catch {
272
+ return value;
273
+ }
274
+ }
275
+ /**
276
+ * Query strings carry signed-in state (`?token=…`, `?userEmail=…`) straight
277
+ * into stored reports and agent prompts. Values of anything credential- or
278
+ * identity-shaped are masked; every other byte of the address stays intact.
279
+ */
280
+ function redactUrl(href) {
281
+ let url;
282
+ try {
283
+ url = new URL(href);
284
+ } catch {
285
+ return maskSecrets(href);
286
+ }
287
+ const pairs = url.search.replace(LEADING_QUESTION_MARK, "").split("&").filter(Boolean);
288
+ if (!pairs.some((pair) => SENSITIVE_PARAM.test(decoded(pair.split("=")[0] ?? "")))) return href;
289
+ const query = pairs.map((pair) => {
290
+ const equals = pair.indexOf("=");
291
+ if (equals === -1 || !SENSITIVE_PARAM.test(decoded(pair.slice(0, equals)))) return pair;
292
+ return `${pair.slice(0, equals)}=${REDACTED}`;
293
+ }).join("&");
294
+ return `${url.origin}${url.pathname}${query ? `?${query}` : ""}${url.hash}`;
295
+ }
296
+ function sanitizeUrl(value) {
297
+ if (OPAQUE_URI.test(value)) return `${value.slice(0, value.indexOf(":"))}:${ELLIPSIS}`;
298
+ return value.split(URL_QUERY)[0] ?? value;
299
+ }
300
+ function sanitizeAttributes(element) {
301
+ const holdsTypedValue = VALUE_HOLDERS.has(element.localName);
302
+ for (const { name, value } of Array.from(element.attributes)) {
303
+ if (holdsTypedValue && name === "value") {
304
+ element.removeAttribute(name);
305
+ continue;
306
+ }
307
+ const cleaned = URL_ATTRIBUTES.has(name) ? sanitizeUrl(value) : maskSecrets(value);
308
+ element.setAttribute(name, truncate$1(cleaned, MAX_ATTRIBUTE_LENGTH));
309
+ }
310
+ }
311
+ function collapse(element) {
312
+ element.replaceChildren(element.ownerDocument.createTextNode(ELLIPSIS));
313
+ }
314
+ function sanitizeTree(element, depth) {
315
+ sanitizeAttributes(element);
316
+ if (element.hasAttribute("data-reflet-redact")) {
317
+ element.replaceChildren(element.ownerDocument.createTextNode(REDACTED));
318
+ return;
319
+ }
320
+ if (element.localName === "svg") {
321
+ element.replaceChildren();
322
+ return;
323
+ }
324
+ for (const child of Array.from(element.childNodes)) {
325
+ if (child.nodeType === Node.COMMENT_NODE) {
326
+ child.remove();
327
+ continue;
328
+ }
329
+ if (child.nodeType === Node.TEXT_NODE) {
330
+ child.textContent = truncate$1(maskSecrets(child.textContent ?? ""), MAX_TEXT_LENGTH$1);
331
+ continue;
332
+ }
333
+ if (!(child instanceof Element)) continue;
334
+ if (DROPPED_TAGS.has(child.localName)) {
335
+ child.remove();
336
+ continue;
337
+ }
338
+ if (depth >= MAX_CHILD_DEPTH) {
339
+ sanitizeAttributes(child);
340
+ collapse(child);
341
+ continue;
342
+ }
343
+ sanitizeTree(child, depth + 1);
344
+ }
345
+ }
346
+ /** Redacted `outerHTML`, shallow-cloned when the subtree is too large to be useful. */
347
+ function sanitizeMarkup(element, maxLength) {
348
+ const isSmallEnough = element.querySelectorAll("*").length <= MAX_CLONED_NODES;
349
+ const clone = element.cloneNode(isSmallEnough);
350
+ if (!(clone instanceof Element)) return "";
351
+ sanitizeTree(clone, 0);
352
+ if (!isSmallEnough) collapse(clone);
353
+ return truncate$1(clone.outerHTML.replace(WHITESPACE_RUN, " "), maxLength);
354
+ }
355
+ //#endregion
208
356
  //#region src/feedback/core/element-selector.ts
209
357
  const SAFE_ID = /^[A-Za-z][\w-]*$/;
210
358
  const WHITESPACE = /\s+/g;
211
359
  const MAX_LABEL_LENGTH = 80;
212
360
  const MAX_HTML_LENGTH = 600;
361
+ const MAX_TEXT_LENGTH = 300;
362
+ const MAX_REGION_LENGTH = 160;
363
+ const MAX_HANDLE_VALUE_LENGTH = 80;
213
364
  const MAX_DEPTH = 12;
365
+ const MIN_PATH_SEGMENTS = 2;
214
366
  const MAX_SELECTOR_LENGTH = 600;
215
367
  const MAX_SOURCE_LOCATION_LENGTH = 400;
368
+ const TEST_ATTRIBUTES = [
369
+ "data-testid",
370
+ "data-test-id",
371
+ "data-test",
372
+ "data-cy",
373
+ "data-qa"
374
+ ];
375
+ const NAMING_ATTRIBUTES = ["name", "aria-label"];
376
+ const LANDMARK_SELECTOR = "dialog, [role=\"dialog\"], [role=\"alertdialog\"], main, nav, aside, header, footer, form, section, article";
377
+ const HEADING_SELECTOR = "h1, h2, h3, h4, h5, h6, [role=\"heading\"]";
378
+ /** Elements that start a new visual block — their text gets separated by a space. */
379
+ const BLOCK_TAGS = /* @__PURE__ */ new Set([
380
+ "address",
381
+ "article",
382
+ "aside",
383
+ "blockquote",
384
+ "dd",
385
+ "details",
386
+ "div",
387
+ "dl",
388
+ "dt",
389
+ "fieldset",
390
+ "figcaption",
391
+ "figure",
392
+ "footer",
393
+ "form",
394
+ "h1",
395
+ "h2",
396
+ "h3",
397
+ "h4",
398
+ "h5",
399
+ "h6",
400
+ "header",
401
+ "hr",
402
+ "li",
403
+ "main",
404
+ "nav",
405
+ "ol",
406
+ "p",
407
+ "pre",
408
+ "section",
409
+ "summary",
410
+ "table",
411
+ "td",
412
+ "th",
413
+ "tr",
414
+ "ul"
415
+ ]);
416
+ function visibleText(element) {
417
+ const clone = element.cloneNode(true);
418
+ if (!(clone instanceof Element)) return "";
419
+ for (const node of clone.querySelectorAll("*")) if (BLOCK_TAGS.has(node.localName)) node.before(clone.ownerDocument.createTextNode(" "));
420
+ return collapseText(maskSecrets(clone.textContent ?? ""));
421
+ }
216
422
  function isUnique(selector) {
217
423
  try {
218
424
  return document.querySelectorAll(selector).length === 1;
@@ -223,13 +429,23 @@ function isUnique(selector) {
223
429
  function escapeAttributeValue(value) {
224
430
  return value.replace(/["\\]/g, "\\$&");
225
431
  }
432
+ function attributeHandle(element, attribute, qualifier) {
433
+ const value = element.getAttribute(attribute);
434
+ if (!value || value.length > MAX_HANDLE_VALUE_LENGTH) return null;
435
+ const selector = `${qualifier}[${attribute}="${escapeAttributeValue(value)}"]`;
436
+ return isUnique(selector) ? selector : null;
437
+ }
438
+ /** Something an engineer would have written on purpose, unlike a positional path. */
226
439
  function uniqueHandle(element) {
227
440
  const id = element.getAttribute("id");
228
441
  if (id && SAFE_ID.test(id) && isUnique(`#${id}`)) return `#${id}`;
229
- const testId = element.getAttribute("data-testid");
230
- if (testId) {
231
- const selector = `[data-testid="${escapeAttributeValue(testId)}"]`;
232
- if (isUnique(selector)) return selector;
442
+ for (const attribute of TEST_ATTRIBUTES) {
443
+ const handle = attributeHandle(element, attribute, "");
444
+ if (handle) return handle;
445
+ }
446
+ for (const attribute of NAMING_ATTRIBUTES) {
447
+ const handle = attributeHandle(element, attribute, element.localName);
448
+ if (handle) return handle;
233
449
  }
234
450
  return null;
235
451
  }
@@ -243,6 +459,9 @@ function positionalSegment(element) {
243
459
  }
244
460
  /**
245
461
  * Shortest selector that still resolves back to `element` from the document.
462
+ * Grows the path one ancestor at a time and stops as soon as it is unique, so
463
+ * a stable handle anywhere up the tree replaces the whole positional chain.
464
+ * A bare tag is never enough: positional paths keep a parent for context.
246
465
  */
247
466
  function buildSelector(element) {
248
467
  if (element === document.documentElement) return "html";
@@ -251,43 +470,62 @@ function buildSelector(element) {
251
470
  if (handle) return handle;
252
471
  const segments = [];
253
472
  let current = element;
254
- let depth = 0;
255
- while (current && depth < MAX_DEPTH) {
256
- if (current === document.body) {
257
- segments.unshift("body");
258
- break;
259
- }
260
- if (current === document.documentElement) {
261
- segments.unshift("html");
262
- break;
263
- }
473
+ for (let depth = 0; current && depth < MAX_DEPTH; depth++) {
264
474
  const ancestorHandle = current === element ? null : uniqueHandle(current);
265
- if (ancestorHandle) {
266
- segments.unshift(ancestorHandle);
267
- break;
268
- }
269
- segments.unshift(positionalSegment(current));
475
+ segments.unshift(ancestorHandle ?? positionalSegment(current));
476
+ const candidate = segments.join(" > ");
477
+ const isAnchored = segments.length >= MIN_PATH_SEGMENTS;
478
+ if (ancestorHandle || current === document.body || isAnchored && isUnique(candidate)) return candidate;
270
479
  current = current.parentElement;
271
- depth++;
272
480
  }
273
481
  return segments.join(" > ");
274
482
  }
275
483
  function truncate(value, max) {
276
484
  return value.length <= max ? value : `${value.slice(0, max - 1)}…`;
277
485
  }
486
+ function collapseText(value) {
487
+ return value.replace(WHITESPACE, " ").trim();
488
+ }
278
489
  function accessibleLabel(element) {
279
490
  const explicit = element.getAttribute("aria-label") ?? element.getAttribute("title") ?? element.getAttribute("alt") ?? element.getAttribute("placeholder");
280
491
  if (explicit?.trim()) return explicit.trim();
281
- return (element.textContent ?? "").replace(WHITESPACE, " ").trim();
492
+ return collapseText(element.textContent ?? "");
282
493
  }
283
494
  /** Short human label such as `button "Sign in"` or `div.card`. */
284
495
  function describeElement(element) {
285
496
  const tag = element.localName;
286
- const label = accessibleLabel(element);
497
+ const label = maskSecrets(accessibleLabel(element));
287
498
  if (label) return truncate(`${tag} "${label}"`, MAX_LABEL_LENGTH);
288
499
  const firstClass = element.classList.item(0);
289
500
  return truncate(firstClass ? `${tag}.${firstClass}` : tag, MAX_LABEL_LENGTH);
290
501
  }
502
+ function describeLandmark(element) {
503
+ const landmark = element.parentElement?.closest(LANDMARK_SELECTOR);
504
+ if (!landmark) return null;
505
+ const name = landmark.getAttribute("aria-label") ?? landmark.getAttribute("title");
506
+ return name?.trim() ? `${landmark.localName} "${collapseText(name)}"` : landmark.localName;
507
+ }
508
+ function headingAbove(element) {
509
+ const scope = element.closest(LANDMARK_SELECTOR) ?? document.body;
510
+ const probe = document.createRange();
511
+ probe.selectNodeContents(element);
512
+ probe.collapse(true);
513
+ let closest = null;
514
+ for (const heading of scope.querySelectorAll(HEADING_SELECTOR)) if (probe.comparePoint(heading, 0) < 0) closest = heading;
515
+ return collapseText(closest?.textContent ?? "") || null;
516
+ }
517
+ /**
518
+ * Where the element sits from a reader's point of view — `dialog "Members" ›
519
+ * Danger zone` says more to an agent than any DOM path can.
520
+ */
521
+ function describeRegion(element) {
522
+ const landmark = describeLandmark(element);
523
+ const heading = headingAbove(element);
524
+ const parts = [landmark];
525
+ if (heading && !landmark?.includes(`"${heading}"`)) parts.push(heading);
526
+ const region = parts.filter((part) => part !== null).join(" › ");
527
+ return region ? truncate(maskSecrets(region), MAX_REGION_LENGTH) : void 0;
528
+ }
291
529
  function getElementRect(element) {
292
530
  const rect = element.getBoundingClientRect();
293
531
  return {
@@ -300,13 +538,16 @@ function getElementRect(element) {
300
538
  /** Everything an agent needs to find the picked element back in the codebase. */
301
539
  function buildElementSelection(element) {
302
540
  const { componentStack, sourceLocation } = inspectReactElement(element);
541
+ const text = truncate(visibleText(element), MAX_TEXT_LENGTH);
303
542
  return {
304
543
  componentStack,
305
- html: truncate(element.outerHTML, MAX_HTML_LENGTH),
544
+ html: sanitizeMarkup(element, MAX_HTML_LENGTH),
306
545
  label: describeElement(element),
307
546
  rect: getElementRect(element),
547
+ region: describeRegion(element),
308
548
  selector: truncate(buildSelector(element), MAX_SELECTOR_LENGTH),
309
- sourceLocation: sourceLocation ? truncate(sourceLocation, MAX_SOURCE_LOCATION_LENGTH) : sourceLocation
549
+ sourceLocation: sourceLocation ? truncate(sourceLocation, MAX_SOURCE_LOCATION_LENGTH) : sourceLocation,
550
+ text: text || void 0
310
551
  };
311
552
  }
312
553
  //#endregion
@@ -383,9 +624,13 @@ function collectPageContext(options) {
383
624
  height: window.screen.height,
384
625
  width: window.screen.width
385
626
  },
627
+ scroll: {
628
+ x: window.scrollX,
629
+ y: window.scrollY
630
+ },
386
631
  sdkVersion: options?.sdkVersion,
387
632
  timezone: resolveTimezone(),
388
- url: clip(window.location.href, MAX_URL_LENGTH),
633
+ url: redactUrl(window.location.href),
389
634
  userAgent: clip(userAgent, MAX_USER_AGENT_LENGTH),
390
635
  viewport: {
391
636
  devicePixelRatio: window.devicePixelRatio,
@@ -396,8 +641,8 @@ function collectPageContext(options) {
396
641
  }
397
642
  //#endregion
398
643
  //#region src/feedback/types.ts
399
- /** Reported with every submission. Keep in sync with package.json on release. */
400
- const SDK_VERSION = "0.2.0";
644
+ /** Reported with every submission. */
645
+ const SDK_VERSION = "0.3.1";
401
646
  const DEFAULT_WIDGET_LABELS = {
402
647
  annotateHint: "Draw on the screenshot to point at the problem.",
403
648
  attachScreenshot: "Attach a screenshot",
@@ -657,9 +902,10 @@ async function renderAnnotatedImage(source, annotations) {
657
902
  }
658
903
  //#endregion
659
904
  //#region src/feedback/ui/icons.tsx
660
- function Svg({ children, size = 16 }) {
905
+ function Svg({ children, className, size = 16 }) {
661
906
  return /* @__PURE__ */ jsx("svg", {
662
907
  "aria-hidden": "true",
908
+ className,
663
909
  fill: "none",
664
910
  height: size,
665
911
  stroke: "currentColor",
@@ -671,14 +917,18 @@ function Svg({ children, size = 16 }) {
671
917
  children
672
918
  });
673
919
  }
674
- function ChatIcon() {
920
+ function ChatIcon({ className }) {
675
921
  return /* @__PURE__ */ jsx(Svg, {
676
- size: 18,
922
+ className,
923
+ size: 15,
677
924
  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" })
678
925
  });
679
926
  }
680
- function CloseIcon() {
681
- return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M18 6 6 18M6 6l12 12" }) });
927
+ function CloseIcon({ className }) {
928
+ return /* @__PURE__ */ jsx(Svg, {
929
+ className,
930
+ children: /* @__PURE__ */ jsx("path", { d: "M18 6 6 18M6 6l12 12" })
931
+ });
682
932
  }
683
933
  function BugIcon() {
684
934
  return /* @__PURE__ */ jsx(Svg, {
@@ -941,15 +1191,21 @@ function Annotator({ annotations, capture, labels, onChange, onDone }) {
941
1191
  }
942
1192
  //#endregion
943
1193
  //#region src/feedback/ui/launcher.tsx
944
- function Launcher({ compact, isOpen, label, onClick }) {
1194
+ /**
1195
+ * Never changes size between states — the panel opening must not move the
1196
+ * button out from under the pointer.
1197
+ */
1198
+ function Launcher({ isOpen, label, onClick }) {
945
1199
  return /* @__PURE__ */ jsxs("button", {
946
1200
  "aria-expanded": isOpen,
947
- "aria-label": label,
948
1201
  className: "launcher",
949
- "data-compact": compact || isOpen ? "true" : "false",
1202
+ "data-open": isOpen,
950
1203
  onClick,
951
1204
  type: "button",
952
- children: [isOpen ? /* @__PURE__ */ jsx(CloseIcon, {}) : /* @__PURE__ */ jsx(ChatIcon, {}), !(compact || isOpen) && label]
1205
+ children: [/* @__PURE__ */ jsxs("span", {
1206
+ className: "launcher-icon",
1207
+ children: [/* @__PURE__ */ jsx(ChatIcon, { className: "on-open" }), /* @__PURE__ */ jsx(CloseIcon, { className: "on-close" })]
1208
+ }), label]
953
1209
  });
954
1210
  }
955
1211
  //#endregion
@@ -970,7 +1226,7 @@ const CATEGORY_META = {
970
1226
  };
971
1227
  function Attachment({ labels, state }) {
972
1228
  if (state.isCapturing) return /* @__PURE__ */ jsxs("div", {
973
- className: "attachment",
1229
+ className: "card",
974
1230
  children: [/* @__PURE__ */ jsx("div", { className: "thumb" }), /* @__PURE__ */ jsxs("div", {
975
1231
  className: "meta",
976
1232
  children: [/* @__PURE__ */ jsx("strong", { children: labels.retakeHint }), /* @__PURE__ */ jsx("span", { children: "…" })]
@@ -984,7 +1240,7 @@ function Attachment({ labels, state }) {
984
1240
  });
985
1241
  const annotationCount = state.annotations.length;
986
1242
  return /* @__PURE__ */ jsxs("div", {
987
- className: "attachment",
1243
+ className: "card",
988
1244
  children: [
989
1245
  /* @__PURE__ */ jsx("div", {
990
1246
  className: "thumb",
@@ -997,12 +1253,18 @@ function Attachment({ labels, state }) {
997
1253
  }),
998
1254
  /* @__PURE__ */ jsxs("div", {
999
1255
  className: "meta",
1000
- children: [/* @__PURE__ */ jsx("strong", { children: labels.retakeHint }), /* @__PURE__ */ jsxs("span", { children: [
1001
- state.capture.width,
1002
- "×",
1003
- state.capture.height,
1004
- annotationCount > 0 ? ` · ${annotationCount} marks` : ""
1005
- ] })]
1256
+ children: [/* @__PURE__ */ jsx("strong", {
1257
+ className: "truncate",
1258
+ children: labels.retakeHint
1259
+ }), /* @__PURE__ */ jsxs("span", {
1260
+ className: "truncate",
1261
+ children: [
1262
+ state.capture.width,
1263
+ "×",
1264
+ state.capture.height,
1265
+ annotationCount > 0 ? ` · ${annotationCount} marks` : ""
1266
+ ]
1267
+ })]
1006
1268
  }),
1007
1269
  /* @__PURE__ */ jsxs("div", {
1008
1270
  className: "actions",
@@ -1036,8 +1298,9 @@ function Attachment({ labels, state }) {
1036
1298
  ]
1037
1299
  });
1038
1300
  }
1039
- function SelectionChip({ labels, state }) {
1040
- const { selection } = state;
1301
+ /** Shows the reporter exactly what will be sent about the element they picked. */
1302
+ function Selection({ labels, state }) {
1303
+ const { elementCapture, selection } = state;
1041
1304
  if (!selection) return /* @__PURE__ */ jsxs("button", {
1042
1305
  className: "ghost-btn",
1043
1306
  onClick: () => state.setStep("picking"),
@@ -1045,24 +1308,45 @@ function SelectionChip({ labels, state }) {
1045
1308
  children: [/* @__PURE__ */ jsx(TargetIcon, {}), labels.pickElement]
1046
1309
  });
1047
1310
  const component = selection.componentStack[0];
1048
- return /* @__PURE__ */ jsxs("span", {
1049
- className: "chip",
1311
+ return /* @__PURE__ */ jsxs("div", {
1312
+ className: "card",
1050
1313
  children: [
1051
- /* @__PURE__ */ jsx(TargetIcon, {}),
1052
- /* @__PURE__ */ jsxs("span", {
1053
- className: "truncate",
1054
- children: [
1055
- component ? /* @__PURE__ */ jsx("strong", { children: `<${component}>` }) : selection.label,
1056
- " ",
1057
- /* @__PURE__ */ jsx("code", { children: selection.sourceLocation ?? selection.selector })
1058
- ]
1314
+ /* @__PURE__ */ jsx("div", {
1315
+ className: "thumb contain",
1316
+ children: elementCapture && /* @__PURE__ */ jsx("img", {
1317
+ alt: "",
1318
+ height: elementCapture.height,
1319
+ src: elementCapture.objectUrl,
1320
+ width: elementCapture.width
1321
+ })
1322
+ }),
1323
+ /* @__PURE__ */ jsxs("div", {
1324
+ className: "meta",
1325
+ children: [/* @__PURE__ */ jsx("strong", {
1326
+ className: "truncate",
1327
+ children: component ? `<${component}>` : selection.label
1328
+ }), /* @__PURE__ */ jsx("span", {
1329
+ className: "truncate",
1330
+ children: selection.region ?? selection.sourceLocation ?? selection.selector
1331
+ })]
1059
1332
  }),
1060
- /* @__PURE__ */ jsx("button", {
1061
- "aria-label": labels.cancel,
1062
- className: "icon-btn",
1063
- onClick: state.clearSelection,
1064
- type: "button",
1065
- children: /* @__PURE__ */ jsx(CloseIcon, {})
1333
+ /* @__PURE__ */ jsxs("div", {
1334
+ className: "actions",
1335
+ children: [/* @__PURE__ */ jsx("button", {
1336
+ "aria-label": labels.pickElement,
1337
+ className: "icon-btn",
1338
+ onClick: () => state.setStep("picking"),
1339
+ title: labels.pickElement,
1340
+ type: "button",
1341
+ children: /* @__PURE__ */ jsx(TargetIcon, {})
1342
+ }), /* @__PURE__ */ jsx("button", {
1343
+ "aria-label": labels.cancel,
1344
+ className: "icon-btn",
1345
+ onClick: state.clearSelection,
1346
+ title: labels.cancel,
1347
+ type: "button",
1348
+ children: /* @__PURE__ */ jsx(CloseIcon, {})
1349
+ })]
1066
1350
  })
1067
1351
  ]
1068
1352
  });
@@ -1129,13 +1413,15 @@ function FeedbackPanel({ categories, labels, state }) {
1129
1413
  ref: messageRef,
1130
1414
  value: state.message
1131
1415
  }),
1132
- /* @__PURE__ */ jsx(Attachment, {
1133
- labels,
1134
- state
1135
- }),
1136
- /* @__PURE__ */ jsx(SelectionChip, {
1137
- labels,
1138
- state
1416
+ /* @__PURE__ */ jsxs("div", {
1417
+ className: "attachments",
1418
+ children: [/* @__PURE__ */ jsx(Attachment, {
1419
+ labels,
1420
+ state
1421
+ }), /* @__PURE__ */ jsx(Selection, {
1422
+ labels,
1423
+ state
1424
+ })]
1139
1425
  }),
1140
1426
  state.isAnonymous && /* @__PURE__ */ jsx("input", {
1141
1427
  "aria-label": labels.emailLabel,
@@ -1192,7 +1478,7 @@ function FeedbackPanel({ categories, labels, state }) {
1192
1478
  }
1193
1479
  //#endregion
1194
1480
  //#region src/feedback/ui/picker.tsx
1195
- const LABEL_HEIGHT = 28;
1481
+ const LABEL_HEIGHT = 24;
1196
1482
  const LABEL_GAP = 6;
1197
1483
  function isWidgetOwned(element) {
1198
1484
  return element.closest(`[${WIDGET_MARKER}]`) !== null;
@@ -1205,14 +1491,15 @@ function elementUnder(x, y) {
1205
1491
  function describeTarget(element) {
1206
1492
  return {
1207
1493
  componentName: resolveComponentStack(getFiberFromNode(element))[0],
1494
+ element,
1208
1495
  label: describeElement(element),
1209
1496
  rect: element.getBoundingClientRect(),
1210
- selector: buildSelector(element)
1497
+ region: describeRegion(element)
1211
1498
  };
1212
1499
  }
1213
1500
  /**
1214
1501
  * Points at any element in the page the way a coding agent needs it: the React
1215
- * component that owns it plus a selector that resolves back to it.
1502
+ * component that owns it plus the page region it lives in.
1216
1503
  */
1217
1504
  function ElementPicker({ hint, onCancel, onPick }) {
1218
1505
  const [target, setTarget] = useState(null);
@@ -1223,7 +1510,14 @@ function ElementPicker({ hint, onCancel, onPick }) {
1223
1510
  };
1224
1511
  const onMove = (event) => {
1225
1512
  const element = elementUnder(event.clientX, event.clientY);
1226
- setTarget(element ? describeTarget(element) : null);
1513
+ if (!element) {
1514
+ setTarget(null);
1515
+ return;
1516
+ }
1517
+ setTarget((current) => current?.element === element ? {
1518
+ ...current,
1519
+ rect: element.getBoundingClientRect()
1520
+ } : describeTarget(element));
1227
1521
  };
1228
1522
  const onClick = (event) => {
1229
1523
  swallow(event);
@@ -1255,7 +1549,7 @@ function ElementPicker({ hint, onCancel, onPick }) {
1255
1549
  };
1256
1550
  }, [onCancel, onPick]);
1257
1551
  const rect = target?.rect;
1258
- const labelAbove = rect ? rect.top > 34 : true;
1552
+ const labelAbove = rect ? rect.top > 30 : true;
1259
1553
  return /* @__PURE__ */ jsxs("div", { children: [
1260
1554
  rect && /* @__PURE__ */ jsx("div", {
1261
1555
  className: "picker-box",
@@ -1272,7 +1566,7 @@ function ElementPicker({ hint, onCancel, onPick }) {
1272
1566
  left: Math.max(4, rect.left),
1273
1567
  top: labelAbove ? rect.top - LABEL_HEIGHT - LABEL_GAP : rect.bottom + LABEL_GAP
1274
1568
  },
1275
- children: [target.componentName ? /* @__PURE__ */ jsx("strong", { children: `<${target.componentName}>` }) : /* @__PURE__ */ jsx("strong", { children: target.label }), /* @__PURE__ */ jsx("code", { children: target.selector })]
1569
+ children: [/* @__PURE__ */ jsx("strong", { children: target.componentName ? `<${target.componentName}>` : target.label }), target.region && /* @__PURE__ */ jsx("span", { children: target.region })]
1276
1570
  }),
1277
1571
  /* @__PURE__ */ jsxs("div", {
1278
1572
  className: "picker-hint",
@@ -1293,30 +1587,42 @@ function ElementPicker({ hint, onCancel, onPick }) {
1293
1587
  const WIDGET_STYLES = `
1294
1588
  :host {
1295
1589
  --rf-bg: #ffffff;
1296
- --rf-bg-subtle: #f6f7f9;
1297
- --rf-border: #e4e6eb;
1298
- --rf-text: #16181d;
1299
- --rf-text-muted: #6b7280;
1300
- --rf-primary: #4f46e5;
1590
+ --rf-bg-subtle: rgb(55 53 47 / 4%);
1591
+ --rf-bg-hover: rgb(55 53 47 / 7%);
1592
+ --rf-border: rgb(55 53 47 / 9%);
1593
+ --rf-text: #37352f;
1594
+ --rf-text-muted: rgb(55 53 47 / 55%);
1595
+ --rf-primary: #37352f;
1301
1596
  --rf-primary-text: #ffffff;
1302
- --rf-danger: #dc2626;
1303
- --rf-success: #16a34a;
1304
- --rf-radius: 14px;
1305
- --rf-shadow: 0 1px 2px rgb(16 24 40 / 6%), 0 12px 32px -8px rgb(16 24 40 / 18%);
1306
- --rf-ring: 0 0 0 3px color-mix(in srgb, var(--rf-primary) 28%, transparent);
1307
- --rf-ease: cubic-bezier(0.32, 0.72, 0, 1);
1597
+ --rf-accent: #2383e2;
1598
+ --rf-danger: #d44c47;
1599
+ --rf-success: #448361;
1600
+ --rf-radius: 12px;
1601
+ --rf-radius-sm: 8px;
1602
+ --rf-shadow-inset: inset 0 0 0 1px var(--rf-border);
1603
+ --rf-shadow-low: 0 0 0 1px rgb(15 15 15 / 4%), 0 1px 2px rgb(15 15 15 / 6%), 0 4px 10px -4px rgb(15 15 15 / 10%);
1604
+ --rf-shadow: 0 0 0 1px rgb(15 15 15 / 4%), 0 2px 5px -1px rgb(15 15 15 / 7%), 0 12px 32px -8px rgb(15 15 15 / 16%);
1605
+ --rf-ring: 0 0 0 3px color-mix(in srgb, var(--rf-accent) 20%, transparent);
1606
+ --rf-ease: cubic-bezier(0.16, 1, 0.3, 1);
1308
1607
  color-scheme: light;
1309
1608
  all: initial;
1310
1609
  font-family: var(--rf-font, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif);
1311
1610
  }
1312
1611
 
1313
1612
  :host([data-theme="dark"]) {
1314
- --rf-bg: #17181c;
1315
- --rf-bg-subtle: #212228;
1316
- --rf-border: #2e3038;
1317
- --rf-text: #f3f4f6;
1318
- --rf-text-muted: #9aa1ad;
1319
- --rf-shadow: 0 1px 2px rgb(0 0 0 / 40%), 0 16px 40px -12px rgb(0 0 0 / 60%);
1613
+ --rf-bg: #202020;
1614
+ --rf-bg-subtle: rgb(255 255 255 / 5%);
1615
+ --rf-bg-hover: rgb(255 255 255 / 9%);
1616
+ --rf-border: rgb(255 255 255 / 9%);
1617
+ --rf-text: #ebeae8;
1618
+ --rf-text-muted: rgb(255 255 255 / 46%);
1619
+ --rf-primary: #f1efec;
1620
+ --rf-primary-text: #1c1c1b;
1621
+ --rf-accent: #529cca;
1622
+ --rf-danger: #eb5757;
1623
+ --rf-success: #4dab74;
1624
+ --rf-shadow-low: 0 0 0 1px rgb(255 255 255 / 6%), 0 1px 2px rgb(0 0 0 / 30%), 0 4px 10px -4px rgb(0 0 0 / 40%);
1625
+ --rf-shadow: 0 0 0 1px rgb(255 255 255 / 7%), 0 2px 5px -1px rgb(0 0 0 / 35%), 0 14px 36px -10px rgb(0 0 0 / 55%);
1320
1626
  color-scheme: dark;
1321
1627
  }
1322
1628
 
@@ -1325,19 +1631,19 @@ const WIDGET_STYLES = `
1325
1631
  .root {
1326
1632
  position: fixed;
1327
1633
  z-index: 2147483000;
1634
+ display: flex;
1635
+ flex-direction: column;
1636
+ gap: 10px;
1328
1637
  font-family: inherit;
1329
- font-size: 14px;
1638
+ font-size: 13.5px;
1330
1639
  line-height: 1.45;
1331
1640
  color: var(--rf-text);
1332
1641
  }
1333
1642
 
1334
- .root[data-position="bottom-right"] { bottom: var(--rf-offset); right: var(--rf-offset); align-items: flex-end; }
1335
- .root[data-position="bottom-left"] { bottom: var(--rf-offset); left: var(--rf-offset); align-items: flex-start; }
1336
- .root[data-position="top-right"] { top: var(--rf-offset); right: var(--rf-offset); align-items: flex-end; }
1337
- .root[data-position="top-left"] { top: var(--rf-offset); left: var(--rf-offset); align-items: flex-start; }
1338
-
1339
- .stack { display: flex; flex-direction: column; gap: 12px; }
1340
- .root[data-position^="top"] .stack { flex-direction: column-reverse; }
1643
+ .root[data-position$="right"] { right: var(--rf-offset); align-items: flex-end; }
1644
+ .root[data-position$="left"] { left: var(--rf-offset); align-items: flex-start; }
1645
+ .root[data-position^="bottom"] { bottom: var(--rf-offset); }
1646
+ .root[data-position^="top"] { top: var(--rf-offset); flex-direction: column-reverse; }
1341
1647
 
1342
1648
  button {
1343
1649
  font: inherit;
@@ -1353,64 +1659,76 @@ button {
1353
1659
  .launcher {
1354
1660
  display: inline-flex;
1355
1661
  align-items: center;
1356
- gap: 8px;
1357
- height: 44px;
1358
- padding: 0 16px;
1359
- border-radius: 999px;
1662
+ gap: 6px;
1663
+ height: 32px;
1664
+ padding: 0 11px;
1665
+ border-radius: var(--rf-radius-sm);
1360
1666
  background: var(--rf-primary);
1361
1667
  color: var(--rf-primary-text);
1362
- font-weight: 550;
1363
- box-shadow: var(--rf-shadow);
1364
- transition: transform 160ms var(--rf-ease), filter 160ms var(--rf-ease);
1668
+ font-size: 13px;
1669
+ font-weight: 500;
1670
+ letter-spacing: -0.005em;
1671
+ box-shadow: var(--rf-shadow-low);
1672
+ transition: transform 240ms var(--rf-ease), box-shadow 240ms var(--rf-ease), opacity 160ms var(--rf-ease);
1365
1673
  }
1366
- .launcher:hover { transform: translateY(-1px); filter: brightness(1.06); }
1367
- .launcher:active { transform: translateY(0) scale(0.97); }
1368
- .launcher[data-compact="true"] { width: 44px; padding: 0; justify-content: center; }
1674
+ .launcher:hover { transform: translateY(-1px); box-shadow: var(--rf-shadow); }
1675
+ .launcher:active { transform: translateY(0) scale(0.98); }
1676
+
1677
+ .launcher-icon { position: relative; width: 15px; height: 15px; flex: none; }
1678
+ .launcher-icon svg { position: absolute; inset: 0; width: 100%; height: 100%; transition: opacity 200ms var(--rf-ease), transform 320ms var(--rf-ease); }
1679
+ .launcher-icon .on-close { opacity: 0; transform: rotate(-90deg) scale(0.6); }
1680
+ .launcher[data-open="true"] .on-open { opacity: 0; transform: rotate(90deg) scale(0.6); }
1681
+ .launcher[data-open="true"] .on-close { opacity: 1; transform: none; }
1369
1682
 
1370
1683
  .panel {
1371
- width: min(384px, calc(100vw - 32px));
1372
- max-height: min(620px, calc(100vh - 96px));
1684
+ width: min(352px, calc(100vw - 24px));
1685
+ max-height: min(600px, calc(100vh - 96px));
1373
1686
  display: flex;
1374
1687
  flex-direction: column;
1375
1688
  overflow: hidden;
1376
1689
  background: var(--rf-bg);
1377
- border: 1px solid var(--rf-border);
1378
1690
  border-radius: var(--rf-radius);
1379
1691
  box-shadow: var(--rf-shadow);
1380
1692
  animation: rf-in 220ms var(--rf-ease);
1381
1693
  }
1694
+ .root[data-position^="bottom"] .panel { transform-origin: bottom center; }
1695
+ .root[data-position^="top"] .panel { transform-origin: top center; animation-name: rf-in-top; }
1382
1696
 
1383
1697
  @keyframes rf-in {
1384
- from { opacity: 0; transform: translateY(8px) scale(0.98); }
1385
- to { opacity: 1; transform: none; }
1698
+ from { opacity: 0; transform: translateY(6px) scale(0.985); }
1699
+ }
1700
+ @keyframes rf-in-top {
1701
+ from { opacity: 0; transform: translateY(-6px) scale(0.985); }
1386
1702
  }
1703
+ @keyframes rf-fade { from { opacity: 0; } }
1387
1704
 
1388
1705
  @media (prefers-reduced-motion: reduce) {
1389
- .panel, .launcher { animation: none; transition: none; }
1706
+ *, *::before, *::after { animation: none !important; transition: none !important; }
1390
1707
  }
1391
1708
 
1392
1709
  .head {
1393
1710
  display: flex;
1394
1711
  align-items: center;
1395
1712
  gap: 8px;
1396
- padding: 14px 14px 10px;
1713
+ padding: 12px 10px 4px 14px;
1397
1714
  }
1398
- .head h2 { margin: 0; font-size: 15px; font-weight: 600; }
1715
+ .head h2 { margin: 0; font-size: 13.5px; font-weight: 600; letter-spacing: -0.01em; }
1399
1716
  .head .spacer { flex: 1; }
1400
1717
 
1401
1718
  .icon-btn {
1402
1719
  display: inline-flex;
1403
1720
  align-items: center;
1404
1721
  justify-content: center;
1405
- width: 30px;
1406
- height: 30px;
1407
- border-radius: 8px;
1722
+ width: 26px;
1723
+ height: 26px;
1724
+ flex: none;
1725
+ border-radius: 6px;
1408
1726
  color: var(--rf-text-muted);
1409
1727
  transition: background 120ms var(--rf-ease), color 120ms var(--rf-ease);
1410
1728
  }
1411
- .icon-btn:hover { background: var(--rf-bg-subtle); color: var(--rf-text); }
1729
+ .icon-btn:hover { background: var(--rf-bg-hover); color: var(--rf-text); }
1412
1730
 
1413
- .body { display: flex; flex-direction: column; gap: 12px; padding: 0 14px 14px; overflow-y: auto; }
1731
+ .body { display: flex; flex-direction: column; gap: 8px; padding: 6px 14px 14px; overflow-y: auto; }
1414
1732
 
1415
1733
  .segmented {
1416
1734
  border: 0;
@@ -1419,94 +1737,88 @@ button {
1419
1737
  display: grid;
1420
1738
  grid-auto-flow: column;
1421
1739
  grid-auto-columns: 1fr;
1422
- gap: 4px;
1423
- padding: 4px;
1740
+ gap: 2px;
1741
+ padding: 2px;
1424
1742
  background: var(--rf-bg-subtle);
1425
- border-radius: 10px;
1743
+ border-radius: var(--rf-radius-sm);
1426
1744
  }
1427
1745
  .segmented button {
1428
1746
  display: flex;
1429
1747
  align-items: center;
1430
1748
  justify-content: center;
1431
- gap: 6px;
1432
- height: 32px;
1433
- border-radius: 7px;
1434
- font-size: 13px;
1749
+ gap: 5px;
1750
+ height: 26px;
1751
+ border-radius: 6px;
1752
+ font-size: 12.5px;
1435
1753
  font-weight: 500;
1436
1754
  color: var(--rf-text-muted);
1437
- transition: background 140ms var(--rf-ease), color 140ms var(--rf-ease);
1755
+ transition: background 160ms var(--rf-ease), color 160ms var(--rf-ease);
1438
1756
  }
1757
+ .segmented button:hover { color: var(--rf-text); }
1439
1758
  .segmented button[aria-pressed="true"] {
1440
1759
  background: var(--rf-bg);
1441
1760
  color: var(--rf-text);
1442
- box-shadow: 0 1px 2px rgb(16 24 40 / 8%);
1761
+ box-shadow: var(--rf-shadow-low);
1443
1762
  }
1444
1763
 
1445
1764
  textarea, input[type="email"] {
1446
1765
  width: 100%;
1447
- padding: 10px 12px;
1766
+ padding: 8px 10px;
1448
1767
  font: inherit;
1449
1768
  color: var(--rf-text);
1450
1769
  background: var(--rf-bg);
1451
1770
  border: 1px solid var(--rf-border);
1452
- border-radius: 10px;
1453
- transition: border-color 140ms var(--rf-ease), box-shadow 140ms var(--rf-ease);
1771
+ border-radius: var(--rf-radius-sm);
1772
+ transition: border-color 160ms var(--rf-ease), box-shadow 160ms var(--rf-ease);
1454
1773
  }
1455
- textarea { min-height: 92px; max-height: 220px; resize: none; }
1774
+ textarea { min-height: 84px; max-height: 220px; resize: none; }
1456
1775
  textarea::placeholder, input::placeholder { color: var(--rf-text-muted); }
1457
- textarea:focus, input:focus { outline: none; border-color: var(--rf-primary); box-shadow: var(--rf-ring); }
1776
+ textarea:focus, input:focus { outline: none; border-color: color-mix(in srgb, var(--rf-accent) 55%, transparent); box-shadow: var(--rf-ring); }
1777
+
1778
+ .attachments { display: flex; flex-wrap: wrap; gap: 6px; }
1458
1779
 
1459
- .attachment {
1780
+ .card {
1781
+ flex: 1 0 100%;
1460
1782
  display: flex;
1783
+ align-items: center;
1461
1784
  gap: 10px;
1462
- padding: 8px;
1463
- border: 1px solid var(--rf-border);
1785
+ min-width: 0;
1786
+ padding: 6px;
1464
1787
  border-radius: 10px;
1465
1788
  background: var(--rf-bg-subtle);
1789
+ animation: rf-fade 200ms var(--rf-ease);
1466
1790
  }
1467
1791
  .thumb {
1468
1792
  position: relative;
1469
- flex: 0 0 84px;
1470
- height: 56px;
1793
+ flex: 0 0 60px;
1794
+ height: 42px;
1471
1795
  overflow: hidden;
1472
1796
  border-radius: 6px;
1473
- border: 1px solid var(--rf-border);
1474
1797
  background: var(--rf-bg);
1798
+ box-shadow: var(--rf-shadow-inset);
1475
1799
  }
1476
1800
  .thumb img { width: 100%; height: 100%; object-fit: cover; object-position: top center; display: block; }
1477
- .attachment .meta { display: flex; flex-direction: column; justify-content: center; gap: 4px; min-width: 0; }
1478
- .attachment .meta strong { font-size: 12.5px; font-weight: 550; }
1479
- .attachment .meta span { font-size: 12px; color: var(--rf-text-muted); }
1480
- .attachment .actions { display: flex; gap: 4px; margin-left: auto; align-items: center; }
1481
-
1482
- .chip {
1483
- display: inline-flex;
1484
- align-items: center;
1485
- gap: 6px;
1486
- max-width: 100%;
1487
- padding: 5px 8px;
1488
- font-size: 12px;
1489
- border: 1px solid var(--rf-border);
1490
- border-radius: 8px;
1491
- background: var(--rf-bg-subtle);
1492
- color: var(--rf-text-muted);
1493
- }
1494
- .chip code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px; color: var(--rf-text); }
1495
- .chip .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1801
+ .thumb.contain img { object-fit: contain; object-position: center; }
1802
+ .card .meta { display: flex; flex-direction: column; justify-content: center; gap: 1px; min-width: 0; }
1803
+ .card .meta strong { font-size: 12.5px; font-weight: 550; }
1804
+ .card .meta span { font-size: 11.5px; color: var(--rf-text-muted); }
1805
+ .card .actions { display: flex; gap: 2px; margin-left: auto; align-items: center; }
1806
+ .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1807
+ .card code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; }
1496
1808
 
1497
- .row { display: flex; gap: 8px; align-items: center; }
1498
1809
  .ghost-btn {
1810
+ flex: 0 0 auto;
1499
1811
  display: inline-flex;
1500
1812
  align-items: center;
1501
1813
  gap: 6px;
1502
- height: 32px;
1814
+ height: 30px;
1503
1815
  padding: 0 10px;
1504
- font-size: 13px;
1816
+ font-size: 12.5px;
1505
1817
  font-weight: 500;
1506
1818
  color: var(--rf-text-muted);
1507
- border: 1px solid var(--rf-border);
1508
- border-radius: 9px;
1509
- transition: background 140ms var(--rf-ease), color 140ms var(--rf-ease);
1819
+ border-radius: var(--rf-radius-sm);
1820
+ box-shadow: var(--rf-shadow-inset);
1821
+ transition: background 160ms var(--rf-ease), color 160ms var(--rf-ease);
1510
1822
  }
1511
1823
  .ghost-btn:hover { background: var(--rf-bg-subtle); color: var(--rf-text); }
1512
1824
 
@@ -1516,53 +1828,55 @@ textarea:focus, input:focus { outline: none; border-color: var(--rf-primary); bo
1516
1828
  justify-content: center;
1517
1829
  gap: 8px;
1518
1830
  width: 100%;
1519
- height: 38px;
1831
+ height: 32px;
1832
+ margin-top: 2px;
1833
+ font-size: 13px;
1520
1834
  font-weight: 550;
1521
1835
  color: var(--rf-primary-text);
1522
1836
  background: var(--rf-primary);
1523
- border-radius: 10px;
1524
- transition: filter 140ms var(--rf-ease);
1837
+ border-radius: var(--rf-radius-sm);
1838
+ transition: opacity 160ms var(--rf-ease), transform 160ms var(--rf-ease);
1525
1839
  }
1526
- .submit:hover:not(:disabled) { filter: brightness(1.06); }
1527
- .submit:disabled { opacity: 0.55; cursor: not-allowed; }
1840
+ .submit:hover:not(:disabled) { opacity: 0.88; }
1841
+ .submit:active:not(:disabled) { transform: scale(0.99); }
1842
+ .submit:disabled { opacity: 0.35; cursor: not-allowed; }
1528
1843
 
1529
- .error { margin: 0; font-size: 12.5px; color: var(--rf-danger); }
1530
- .hint { margin: 0; font-size: 12px; color: var(--rf-text-muted); }
1844
+ .error { margin: 0; font-size: 12px; color: var(--rf-danger); }
1531
1845
 
1532
1846
  .footer {
1533
- min-height: 44px;
1847
+ min-height: 34px;
1534
1848
  padding: 0 14px;
1535
1849
  border-top: 1px solid var(--rf-border);
1536
1850
  display: flex;
1537
1851
  align-items: center;
1538
1852
  justify-content: center;
1539
1853
  gap: 12px;
1540
- font-size: 11.5px;
1854
+ font-size: 11px;
1541
1855
  color: var(--rf-text-muted);
1542
1856
  }
1543
1857
  .footer[data-dismissible="true"] { justify-content: space-between; }
1544
- .footer a { color: inherit; text-decoration: none; font-weight: 550; }
1858
+ .footer a { color: inherit; text-decoration: none; font-weight: 550; transition: color 120ms var(--rf-ease); }
1545
1859
  .footer a:hover { color: var(--rf-text); }
1546
1860
  .dismiss-btn {
1547
1861
  align-self: stretch;
1548
1862
  color: inherit;
1549
- font-size: 11.5px;
1550
- text-decoration: underline;
1551
- text-underline-offset: 3px;
1863
+ font-size: 11px;
1864
+ transition: color 120ms var(--rf-ease);
1552
1865
  }
1553
1866
  .dismiss-btn:hover { color: var(--rf-text); }
1554
1867
 
1555
- .done { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 32px 20px 36px; text-align: center; }
1868
+ .done { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 30px 20px 34px; text-align: center; }
1556
1869
  .done svg { color: var(--rf-success); }
1557
- .done h2 { margin: 0; font-size: 15px; }
1558
- .done p { margin: 0; font-size: 13px; color: var(--rf-text-muted); }
1870
+ .done h2 { margin: 0; font-size: 14px; font-weight: 600; }
1871
+ .done p { margin: 0; font-size: 12.5px; color: var(--rf-text-muted); }
1559
1872
 
1560
1873
  .spinner {
1561
- width: 15px;
1562
- height: 15px;
1874
+ width: 14px;
1875
+ height: 14px;
1563
1876
  border: 2px solid currentColor;
1564
1877
  border-right-color: transparent;
1565
1878
  border-radius: 50%;
1879
+ opacity: 0.6;
1566
1880
  animation: rf-spin 700ms linear infinite;
1567
1881
  }
1568
1882
  @keyframes rf-spin { to { transform: rotate(360deg); } }
@@ -1575,16 +1889,17 @@ textarea:focus, input:focus { outline: none; border-color: var(--rf-primary); bo
1575
1889
  z-index: 2147483001;
1576
1890
  display: flex;
1577
1891
  flex-direction: column;
1578
- background: rgb(9 10 13 / 78%);
1579
- backdrop-filter: blur(2px);
1892
+ background: rgb(15 15 15 / 76%);
1893
+ backdrop-filter: blur(3px);
1894
+ animation: rf-fade 180ms var(--rf-ease);
1580
1895
  }
1581
1896
 
1582
1897
  .editor { flex: 1; display: flex; align-items: center; justify-content: center; padding: 24px 24px 0; min-height: 0; }
1583
1898
  .editor canvas {
1584
1899
  max-width: 100%;
1585
1900
  max-height: 100%;
1586
- border-radius: 8px;
1587
- box-shadow: 0 24px 60px -12px rgb(0 0 0 / 60%);
1901
+ border-radius: 10px;
1902
+ box-shadow: 0 20px 60px -16px rgb(0 0 0 / 70%);
1588
1903
  cursor: crosshair;
1589
1904
  touch-action: none;
1590
1905
  }
@@ -1600,97 +1915,106 @@ textarea:focus, input:focus { outline: none; border-color: var(--rf-primary); bo
1600
1915
  .toolbar .group {
1601
1916
  display: flex;
1602
1917
  align-items: center;
1603
- gap: 4px;
1604
- padding: 5px;
1918
+ gap: 2px;
1919
+ padding: 4px;
1605
1920
  background: var(--rf-bg);
1606
- border: 1px solid var(--rf-border);
1607
- border-radius: 12px;
1921
+ border-radius: 10px;
1608
1922
  box-shadow: var(--rf-shadow);
1609
1923
  }
1610
1924
  .tool {
1611
1925
  display: inline-flex;
1612
1926
  align-items: center;
1613
1927
  justify-content: center;
1614
- width: 34px;
1615
- height: 34px;
1616
- border-radius: 8px;
1928
+ width: 30px;
1929
+ height: 30px;
1930
+ border-radius: 7px;
1617
1931
  color: var(--rf-text-muted);
1618
- transition: background 140ms var(--rf-ease), color 140ms var(--rf-ease);
1932
+ transition: background 160ms var(--rf-ease), color 160ms var(--rf-ease);
1619
1933
  }
1620
- .tool:hover { background: var(--rf-bg-subtle); color: var(--rf-text); }
1621
- .tool[aria-pressed="true"] { background: var(--rf-primary); color: var(--rf-primary-text); }
1934
+ .tool:hover { background: var(--rf-bg-hover); color: var(--rf-text); }
1935
+ .tool[aria-pressed="true"] { background: var(--rf-bg-hover); color: var(--rf-text); }
1622
1936
  .swatch {
1623
- width: 22px;
1624
- height: 22px;
1937
+ width: 20px;
1938
+ height: 20px;
1625
1939
  border-radius: 50%;
1626
1940
  border: 2px solid transparent;
1627
- transition: transform 140ms var(--rf-ease);
1941
+ transition: transform 200ms var(--rf-ease), border-color 160ms var(--rf-ease);
1628
1942
  }
1629
- .swatch[aria-pressed="true"] { border-color: var(--rf-text); transform: scale(1.12); }
1943
+ .swatch[aria-pressed="true"] { border-color: var(--rf-text); transform: scale(1.15); }
1630
1944
  .toolbar .done-btn {
1631
- height: 34px;
1632
- padding: 0 14px;
1945
+ height: 30px;
1946
+ padding: 0 13px;
1947
+ font-size: 13px;
1633
1948
  font-weight: 550;
1634
- border-radius: 8px;
1949
+ border-radius: 7px;
1635
1950
  color: var(--rf-primary-text);
1636
1951
  background: var(--rf-primary);
1952
+ transition: opacity 160ms var(--rf-ease);
1637
1953
  }
1954
+ .toolbar .done-btn:hover { opacity: 0.88; }
1638
1955
 
1639
- .picker { position: fixed; inset: 0; z-index: 2147483002; cursor: crosshair; }
1640
1956
  .picker-box {
1641
1957
  position: fixed;
1642
1958
  pointer-events: none;
1643
- border: 2px solid var(--rf-primary);
1644
- border-radius: 3px;
1645
- background: color-mix(in srgb, var(--rf-primary) 12%, transparent);
1646
- transition: all 90ms var(--rf-ease);
1959
+ border-radius: 5px;
1960
+ background: color-mix(in srgb, var(--rf-accent) 10%, transparent);
1961
+ box-shadow: 0 0 0 2px var(--rf-accent), 0 0 0 5px color-mix(in srgb, var(--rf-accent) 18%, transparent);
1962
+ transition: top 130ms var(--rf-ease), left 130ms var(--rf-ease), width 130ms var(--rf-ease), height 130ms var(--rf-ease);
1647
1963
  }
1648
1964
  .picker-label {
1649
1965
  position: fixed;
1650
1966
  pointer-events: none;
1651
- display: flex;
1967
+ display: inline-flex;
1652
1968
  align-items: center;
1653
1969
  gap: 6px;
1654
1970
  max-width: min(420px, 90vw);
1655
- padding: 5px 9px;
1656
- font-size: 12px;
1971
+ height: 24px;
1972
+ padding: 0 8px;
1973
+ font-size: 11.5px;
1657
1974
  font-weight: 500;
1658
- color: var(--rf-primary-text);
1659
- background: var(--rf-primary);
1660
- border-radius: 7px;
1975
+ color: #ffffff;
1976
+ background: var(--rf-accent);
1977
+ border-radius: 6px;
1978
+ box-shadow: 0 4px 12px -3px rgb(15 15 15 / 35%);
1661
1979
  white-space: nowrap;
1662
1980
  overflow: hidden;
1663
- text-overflow: ellipsis;
1981
+ transition: top 130ms var(--rf-ease), left 130ms var(--rf-ease);
1664
1982
  }
1665
- .picker-label code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; opacity: 0.82; }
1983
+ .picker-label strong { flex: none; font-weight: 600; }
1984
+ .picker-label span { min-width: 0; overflow: hidden; text-overflow: ellipsis; opacity: 0.75; }
1666
1985
  .picker-hint {
1667
1986
  position: fixed;
1668
1987
  left: 50%;
1669
1988
  bottom: 24px;
1670
1989
  transform: translateX(-50%);
1671
- padding: 9px 14px;
1672
- font-size: 13px;
1990
+ display: flex;
1991
+ align-items: center;
1992
+ gap: 6px;
1993
+ padding: 7px 12px;
1994
+ font-size: 12.5px;
1673
1995
  color: var(--rf-text);
1674
1996
  background: var(--rf-bg);
1675
- border: 1px solid var(--rf-border);
1676
1997
  border-radius: 10px;
1677
1998
  box-shadow: var(--rf-shadow);
1999
+ animation: rf-fade 240ms var(--rf-ease);
1678
2000
  }
1679
2001
  .picker-hint kbd {
1680
2002
  padding: 1px 5px;
1681
2003
  font: inherit;
1682
- font-size: 11.5px;
1683
- border: 1px solid var(--rf-border);
2004
+ font-size: 11px;
2005
+ color: var(--rf-text-muted);
1684
2006
  border-radius: 4px;
1685
2007
  background: var(--rf-bg-subtle);
2008
+ box-shadow: var(--rf-shadow-inset);
1686
2009
  }
2010
+
1687
2011
  @media (pointer: coarse) {
1688
2012
  .icon-btn, .tool, .swatch {
1689
2013
  width: 44px;
1690
2014
  height: 44px;
1691
2015
  }
1692
2016
 
1693
- .segmented button, .ghost-btn, .submit, .toolbar .done-btn {
2017
+ .launcher, .segmented button, .ghost-btn, .submit, .toolbar .done-btn {
1694
2018
  min-height: 44px;
1695
2019
  }
1696
2020
 
@@ -1705,7 +2029,6 @@ textarea:focus, input:focus { outline: none; border-color: var(--rf-primary); bo
1705
2029
  justify-content: center;
1706
2030
  }
1707
2031
  }
1708
-
1709
2032
  `;
1710
2033
  //#endregion
1711
2034
  //#region src/feedback/ui/shadow-portal.tsx
@@ -1832,6 +2155,128 @@ function startConsoleRecorder(options) {
1832
2155
  };
1833
2156
  }
1834
2157
  //#endregion
2158
+ //#region src/feedback/core/element-capture.ts
2159
+ const MIN_CONTEXT_PADDING = 24;
2160
+ const MAX_CONTEXT_WIDTH = 2e3;
2161
+ const CLOSE_UP_MAX_WIDTH = 1200;
2162
+ const SELECTION_COLOR = "#4f46e5";
2163
+ const OUTLINE_COLOR = "#4f46e5";
2164
+ const OUTLINE_GLOW = "#ffffff";
2165
+ const OUTLINE_WIDTH = 3;
2166
+ const GLOW_WIDTH = 6;
2167
+ const DIM_OVERLAY = "rgba(15, 23, 42, 0.45)";
2168
+ /**
2169
+ * Tightest ancestor that keeps some breathing room around the pick — the zoom
2170
+ * stays zoomed instead of growing to a giant section. Only when nothing up
2171
+ * the tree clears the minimum does the widest candidate below the cap win.
2172
+ */
2173
+ function contextRootFor(element) {
2174
+ const target = element.getBoundingClientRect();
2175
+ const coversAtLeast = (box, margin) => box.left <= target.left - margin && box.right >= target.right + margin && box.top <= target.top - margin && box.bottom >= target.bottom + margin;
2176
+ let tightest = null;
2177
+ let current = element.parentElement;
2178
+ while (current && current !== document.documentElement) {
2179
+ const box = current.getBoundingClientRect();
2180
+ if (coversAtLeast(box, MIN_CONTEXT_PADDING)) return current;
2181
+ tightest ??= box.width < MAX_CONTEXT_WIDTH ? current : null;
2182
+ current = current.parentElement;
2183
+ }
2184
+ return tightest ?? element;
2185
+ }
2186
+ /** Where the picked element sits inside the context shot, in image pixels. */
2187
+ function closeUpGeometry(params) {
2188
+ const scale = params.rootRect.width > 0 ? params.capture.width / params.rootRect.width : 1;
2189
+ return {
2190
+ height: Math.round(params.elementRect.height * scale),
2191
+ scale,
2192
+ width: Math.round(params.elementRect.width * scale),
2193
+ x: Math.round((params.elementRect.left - params.rootRect.left) * scale),
2194
+ y: Math.round((params.elementRect.top - params.rootRect.top) * scale)
2195
+ };
2196
+ }
2197
+ /** Rectangle annotation marking the picked element on the viewport shot. */
2198
+ function highlightFor(selection, capture) {
2199
+ const scale = capture.width / window.innerWidth;
2200
+ const { rect } = selection;
2201
+ return {
2202
+ color: SELECTION_COLOR,
2203
+ end: {
2204
+ x: (rect.x + rect.width) * scale,
2205
+ y: (rect.y + rect.height) * scale
2206
+ },
2207
+ id: `selection-${rect.x}-${rect.y}`,
2208
+ start: {
2209
+ x: rect.x * scale,
2210
+ y: rect.y * scale
2211
+ },
2212
+ tool: "rectangle"
2213
+ };
2214
+ }
2215
+ function drawZoneOverlay(context, zone) {
2216
+ context.save();
2217
+ context.fillStyle = DIM_OVERLAY;
2218
+ context.beginPath();
2219
+ context.rect(0, 0, context.canvas.width, context.canvas.height);
2220
+ context.rect(zone.x, zone.y, zone.width, zone.height);
2221
+ context.fill("evenodd");
2222
+ for (const [color, width] of [[OUTLINE_GLOW, GLOW_WIDTH], [OUTLINE_COLOR, OUTLINE_WIDTH]]) {
2223
+ context.strokeStyle = color;
2224
+ context.lineWidth = width * zone.scale;
2225
+ context.strokeRect(zone.x, zone.y, zone.width, zone.height);
2226
+ }
2227
+ context.restore();
2228
+ }
2229
+ function drawCloseUp(capture, zone) {
2230
+ return new Promise((resolve) => {
2231
+ const canvas = document.createElement("canvas");
2232
+ canvas.width = capture.width;
2233
+ canvas.height = capture.height;
2234
+ const context = canvas.getContext("2d");
2235
+ const image = new Image();
2236
+ image.onload = () => {
2237
+ if (!context) {
2238
+ resolve(null);
2239
+ return;
2240
+ }
2241
+ context.drawImage(image, 0, 0, capture.width, capture.height);
2242
+ drawZoneOverlay(context, zone);
2243
+ resolve(capturedFromCanvas(canvas, CLOSE_UP_MAX_WIDTH));
2244
+ };
2245
+ image.onerror = () => resolve(null);
2246
+ image.src = capture.objectUrl;
2247
+ });
2248
+ }
2249
+ /**
2250
+ * Zoomed view of the picked zone: the surrounding area captured for context,
2251
+ * everything outside the selection dimmed and the zone framed with a glowing
2252
+ * outline — unambiguous to eyes and vision models alike.
2253
+ * Falls back to the plain context shot when the overlay cannot be drawn.
2254
+ */
2255
+ async function captureElementCloseUp(element) {
2256
+ try {
2257
+ const root = contextRootFor(element);
2258
+ const rootRect = root.getBoundingClientRect();
2259
+ const elementRect = element.getBoundingClientRect();
2260
+ const capture = await captureViewport({
2261
+ element: root,
2262
+ maxWidth: CLOSE_UP_MAX_WIDTH
2263
+ });
2264
+ if (!capture || root === element) return capture;
2265
+ const outlined = await drawCloseUp(capture, closeUpGeometry({
2266
+ capture,
2267
+ elementRect,
2268
+ rootRect
2269
+ }));
2270
+ if (outlined) {
2271
+ releaseCapture(capture);
2272
+ return outlined;
2273
+ }
2274
+ return capture;
2275
+ } catch {
2276
+ return null;
2277
+ }
2278
+ }
2279
+ //#endregion
1835
2280
  //#region src/feedback/core/submit.ts
1836
2281
  const MAX_TITLE_LENGTH = 100;
1837
2282
  const NEWLINE = /\r?\n/;
@@ -1849,20 +2294,20 @@ function buildDescription(params) {
1849
2294
  const contact = isAnonymous && email ? `\n\n---\nContact: ${email}` : "";
1850
2295
  return `[${label}] ${message.trim()}${contact}`;
1851
2296
  }
2297
+ async function upload(transport, image) {
2298
+ const { uploadUrl } = await transport.getScreenshotUploadUrl();
2299
+ return await transport.uploadImage(uploadUrl, image);
2300
+ }
1852
2301
  async function attachScreenshot(transport, feedbackId, submission) {
1853
2302
  const { annotated, annotations, context, screenshot } = submission;
1854
2303
  if (!screenshot) return false;
1855
- const original = await transport.getScreenshotUploadUrl();
1856
- const storageId = await transport.uploadImage(original.uploadUrl, screenshot);
1857
- let annotatedStorageId;
1858
- if (annotated) {
1859
- const target = await transport.getScreenshotUploadUrl();
1860
- annotatedStorageId = await transport.uploadImage(target.uploadUrl, annotated);
1861
- }
2304
+ const storageId = await upload(transport, screenshot);
2305
+ const annotatedStorageId = annotated ? await upload(transport, annotated) : void 0;
1862
2306
  const wireAnnotations = toWireAnnotations(annotations);
1863
2307
  await transport.saveScreenshot({
1864
2308
  annotatedStorageId,
1865
2309
  annotations: wireAnnotations.length > 0 ? wireAnnotations : void 0,
2310
+ captureSource: "widget",
1866
2311
  feedbackId,
1867
2312
  filename: "screenshot.png",
1868
2313
  height: screenshot.height,
@@ -1874,6 +2319,21 @@ async function attachScreenshot(transport, feedbackId, submission) {
1874
2319
  });
1875
2320
  return true;
1876
2321
  }
2322
+ async function attachElement(transport, feedbackId, submission) {
2323
+ const { context, element } = submission;
2324
+ if (!element) return;
2325
+ await transport.saveScreenshot({
2326
+ captureSource: "element",
2327
+ feedbackId,
2328
+ filename: "element.png",
2329
+ height: element.height,
2330
+ mimeType: element.mimeType,
2331
+ pageUrl: context.url,
2332
+ size: element.blob.size,
2333
+ storageId: await upload(transport, element),
2334
+ width: element.width
2335
+ });
2336
+ }
1877
2337
  /**
1878
2338
  * Creates the feedback first, then attaches the capture. A failed upload is
1879
2339
  * reported back but never discards what the user wrote.
@@ -1886,12 +2346,8 @@ async function submitWidgetFeedback(transport, submission) {
1886
2346
  description: buildDescription(submission),
1887
2347
  title
1888
2348
  });
1889
- let screenshotSaved = false;
1890
- try {
1891
- screenshotSaved = await attachScreenshot(transport, feedbackId, submission);
1892
- } catch {
1893
- screenshotSaved = false;
1894
- }
2349
+ const screenshotSaved = await attachScreenshot(transport, feedbackId, submission).catch(() => false);
2350
+ await attachElement(transport, feedbackId, submission).catch(() => void 0);
1895
2351
  return {
1896
2352
  feedbackId,
1897
2353
  screenshotSaved
@@ -1919,6 +2375,38 @@ function createFeedbackTransport(client) {
1919
2375
  };
1920
2376
  }
1921
2377
  //#endregion
2378
+ //#region src/feedback/ui/use-route-sync.ts
2379
+ /**
2380
+ * Calls `refresh` whenever the app navigates client-side while it is active.
2381
+ *
2382
+ * SPA routes never fire popstate on push/replace — patching history methods is
2383
+ * the only way to keep screenshot and URL describing the same page while the
2384
+ * reporter is composing.
2385
+ */
2386
+ function useRouteSync(enabled, refresh) {
2387
+ useEffect(() => {
2388
+ if (!enabled) return;
2389
+ const originalPush = window.history.pushState.bind(window.history);
2390
+ const originalReplace = window.history.replaceState.bind(window.history);
2391
+ window.history.pushState = function pushState(data, unused, url) {
2392
+ const result = originalPush(data, unused, url);
2393
+ refresh();
2394
+ return result;
2395
+ };
2396
+ window.history.replaceState = function replaceState(data, unused, url) {
2397
+ const result = originalReplace(data, unused, url);
2398
+ refresh();
2399
+ return result;
2400
+ };
2401
+ window.addEventListener("popstate", refresh);
2402
+ return () => {
2403
+ window.history.pushState = originalPush;
2404
+ window.history.replaceState = originalReplace;
2405
+ window.removeEventListener("popstate", refresh);
2406
+ };
2407
+ }, [enabled, refresh]);
2408
+ }
2409
+ //#endregion
1922
2410
  //#region src/feedback/ui/use-widget-state.ts
1923
2411
  const SUCCESS_CLOSE_DELAY = 2400;
1924
2412
  const IS_APPLE = /Mac|iPhone|iPad/;
@@ -1958,22 +2446,21 @@ function matchesHotkey(event, hotkey, platform = typeof navigator === "undefined
1958
2446
  };
1959
2447
  return event.altKey === expected.alt && event.ctrlKey === expected.ctrl && event.metaKey === expected.meta && event.shiftKey === expected.shift;
1960
2448
  }
1961
- function highlightFor(selection, capture) {
1962
- const scale = capture.width / window.innerWidth;
1963
- const { rect } = selection;
1964
- return {
1965
- color: "#4f46e5",
1966
- end: {
1967
- x: (rect.x + rect.width) * scale,
1968
- y: (rect.y + rect.height) * scale
1969
- },
1970
- id: `selection-${rect.x}-${rect.y}`,
1971
- start: {
1972
- x: rect.x * scale,
1973
- y: rect.y * scale
1974
- },
1975
- tool: "rectangle"
1976
- };
2449
+ /**
2450
+ * A capture whose object URL is revoked the moment it is replaced or dropped —
2451
+ * holding one without this leaks the blob for the lifetime of the page.
2452
+ */
2453
+ function useOwnedCapture() {
2454
+ const [image, setImage] = useState(null);
2455
+ const ref = useRef(null);
2456
+ useEffect(() => {
2457
+ ref.current = image;
2458
+ }, [image]);
2459
+ useEffect(() => () => releaseCapture(ref.current), []);
2460
+ return [image, useCallback((next) => {
2461
+ releaseCapture(ref.current);
2462
+ setImage(next);
2463
+ }, [])];
1977
2464
  }
1978
2465
  function useWidgetState(props) {
1979
2466
  const context = useContext(RefletContext);
@@ -1990,18 +2477,17 @@ function useWidgetState(props) {
1990
2477
  const [message, setMessage] = useState("");
1991
2478
  const [email, setEmail] = useState("");
1992
2479
  const [honeypot, setHoneypot] = useState("");
1993
- const [capture, setCapture] = useState(null);
2480
+ const [capture, replaceCapture] = useOwnedCapture();
2481
+ const [elementCapture, replaceElementCapture] = useOwnedCapture();
1994
2482
  const [annotations, setAnnotations] = useState([]);
1995
2483
  const [selection, setSelection] = useState(null);
1996
2484
  const [isCapturing, setIsCapturing] = useState(false);
1997
2485
  const [isSubmitting, setIsSubmitting] = useState(false);
1998
2486
  const [error, setError] = useState(null);
1999
2487
  const [dismissedUntil, setDismissedUntil] = useState(() => readDismissedUntil(dismissalKey));
2000
- const captureRef = useRef(null);
2488
+ const pickRef = useRef(0);
2001
2489
  const consoleRef = useRef(null);
2002
- useEffect(() => {
2003
- captureRef.current = capture;
2004
- }, [capture]);
2490
+ const frozenContext = useRef(null);
2005
2491
  useEffect(() => {
2006
2492
  setDismissedUntil(readDismissedUntil(dismissalKey));
2007
2493
  }, [dismissalKey]);
@@ -2014,23 +2500,22 @@ function useWidgetState(props) {
2014
2500
  recorder.stop();
2015
2501
  };
2016
2502
  }, [props.captureConsole]);
2017
- useEffect(() => () => {
2018
- releaseCapture(captureRef.current);
2019
- }, []);
2020
- const replaceCapture = useCallback((next) => {
2021
- releaseCapture(captureRef.current);
2022
- setCapture(next);
2023
- }, []);
2024
- const takeCapture = useCallback((element) => {
2503
+ /** Frozen at the same instant as the screenshot, so shot and URL always match. */
2504
+ const takeCapture = useCallback(() => {
2505
+ const context = collectPageContext({ sdkVersion: SDK_VERSION });
2025
2506
  setIsCapturing(true);
2026
- captureViewport({ element }).catch(() => null).then((image) => {
2507
+ captureViewport().catch(() => null).then((image) => {
2027
2508
  setIsCapturing(false);
2028
2509
  replaceCapture(image);
2510
+ frozenContext.current = image ? context : null;
2029
2511
  });
2030
2512
  }, [replaceCapture]);
2513
+ useRouteSync(isOpen && step === "compose" && annotations.length === 0 && !selection && props.captureOnOpen !== false, takeCapture);
2031
2514
  const reset = useCallback(() => {
2032
- releaseCapture(captureRef.current);
2033
- setCapture(null);
2515
+ pickRef.current++;
2516
+ frozenContext.current = null;
2517
+ replaceCapture(null);
2518
+ replaceElementCapture(null);
2034
2519
  setAnnotations([]);
2035
2520
  setSelection(null);
2036
2521
  setMessage("");
@@ -2039,7 +2524,11 @@ function useWidgetState(props) {
2039
2524
  setError(null);
2040
2525
  setStep("compose");
2041
2526
  setCategory(props.defaultCategory ?? "bug");
2042
- }, [props.defaultCategory]);
2527
+ }, [
2528
+ props.defaultCategory,
2529
+ replaceCapture,
2530
+ replaceElementCapture
2531
+ ]);
2043
2532
  const close = useCallback(() => {
2044
2533
  setIsOpen(false);
2045
2534
  reset();
@@ -2069,18 +2558,32 @@ function useWidgetState(props) {
2069
2558
  props.onOpen,
2070
2559
  takeCapture
2071
2560
  ]);
2072
- const selectElement = useCallback((element) => {
2561
+ /** Fresh pixels so the highlight maps onto the scroll position the element had. */
2562
+ const selectElement = useCallback(async (element) => {
2073
2563
  const picked = buildElementSelection(element);
2074
2564
  setSelection(picked);
2075
2565
  setStep("compose");
2076
- const current = captureRef.current;
2077
- if (current) setAnnotations((previous) => [...previous.filter((item) => !item.id.startsWith("selection-")), highlightFor(picked, current)]);
2078
- }, []);
2566
+ const pick = ++pickRef.current;
2567
+ const context = collectPageContext({ sdkVersion: SDK_VERSION });
2568
+ const [fresh, closeUp] = await Promise.all([captureViewport().catch(() => null), captureElementCloseUp(element).catch(() => null)]);
2569
+ if (pickRef.current !== pick) {
2570
+ releaseCapture(fresh);
2571
+ releaseCapture(closeUp);
2572
+ return;
2573
+ }
2574
+ replaceCapture(fresh);
2575
+ replaceElementCapture(closeUp);
2576
+ frozenContext.current = fresh ? context : null;
2577
+ setAnnotations(fresh ? [highlightFor(picked, fresh)] : []);
2578
+ }, [replaceCapture, replaceElementCapture]);
2079
2579
  const clearSelection = useCallback(() => {
2580
+ pickRef.current++;
2581
+ replaceElementCapture(null);
2080
2582
  setSelection(null);
2081
2583
  setAnnotations((previous) => previous.filter((item) => !item.id.startsWith("selection-")));
2082
- }, []);
2584
+ }, [replaceElementCapture]);
2083
2585
  const removeCapture = useCallback(() => {
2586
+ frozenContext.current = null;
2084
2587
  replaceCapture(null);
2085
2588
  setAnnotations([]);
2086
2589
  }, [replaceCapture]);
@@ -2114,11 +2617,12 @@ function useWidgetState(props) {
2114
2617
  annotations,
2115
2618
  category,
2116
2619
  context: {
2117
- ...collectPageContext({ sdkVersion: SDK_VERSION }),
2620
+ ...frozenContext.current ?? collectPageContext({ sdkVersion: "0.3.1" }),
2118
2621
  consoleEvents: consoleRef.current?.events(),
2119
2622
  metadata: props.metadata,
2120
2623
  selection: selection ?? void 0
2121
2624
  },
2625
+ element: elementCapture,
2122
2626
  email,
2123
2627
  isAnonymous,
2124
2628
  message,
@@ -2139,6 +2643,7 @@ function useWidgetState(props) {
2139
2643
  category,
2140
2644
  client,
2141
2645
  close,
2646
+ elementCapture,
2142
2647
  email,
2143
2648
  honeypot,
2144
2649
  isAnonymous,
@@ -2156,6 +2661,7 @@ function useWidgetState(props) {
2156
2661
  close,
2157
2662
  dismiss,
2158
2663
  dismissForDays,
2664
+ elementCapture,
2159
2665
  email,
2160
2666
  error,
2161
2667
  honeypot,
@@ -2238,22 +2744,18 @@ function RefletFeedback(props) {
2238
2744
  primaryColor: props.primaryColor,
2239
2745
  theme,
2240
2746
  children: [
2241
- /* @__PURE__ */ jsx("div", {
2747
+ /* @__PURE__ */ jsxs("div", {
2242
2748
  className: "root",
2243
2749
  "data-position": position,
2244
- children: /* @__PURE__ */ jsxs("div", {
2245
- className: "stack",
2246
- children: [showPanel && /* @__PURE__ */ jsx(FeedbackPanel, {
2247
- categories,
2248
- labels,
2249
- state
2250
- }), /* @__PURE__ */ jsx(Launcher, {
2251
- compact: isOpen,
2252
- isOpen,
2253
- label: labels.trigger,
2254
- onClick: isOpen ? close : open
2255
- })]
2256
- })
2750
+ children: [showPanel && /* @__PURE__ */ jsx(FeedbackPanel, {
2751
+ categories,
2752
+ labels,
2753
+ state
2754
+ }), /* @__PURE__ */ jsx(Launcher, {
2755
+ isOpen,
2756
+ label: labels.trigger,
2757
+ onClick: isOpen ? close : open
2758
+ })]
2257
2759
  }),
2258
2760
  isOpen && state.step === "annotate" && state.capture && /* @__PURE__ */ jsx(Annotator, {
2259
2761
  annotations: state.annotations,