reflet-sdk 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/feedback.js CHANGED
@@ -94,6 +94,9 @@ const FORWARD_REF_TYPE = Symbol.for("react.forward_ref");
94
94
  const MAX_STACK_DEPTH = 6;
95
95
  const MAX_FIBER_WALK = 60;
96
96
  const MAX_OWNER_WALK = 8;
97
+ const MIN_NAME_LENGTH = 4;
98
+ const CAPITALIZED = /^[A-Z]/;
99
+ const WORD_INSIDE = /[a-z]{2}/;
97
100
  const ORIGIN_PREFIX = /^[a-z][\w+.-]*:\/\/[^/]*/i;
98
101
  const WEBPACK_PREFIX = /^webpack-internal:\/{2,}(\.\/)?/;
99
102
  const STACK_LOCATION = /([^\s()]+):(\d+):(\d+)\)?\s*$/;
@@ -122,6 +125,14 @@ function readName(value) {
122
125
  const name = Reflect.get(value, "name");
123
126
  return typeof name === "string" && name ? name : null;
124
127
  }
128
+ /**
129
+ * Minified bundles hand out names like `Ur` or `Sn`. Reporting those as the
130
+ * owning component actively misleads an agent, so only real words get through.
131
+ */
132
+ function isMeaningfulName(name) {
133
+ if (name.includes("/") || name.includes("_")) return true;
134
+ return name.length >= MIN_NAME_LENGTH && CAPITALIZED.test(name) && WORD_INSIDE.test(name);
135
+ }
125
136
  function componentName(type, depth = 0) {
126
137
  if (typeof type === "string" || type === null || depth > 3) return null;
127
138
  if (isRecord(type)) {
@@ -130,8 +141,7 @@ function componentName(type, depth = 0) {
130
141
  if (marker === FORWARD_REF_TYPE) return componentName(Reflect.get(type, "render"), depth + 1);
131
142
  }
132
143
  const name = readName(type);
133
- if (!name || name.length < 2) return null;
134
- return name[0] === name[0]?.toUpperCase() || name.includes("/") ? name : null;
144
+ return name && isMeaningfulName(name) ? name : null;
135
145
  }
136
146
  /** Component names owning the fiber, innermost first. */
137
147
  function resolveComponentStack(fiber) {
@@ -205,14 +215,131 @@ function inspectReactElement(element) {
205
215
  };
206
216
  }
207
217
  //#endregion
218
+ //#region src/feedback/core/redact.ts
219
+ /**
220
+ * The picked element's markup leaves the reporter's browser and ends up in an
221
+ * agent prompt, so it is scrubbed here: typed-in values, credentials and
222
+ * anything marked `data-reflet-redact` never make the trip.
223
+ */
224
+ const EMAIL = /[\w.%+-]+@[\w.-]+\.[a-z]{2,}/gi;
225
+ const LONG_TOKEN = /\b[\w-]{28,}\b/g;
226
+ const OPAQUE_URI = /^(?:data|blob|javascript):/i;
227
+ const WHITESPACE_RUN = /\s+/g;
228
+ const URL_QUERY = /[?#]/;
229
+ const REDACTED = "[redacted]";
230
+ const ELLIPSIS = "…";
231
+ const MAX_ATTRIBUTE_LENGTH = 120;
232
+ const MAX_TEXT_LENGTH = 200;
233
+ const MAX_CHILD_DEPTH = 3;
234
+ const MAX_CLONED_NODES = 400;
235
+ const DROPPED_TAGS = /* @__PURE__ */ new Set([
236
+ "script",
237
+ "style",
238
+ "noscript",
239
+ "template"
240
+ ]);
241
+ const VALUE_HOLDERS = /* @__PURE__ */ new Set([
242
+ "input",
243
+ "textarea",
244
+ "select",
245
+ "option"
246
+ ]);
247
+ const URL_ATTRIBUTES = /* @__PURE__ */ new Set([
248
+ "src",
249
+ "srcset",
250
+ "href",
251
+ "poster",
252
+ "action",
253
+ "formaction"
254
+ ]);
255
+ function truncate$1(value, max) {
256
+ return value.length <= max ? value : `${value.slice(0, max - 1)}${ELLIPSIS}`;
257
+ }
258
+ /** Emails and token-shaped strings, wherever they appear in reported text. */
259
+ function maskSecrets(value) {
260
+ return value.replace(EMAIL, REDACTED).replace(LONG_TOKEN, REDACTED);
261
+ }
262
+ function sanitizeUrl(value) {
263
+ if (OPAQUE_URI.test(value)) return `${value.slice(0, value.indexOf(":"))}:${ELLIPSIS}`;
264
+ return value.split(URL_QUERY)[0] ?? value;
265
+ }
266
+ function sanitizeAttributes(element) {
267
+ const holdsTypedValue = VALUE_HOLDERS.has(element.localName);
268
+ for (const { name, value } of Array.from(element.attributes)) {
269
+ if (holdsTypedValue && name === "value") {
270
+ element.removeAttribute(name);
271
+ continue;
272
+ }
273
+ const cleaned = URL_ATTRIBUTES.has(name) ? sanitizeUrl(value) : maskSecrets(value);
274
+ element.setAttribute(name, truncate$1(cleaned, MAX_ATTRIBUTE_LENGTH));
275
+ }
276
+ }
277
+ function collapse(element) {
278
+ element.replaceChildren(element.ownerDocument.createTextNode(ELLIPSIS));
279
+ }
280
+ function sanitizeTree(element, depth) {
281
+ sanitizeAttributes(element);
282
+ if (element.hasAttribute("data-reflet-redact")) {
283
+ element.replaceChildren(element.ownerDocument.createTextNode(REDACTED));
284
+ return;
285
+ }
286
+ if (element.localName === "svg") {
287
+ element.replaceChildren();
288
+ return;
289
+ }
290
+ for (const child of Array.from(element.childNodes)) {
291
+ if (child.nodeType === Node.COMMENT_NODE) {
292
+ child.remove();
293
+ continue;
294
+ }
295
+ if (child.nodeType === Node.TEXT_NODE) {
296
+ child.textContent = truncate$1(maskSecrets(child.textContent ?? ""), MAX_TEXT_LENGTH);
297
+ continue;
298
+ }
299
+ if (!(child instanceof Element)) continue;
300
+ if (DROPPED_TAGS.has(child.localName)) {
301
+ child.remove();
302
+ continue;
303
+ }
304
+ if (depth >= MAX_CHILD_DEPTH) {
305
+ sanitizeAttributes(child);
306
+ collapse(child);
307
+ continue;
308
+ }
309
+ sanitizeTree(child, depth + 1);
310
+ }
311
+ }
312
+ /** Redacted `outerHTML`, shallow-cloned when the subtree is too large to be useful. */
313
+ function sanitizeMarkup(element, maxLength) {
314
+ const isSmallEnough = element.querySelectorAll("*").length <= MAX_CLONED_NODES;
315
+ const clone = element.cloneNode(isSmallEnough);
316
+ if (!(clone instanceof Element)) return "";
317
+ sanitizeTree(clone, 0);
318
+ if (!isSmallEnough) collapse(clone);
319
+ return truncate$1(clone.outerHTML.replace(WHITESPACE_RUN, " "), maxLength);
320
+ }
321
+ //#endregion
208
322
  //#region src/feedback/core/element-selector.ts
209
323
  const SAFE_ID = /^[A-Za-z][\w-]*$/;
210
324
  const WHITESPACE = /\s+/g;
211
325
  const MAX_LABEL_LENGTH = 80;
212
326
  const MAX_HTML_LENGTH = 600;
327
+ const MAX_REGION_LENGTH = 160;
328
+ const MAX_HANDLE_VALUE_LENGTH = 80;
213
329
  const MAX_DEPTH = 12;
330
+ const MIN_PATH_SEGMENTS = 2;
214
331
  const MAX_SELECTOR_LENGTH = 600;
215
332
  const MAX_SOURCE_LOCATION_LENGTH = 400;
333
+ const TEST_ATTRIBUTES = [
334
+ "data-testid",
335
+ "data-test-id",
336
+ "data-test",
337
+ "data-cy",
338
+ "data-qa"
339
+ ];
340
+ const NAMING_ATTRIBUTES = ["name", "aria-label"];
341
+ const LANDMARK_SELECTOR = "dialog, [role=\"dialog\"], [role=\"alertdialog\"], main, nav, aside, header, footer, form, section, article";
342
+ const HEADING_SELECTOR = "h1, h2, h3, h4, h5, h6, [role=\"heading\"]";
216
343
  function isUnique(selector) {
217
344
  try {
218
345
  return document.querySelectorAll(selector).length === 1;
@@ -223,13 +350,23 @@ function isUnique(selector) {
223
350
  function escapeAttributeValue(value) {
224
351
  return value.replace(/["\\]/g, "\\$&");
225
352
  }
353
+ function attributeHandle(element, attribute, qualifier) {
354
+ const value = element.getAttribute(attribute);
355
+ if (!value || value.length > MAX_HANDLE_VALUE_LENGTH) return null;
356
+ const selector = `${qualifier}[${attribute}="${escapeAttributeValue(value)}"]`;
357
+ return isUnique(selector) ? selector : null;
358
+ }
359
+ /** Something an engineer would have written on purpose, unlike a positional path. */
226
360
  function uniqueHandle(element) {
227
361
  const id = element.getAttribute("id");
228
362
  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;
363
+ for (const attribute of TEST_ATTRIBUTES) {
364
+ const handle = attributeHandle(element, attribute, "");
365
+ if (handle) return handle;
366
+ }
367
+ for (const attribute of NAMING_ATTRIBUTES) {
368
+ const handle = attributeHandle(element, attribute, element.localName);
369
+ if (handle) return handle;
233
370
  }
234
371
  return null;
235
372
  }
@@ -243,6 +380,9 @@ function positionalSegment(element) {
243
380
  }
244
381
  /**
245
382
  * Shortest selector that still resolves back to `element` from the document.
383
+ * Grows the path one ancestor at a time and stops as soon as it is unique, so
384
+ * a stable handle anywhere up the tree replaces the whole positional chain.
385
+ * A bare tag is never enough: positional paths keep a parent for context.
246
386
  */
247
387
  function buildSelector(element) {
248
388
  if (element === document.documentElement) return "html";
@@ -251,43 +391,58 @@ function buildSelector(element) {
251
391
  if (handle) return handle;
252
392
  const segments = [];
253
393
  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
- }
394
+ for (let depth = 0; current && depth < MAX_DEPTH; depth++) {
264
395
  const ancestorHandle = current === element ? null : uniqueHandle(current);
265
- if (ancestorHandle) {
266
- segments.unshift(ancestorHandle);
267
- break;
268
- }
269
- segments.unshift(positionalSegment(current));
396
+ segments.unshift(ancestorHandle ?? positionalSegment(current));
397
+ const candidate = segments.join(" > ");
398
+ const isAnchored = segments.length >= MIN_PATH_SEGMENTS;
399
+ if (ancestorHandle || current === document.body || isAnchored && isUnique(candidate)) return candidate;
270
400
  current = current.parentElement;
271
- depth++;
272
401
  }
273
402
  return segments.join(" > ");
274
403
  }
275
404
  function truncate(value, max) {
276
405
  return value.length <= max ? value : `${value.slice(0, max - 1)}…`;
277
406
  }
407
+ function collapseText(value) {
408
+ return value.replace(WHITESPACE, " ").trim();
409
+ }
278
410
  function accessibleLabel(element) {
279
411
  const explicit = element.getAttribute("aria-label") ?? element.getAttribute("title") ?? element.getAttribute("alt") ?? element.getAttribute("placeholder");
280
412
  if (explicit?.trim()) return explicit.trim();
281
- return (element.textContent ?? "").replace(WHITESPACE, " ").trim();
413
+ return collapseText(element.textContent ?? "");
282
414
  }
283
415
  /** Short human label such as `button "Sign in"` or `div.card`. */
284
416
  function describeElement(element) {
285
417
  const tag = element.localName;
286
- const label = accessibleLabel(element);
418
+ const label = maskSecrets(accessibleLabel(element));
287
419
  if (label) return truncate(`${tag} "${label}"`, MAX_LABEL_LENGTH);
288
420
  const firstClass = element.classList.item(0);
289
421
  return truncate(firstClass ? `${tag}.${firstClass}` : tag, MAX_LABEL_LENGTH);
290
422
  }
423
+ function describeLandmark(element) {
424
+ const landmark = element.parentElement?.closest(LANDMARK_SELECTOR);
425
+ if (!landmark) return null;
426
+ const name = landmark.getAttribute("aria-label") ?? landmark.getAttribute("title");
427
+ return name?.trim() ? `${landmark.localName} "${collapseText(name)}"` : landmark.localName;
428
+ }
429
+ function headingAbove(element) {
430
+ let closest = null;
431
+ for (const heading of document.querySelectorAll(HEADING_SELECTOR)) if ((heading.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0) closest = heading;
432
+ return collapseText(closest?.textContent ?? "") || null;
433
+ }
434
+ /**
435
+ * Where the element sits from a reader's point of view — `dialog "Members" ›
436
+ * Danger zone` says more to an agent than any DOM path can.
437
+ */
438
+ function describeRegion(element) {
439
+ const landmark = describeLandmark(element);
440
+ const heading = headingAbove(element);
441
+ const parts = [landmark];
442
+ if (heading && !landmark?.includes(`"${heading}"`)) parts.push(heading);
443
+ const region = parts.filter((part) => part !== null).join(" › ");
444
+ return region ? truncate(maskSecrets(region), MAX_REGION_LENGTH) : void 0;
445
+ }
291
446
  function getElementRect(element) {
292
447
  const rect = element.getBoundingClientRect();
293
448
  return {
@@ -302,9 +457,10 @@ function buildElementSelection(element) {
302
457
  const { componentStack, sourceLocation } = inspectReactElement(element);
303
458
  return {
304
459
  componentStack,
305
- html: truncate(element.outerHTML, MAX_HTML_LENGTH),
460
+ html: sanitizeMarkup(element, MAX_HTML_LENGTH),
306
461
  label: describeElement(element),
307
462
  rect: getElementRect(element),
463
+ region: describeRegion(element),
308
464
  selector: truncate(buildSelector(element), MAX_SELECTOR_LENGTH),
309
465
  sourceLocation: sourceLocation ? truncate(sourceLocation, MAX_SOURCE_LOCATION_LENGTH) : sourceLocation
310
466
  };
@@ -396,8 +552,8 @@ function collectPageContext(options) {
396
552
  }
397
553
  //#endregion
398
554
  //#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";
555
+ /** Reported with every submission. */
556
+ const SDK_VERSION = "0.3.0";
401
557
  const DEFAULT_WIDGET_LABELS = {
402
558
  annotateHint: "Draw on the screenshot to point at the problem.",
403
559
  attachScreenshot: "Attach a screenshot",
@@ -657,9 +813,10 @@ async function renderAnnotatedImage(source, annotations) {
657
813
  }
658
814
  //#endregion
659
815
  //#region src/feedback/ui/icons.tsx
660
- function Svg({ children, size = 16 }) {
816
+ function Svg({ children, className, size = 16 }) {
661
817
  return /* @__PURE__ */ jsx("svg", {
662
818
  "aria-hidden": "true",
819
+ className,
663
820
  fill: "none",
664
821
  height: size,
665
822
  stroke: "currentColor",
@@ -671,14 +828,18 @@ function Svg({ children, size = 16 }) {
671
828
  children
672
829
  });
673
830
  }
674
- function ChatIcon() {
831
+ function ChatIcon({ className }) {
675
832
  return /* @__PURE__ */ jsx(Svg, {
676
- size: 18,
833
+ className,
834
+ size: 15,
677
835
  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
836
  });
679
837
  }
680
- function CloseIcon() {
681
- return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M18 6 6 18M6 6l12 12" }) });
838
+ function CloseIcon({ className }) {
839
+ return /* @__PURE__ */ jsx(Svg, {
840
+ className,
841
+ children: /* @__PURE__ */ jsx("path", { d: "M18 6 6 18M6 6l12 12" })
842
+ });
682
843
  }
683
844
  function BugIcon() {
684
845
  return /* @__PURE__ */ jsx(Svg, {
@@ -941,15 +1102,21 @@ function Annotator({ annotations, capture, labels, onChange, onDone }) {
941
1102
  }
942
1103
  //#endregion
943
1104
  //#region src/feedback/ui/launcher.tsx
944
- function Launcher({ compact, isOpen, label, onClick }) {
1105
+ /**
1106
+ * Never changes size between states — the panel opening must not move the
1107
+ * button out from under the pointer.
1108
+ */
1109
+ function Launcher({ isOpen, label, onClick }) {
945
1110
  return /* @__PURE__ */ jsxs("button", {
946
1111
  "aria-expanded": isOpen,
947
- "aria-label": label,
948
1112
  className: "launcher",
949
- "data-compact": compact || isOpen ? "true" : "false",
1113
+ "data-open": isOpen,
950
1114
  onClick,
951
1115
  type: "button",
952
- children: [isOpen ? /* @__PURE__ */ jsx(CloseIcon, {}) : /* @__PURE__ */ jsx(ChatIcon, {}), !(compact || isOpen) && label]
1116
+ children: [/* @__PURE__ */ jsxs("span", {
1117
+ className: "launcher-icon",
1118
+ children: [/* @__PURE__ */ jsx(ChatIcon, { className: "on-open" }), /* @__PURE__ */ jsx(CloseIcon, { className: "on-close" })]
1119
+ }), label]
953
1120
  });
954
1121
  }
955
1122
  //#endregion
@@ -970,7 +1137,7 @@ const CATEGORY_META = {
970
1137
  };
971
1138
  function Attachment({ labels, state }) {
972
1139
  if (state.isCapturing) return /* @__PURE__ */ jsxs("div", {
973
- className: "attachment",
1140
+ className: "card",
974
1141
  children: [/* @__PURE__ */ jsx("div", { className: "thumb" }), /* @__PURE__ */ jsxs("div", {
975
1142
  className: "meta",
976
1143
  children: [/* @__PURE__ */ jsx("strong", { children: labels.retakeHint }), /* @__PURE__ */ jsx("span", { children: "…" })]
@@ -984,7 +1151,7 @@ function Attachment({ labels, state }) {
984
1151
  });
985
1152
  const annotationCount = state.annotations.length;
986
1153
  return /* @__PURE__ */ jsxs("div", {
987
- className: "attachment",
1154
+ className: "card",
988
1155
  children: [
989
1156
  /* @__PURE__ */ jsx("div", {
990
1157
  className: "thumb",
@@ -997,12 +1164,18 @@ function Attachment({ labels, state }) {
997
1164
  }),
998
1165
  /* @__PURE__ */ jsxs("div", {
999
1166
  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
- ] })]
1167
+ children: [/* @__PURE__ */ jsx("strong", {
1168
+ className: "truncate",
1169
+ children: labels.retakeHint
1170
+ }), /* @__PURE__ */ jsxs("span", {
1171
+ className: "truncate",
1172
+ children: [
1173
+ state.capture.width,
1174
+ "×",
1175
+ state.capture.height,
1176
+ annotationCount > 0 ? ` · ${annotationCount} marks` : ""
1177
+ ]
1178
+ })]
1006
1179
  }),
1007
1180
  /* @__PURE__ */ jsxs("div", {
1008
1181
  className: "actions",
@@ -1036,8 +1209,9 @@ function Attachment({ labels, state }) {
1036
1209
  ]
1037
1210
  });
1038
1211
  }
1039
- function SelectionChip({ labels, state }) {
1040
- const { selection } = state;
1212
+ /** Shows the reporter exactly what will be sent about the element they picked. */
1213
+ function Selection({ labels, state }) {
1214
+ const { elementCapture, selection } = state;
1041
1215
  if (!selection) return /* @__PURE__ */ jsxs("button", {
1042
1216
  className: "ghost-btn",
1043
1217
  onClick: () => state.setStep("picking"),
@@ -1045,24 +1219,45 @@ function SelectionChip({ labels, state }) {
1045
1219
  children: [/* @__PURE__ */ jsx(TargetIcon, {}), labels.pickElement]
1046
1220
  });
1047
1221
  const component = selection.componentStack[0];
1048
- return /* @__PURE__ */ jsxs("span", {
1049
- className: "chip",
1222
+ return /* @__PURE__ */ jsxs("div", {
1223
+ className: "card",
1050
1224
  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
- ]
1225
+ /* @__PURE__ */ jsx("div", {
1226
+ className: "thumb contain",
1227
+ children: elementCapture && /* @__PURE__ */ jsx("img", {
1228
+ alt: "",
1229
+ height: elementCapture.height,
1230
+ src: elementCapture.objectUrl,
1231
+ width: elementCapture.width
1232
+ })
1233
+ }),
1234
+ /* @__PURE__ */ jsxs("div", {
1235
+ className: "meta",
1236
+ children: [/* @__PURE__ */ jsx("strong", {
1237
+ className: "truncate",
1238
+ children: component ? `<${component}>` : selection.label
1239
+ }), /* @__PURE__ */ jsx("span", {
1240
+ className: "truncate",
1241
+ children: selection.region ?? selection.sourceLocation ?? selection.selector
1242
+ })]
1059
1243
  }),
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, {})
1244
+ /* @__PURE__ */ jsxs("div", {
1245
+ className: "actions",
1246
+ children: [/* @__PURE__ */ jsx("button", {
1247
+ "aria-label": labels.pickElement,
1248
+ className: "icon-btn",
1249
+ onClick: () => state.setStep("picking"),
1250
+ title: labels.pickElement,
1251
+ type: "button",
1252
+ children: /* @__PURE__ */ jsx(TargetIcon, {})
1253
+ }), /* @__PURE__ */ jsx("button", {
1254
+ "aria-label": labels.cancel,
1255
+ className: "icon-btn",
1256
+ onClick: state.clearSelection,
1257
+ title: labels.cancel,
1258
+ type: "button",
1259
+ children: /* @__PURE__ */ jsx(CloseIcon, {})
1260
+ })]
1066
1261
  })
1067
1262
  ]
1068
1263
  });
@@ -1129,13 +1324,15 @@ function FeedbackPanel({ categories, labels, state }) {
1129
1324
  ref: messageRef,
1130
1325
  value: state.message
1131
1326
  }),
1132
- /* @__PURE__ */ jsx(Attachment, {
1133
- labels,
1134
- state
1135
- }),
1136
- /* @__PURE__ */ jsx(SelectionChip, {
1137
- labels,
1138
- state
1327
+ /* @__PURE__ */ jsxs("div", {
1328
+ className: "attachments",
1329
+ children: [/* @__PURE__ */ jsx(Attachment, {
1330
+ labels,
1331
+ state
1332
+ }), /* @__PURE__ */ jsx(Selection, {
1333
+ labels,
1334
+ state
1335
+ })]
1139
1336
  }),
1140
1337
  state.isAnonymous && /* @__PURE__ */ jsx("input", {
1141
1338
  "aria-label": labels.emailLabel,
@@ -1192,7 +1389,7 @@ function FeedbackPanel({ categories, labels, state }) {
1192
1389
  }
1193
1390
  //#endregion
1194
1391
  //#region src/feedback/ui/picker.tsx
1195
- const LABEL_HEIGHT = 28;
1392
+ const LABEL_HEIGHT = 24;
1196
1393
  const LABEL_GAP = 6;
1197
1394
  function isWidgetOwned(element) {
1198
1395
  return element.closest(`[${WIDGET_MARKER}]`) !== null;
@@ -1205,14 +1402,15 @@ function elementUnder(x, y) {
1205
1402
  function describeTarget(element) {
1206
1403
  return {
1207
1404
  componentName: resolveComponentStack(getFiberFromNode(element))[0],
1405
+ element,
1208
1406
  label: describeElement(element),
1209
1407
  rect: element.getBoundingClientRect(),
1210
- selector: buildSelector(element)
1408
+ region: describeRegion(element)
1211
1409
  };
1212
1410
  }
1213
1411
  /**
1214
1412
  * 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.
1413
+ * component that owns it plus the page region it lives in.
1216
1414
  */
1217
1415
  function ElementPicker({ hint, onCancel, onPick }) {
1218
1416
  const [target, setTarget] = useState(null);
@@ -1223,7 +1421,14 @@ function ElementPicker({ hint, onCancel, onPick }) {
1223
1421
  };
1224
1422
  const onMove = (event) => {
1225
1423
  const element = elementUnder(event.clientX, event.clientY);
1226
- setTarget(element ? describeTarget(element) : null);
1424
+ if (!element) {
1425
+ setTarget(null);
1426
+ return;
1427
+ }
1428
+ setTarget((current) => current?.element === element ? {
1429
+ ...current,
1430
+ rect: element.getBoundingClientRect()
1431
+ } : describeTarget(element));
1227
1432
  };
1228
1433
  const onClick = (event) => {
1229
1434
  swallow(event);
@@ -1255,7 +1460,7 @@ function ElementPicker({ hint, onCancel, onPick }) {
1255
1460
  };
1256
1461
  }, [onCancel, onPick]);
1257
1462
  const rect = target?.rect;
1258
- const labelAbove = rect ? rect.top > 34 : true;
1463
+ const labelAbove = rect ? rect.top > 30 : true;
1259
1464
  return /* @__PURE__ */ jsxs("div", { children: [
1260
1465
  rect && /* @__PURE__ */ jsx("div", {
1261
1466
  className: "picker-box",
@@ -1272,7 +1477,7 @@ function ElementPicker({ hint, onCancel, onPick }) {
1272
1477
  left: Math.max(4, rect.left),
1273
1478
  top: labelAbove ? rect.top - LABEL_HEIGHT - LABEL_GAP : rect.bottom + LABEL_GAP
1274
1479
  },
1275
- children: [target.componentName ? /* @__PURE__ */ jsx("strong", { children: `<${target.componentName}>` }) : /* @__PURE__ */ jsx("strong", { children: target.label }), /* @__PURE__ */ jsx("code", { children: target.selector })]
1480
+ children: [/* @__PURE__ */ jsx("strong", { children: target.componentName ? `<${target.componentName}>` : target.label }), target.region && /* @__PURE__ */ jsx("span", { children: target.region })]
1276
1481
  }),
1277
1482
  /* @__PURE__ */ jsxs("div", {
1278
1483
  className: "picker-hint",
@@ -1293,30 +1498,42 @@ function ElementPicker({ hint, onCancel, onPick }) {
1293
1498
  const WIDGET_STYLES = `
1294
1499
  :host {
1295
1500
  --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;
1501
+ --rf-bg-subtle: rgb(55 53 47 / 4%);
1502
+ --rf-bg-hover: rgb(55 53 47 / 7%);
1503
+ --rf-border: rgb(55 53 47 / 9%);
1504
+ --rf-text: #37352f;
1505
+ --rf-text-muted: rgb(55 53 47 / 55%);
1506
+ --rf-primary: #37352f;
1301
1507
  --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);
1508
+ --rf-accent: #2383e2;
1509
+ --rf-danger: #d44c47;
1510
+ --rf-success: #448361;
1511
+ --rf-radius: 12px;
1512
+ --rf-radius-sm: 8px;
1513
+ --rf-shadow-inset: inset 0 0 0 1px var(--rf-border);
1514
+ --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%);
1515
+ --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%);
1516
+ --rf-ring: 0 0 0 3px color-mix(in srgb, var(--rf-accent) 20%, transparent);
1517
+ --rf-ease: cubic-bezier(0.16, 1, 0.3, 1);
1308
1518
  color-scheme: light;
1309
1519
  all: initial;
1310
1520
  font-family: var(--rf-font, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif);
1311
1521
  }
1312
1522
 
1313
1523
  :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%);
1524
+ --rf-bg: #202020;
1525
+ --rf-bg-subtle: rgb(255 255 255 / 5%);
1526
+ --rf-bg-hover: rgb(255 255 255 / 9%);
1527
+ --rf-border: rgb(255 255 255 / 9%);
1528
+ --rf-text: #ebeae8;
1529
+ --rf-text-muted: rgb(255 255 255 / 46%);
1530
+ --rf-primary: #f1efec;
1531
+ --rf-primary-text: #1c1c1b;
1532
+ --rf-accent: #529cca;
1533
+ --rf-danger: #eb5757;
1534
+ --rf-success: #4dab74;
1535
+ --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%);
1536
+ --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
1537
  color-scheme: dark;
1321
1538
  }
1322
1539
 
@@ -1325,19 +1542,19 @@ const WIDGET_STYLES = `
1325
1542
  .root {
1326
1543
  position: fixed;
1327
1544
  z-index: 2147483000;
1545
+ display: flex;
1546
+ flex-direction: column;
1547
+ gap: 10px;
1328
1548
  font-family: inherit;
1329
- font-size: 14px;
1549
+ font-size: 13.5px;
1330
1550
  line-height: 1.45;
1331
1551
  color: var(--rf-text);
1332
1552
  }
1333
1553
 
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; }
1554
+ .root[data-position$="right"] { right: var(--rf-offset); align-items: flex-end; }
1555
+ .root[data-position$="left"] { left: var(--rf-offset); align-items: flex-start; }
1556
+ .root[data-position^="bottom"] { bottom: var(--rf-offset); }
1557
+ .root[data-position^="top"] { top: var(--rf-offset); flex-direction: column-reverse; }
1341
1558
 
1342
1559
  button {
1343
1560
  font: inherit;
@@ -1353,64 +1570,76 @@ button {
1353
1570
  .launcher {
1354
1571
  display: inline-flex;
1355
1572
  align-items: center;
1356
- gap: 8px;
1357
- height: 44px;
1358
- padding: 0 16px;
1359
- border-radius: 999px;
1573
+ gap: 6px;
1574
+ height: 32px;
1575
+ padding: 0 11px;
1576
+ border-radius: var(--rf-radius-sm);
1360
1577
  background: var(--rf-primary);
1361
1578
  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);
1579
+ font-size: 13px;
1580
+ font-weight: 500;
1581
+ letter-spacing: -0.005em;
1582
+ box-shadow: var(--rf-shadow-low);
1583
+ transition: transform 240ms var(--rf-ease), box-shadow 240ms var(--rf-ease), opacity 160ms var(--rf-ease);
1365
1584
  }
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; }
1585
+ .launcher:hover { transform: translateY(-1px); box-shadow: var(--rf-shadow); }
1586
+ .launcher:active { transform: translateY(0) scale(0.98); }
1587
+
1588
+ .launcher-icon { position: relative; width: 15px; height: 15px; flex: none; }
1589
+ .launcher-icon svg { position: absolute; inset: 0; width: 100%; height: 100%; transition: opacity 200ms var(--rf-ease), transform 320ms var(--rf-ease); }
1590
+ .launcher-icon .on-close { opacity: 0; transform: rotate(-90deg) scale(0.6); }
1591
+ .launcher[data-open="true"] .on-open { opacity: 0; transform: rotate(90deg) scale(0.6); }
1592
+ .launcher[data-open="true"] .on-close { opacity: 1; transform: none; }
1369
1593
 
1370
1594
  .panel {
1371
- width: min(384px, calc(100vw - 32px));
1372
- max-height: min(620px, calc(100vh - 96px));
1595
+ width: min(352px, calc(100vw - 24px));
1596
+ max-height: min(600px, calc(100vh - 96px));
1373
1597
  display: flex;
1374
1598
  flex-direction: column;
1375
1599
  overflow: hidden;
1376
1600
  background: var(--rf-bg);
1377
- border: 1px solid var(--rf-border);
1378
1601
  border-radius: var(--rf-radius);
1379
1602
  box-shadow: var(--rf-shadow);
1380
1603
  animation: rf-in 220ms var(--rf-ease);
1381
1604
  }
1605
+ .root[data-position^="bottom"] .panel { transform-origin: bottom center; }
1606
+ .root[data-position^="top"] .panel { transform-origin: top center; animation-name: rf-in-top; }
1382
1607
 
1383
1608
  @keyframes rf-in {
1384
- from { opacity: 0; transform: translateY(8px) scale(0.98); }
1385
- to { opacity: 1; transform: none; }
1609
+ from { opacity: 0; transform: translateY(6px) scale(0.985); }
1610
+ }
1611
+ @keyframes rf-in-top {
1612
+ from { opacity: 0; transform: translateY(-6px) scale(0.985); }
1386
1613
  }
1614
+ @keyframes rf-fade { from { opacity: 0; } }
1387
1615
 
1388
1616
  @media (prefers-reduced-motion: reduce) {
1389
- .panel, .launcher { animation: none; transition: none; }
1617
+ *, *::before, *::after { animation: none !important; transition: none !important; }
1390
1618
  }
1391
1619
 
1392
1620
  .head {
1393
1621
  display: flex;
1394
1622
  align-items: center;
1395
1623
  gap: 8px;
1396
- padding: 14px 14px 10px;
1624
+ padding: 12px 10px 4px 14px;
1397
1625
  }
1398
- .head h2 { margin: 0; font-size: 15px; font-weight: 600; }
1626
+ .head h2 { margin: 0; font-size: 13.5px; font-weight: 600; letter-spacing: -0.01em; }
1399
1627
  .head .spacer { flex: 1; }
1400
1628
 
1401
1629
  .icon-btn {
1402
1630
  display: inline-flex;
1403
1631
  align-items: center;
1404
1632
  justify-content: center;
1405
- width: 30px;
1406
- height: 30px;
1407
- border-radius: 8px;
1633
+ width: 26px;
1634
+ height: 26px;
1635
+ flex: none;
1636
+ border-radius: 6px;
1408
1637
  color: var(--rf-text-muted);
1409
1638
  transition: background 120ms var(--rf-ease), color 120ms var(--rf-ease);
1410
1639
  }
1411
- .icon-btn:hover { background: var(--rf-bg-subtle); color: var(--rf-text); }
1640
+ .icon-btn:hover { background: var(--rf-bg-hover); color: var(--rf-text); }
1412
1641
 
1413
- .body { display: flex; flex-direction: column; gap: 12px; padding: 0 14px 14px; overflow-y: auto; }
1642
+ .body { display: flex; flex-direction: column; gap: 8px; padding: 6px 14px 14px; overflow-y: auto; }
1414
1643
 
1415
1644
  .segmented {
1416
1645
  border: 0;
@@ -1419,94 +1648,88 @@ button {
1419
1648
  display: grid;
1420
1649
  grid-auto-flow: column;
1421
1650
  grid-auto-columns: 1fr;
1422
- gap: 4px;
1423
- padding: 4px;
1651
+ gap: 2px;
1652
+ padding: 2px;
1424
1653
  background: var(--rf-bg-subtle);
1425
- border-radius: 10px;
1654
+ border-radius: var(--rf-radius-sm);
1426
1655
  }
1427
1656
  .segmented button {
1428
1657
  display: flex;
1429
1658
  align-items: center;
1430
1659
  justify-content: center;
1431
- gap: 6px;
1432
- height: 32px;
1433
- border-radius: 7px;
1434
- font-size: 13px;
1660
+ gap: 5px;
1661
+ height: 26px;
1662
+ border-radius: 6px;
1663
+ font-size: 12.5px;
1435
1664
  font-weight: 500;
1436
1665
  color: var(--rf-text-muted);
1437
- transition: background 140ms var(--rf-ease), color 140ms var(--rf-ease);
1666
+ transition: background 160ms var(--rf-ease), color 160ms var(--rf-ease);
1438
1667
  }
1668
+ .segmented button:hover { color: var(--rf-text); }
1439
1669
  .segmented button[aria-pressed="true"] {
1440
1670
  background: var(--rf-bg);
1441
1671
  color: var(--rf-text);
1442
- box-shadow: 0 1px 2px rgb(16 24 40 / 8%);
1672
+ box-shadow: var(--rf-shadow-low);
1443
1673
  }
1444
1674
 
1445
1675
  textarea, input[type="email"] {
1446
1676
  width: 100%;
1447
- padding: 10px 12px;
1677
+ padding: 8px 10px;
1448
1678
  font: inherit;
1449
1679
  color: var(--rf-text);
1450
1680
  background: var(--rf-bg);
1451
1681
  border: 1px solid var(--rf-border);
1452
- border-radius: 10px;
1453
- transition: border-color 140ms var(--rf-ease), box-shadow 140ms var(--rf-ease);
1682
+ border-radius: var(--rf-radius-sm);
1683
+ transition: border-color 160ms var(--rf-ease), box-shadow 160ms var(--rf-ease);
1454
1684
  }
1455
- textarea { min-height: 92px; max-height: 220px; resize: none; }
1685
+ textarea { min-height: 84px; max-height: 220px; resize: none; }
1456
1686
  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); }
1687
+ textarea:focus, input:focus { outline: none; border-color: color-mix(in srgb, var(--rf-accent) 55%, transparent); box-shadow: var(--rf-ring); }
1688
+
1689
+ .attachments { display: flex; flex-wrap: wrap; gap: 6px; }
1458
1690
 
1459
- .attachment {
1691
+ .card {
1692
+ flex: 1 0 100%;
1460
1693
  display: flex;
1694
+ align-items: center;
1461
1695
  gap: 10px;
1462
- padding: 8px;
1463
- border: 1px solid var(--rf-border);
1696
+ min-width: 0;
1697
+ padding: 6px;
1464
1698
  border-radius: 10px;
1465
1699
  background: var(--rf-bg-subtle);
1700
+ animation: rf-fade 200ms var(--rf-ease);
1466
1701
  }
1467
1702
  .thumb {
1468
1703
  position: relative;
1469
- flex: 0 0 84px;
1470
- height: 56px;
1704
+ flex: 0 0 60px;
1705
+ height: 42px;
1471
1706
  overflow: hidden;
1472
1707
  border-radius: 6px;
1473
- border: 1px solid var(--rf-border);
1474
1708
  background: var(--rf-bg);
1709
+ box-shadow: var(--rf-shadow-inset);
1475
1710
  }
1476
1711
  .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; }
1712
+ .thumb.contain img { object-fit: contain; object-position: center; }
1713
+ .card .meta { display: flex; flex-direction: column; justify-content: center; gap: 1px; min-width: 0; }
1714
+ .card .meta strong { font-size: 12.5px; font-weight: 550; }
1715
+ .card .meta span { font-size: 11.5px; color: var(--rf-text-muted); }
1716
+ .card .actions { display: flex; gap: 2px; margin-left: auto; align-items: center; }
1717
+ .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1718
+ .card code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; }
1496
1719
 
1497
- .row { display: flex; gap: 8px; align-items: center; }
1498
1720
  .ghost-btn {
1721
+ flex: 0 0 auto;
1499
1722
  display: inline-flex;
1500
1723
  align-items: center;
1501
1724
  gap: 6px;
1502
- height: 32px;
1725
+ height: 30px;
1503
1726
  padding: 0 10px;
1504
- font-size: 13px;
1727
+ font-size: 12.5px;
1505
1728
  font-weight: 500;
1506
1729
  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);
1730
+ border-radius: var(--rf-radius-sm);
1731
+ box-shadow: var(--rf-shadow-inset);
1732
+ transition: background 160ms var(--rf-ease), color 160ms var(--rf-ease);
1510
1733
  }
1511
1734
  .ghost-btn:hover { background: var(--rf-bg-subtle); color: var(--rf-text); }
1512
1735
 
@@ -1516,53 +1739,55 @@ textarea:focus, input:focus { outline: none; border-color: var(--rf-primary); bo
1516
1739
  justify-content: center;
1517
1740
  gap: 8px;
1518
1741
  width: 100%;
1519
- height: 38px;
1742
+ height: 32px;
1743
+ margin-top: 2px;
1744
+ font-size: 13px;
1520
1745
  font-weight: 550;
1521
1746
  color: var(--rf-primary-text);
1522
1747
  background: var(--rf-primary);
1523
- border-radius: 10px;
1524
- transition: filter 140ms var(--rf-ease);
1748
+ border-radius: var(--rf-radius-sm);
1749
+ transition: opacity 160ms var(--rf-ease), transform 160ms var(--rf-ease);
1525
1750
  }
1526
- .submit:hover:not(:disabled) { filter: brightness(1.06); }
1527
- .submit:disabled { opacity: 0.55; cursor: not-allowed; }
1751
+ .submit:hover:not(:disabled) { opacity: 0.88; }
1752
+ .submit:active:not(:disabled) { transform: scale(0.99); }
1753
+ .submit:disabled { opacity: 0.35; cursor: not-allowed; }
1528
1754
 
1529
- .error { margin: 0; font-size: 12.5px; color: var(--rf-danger); }
1530
- .hint { margin: 0; font-size: 12px; color: var(--rf-text-muted); }
1755
+ .error { margin: 0; font-size: 12px; color: var(--rf-danger); }
1531
1756
 
1532
1757
  .footer {
1533
- min-height: 44px;
1758
+ min-height: 34px;
1534
1759
  padding: 0 14px;
1535
1760
  border-top: 1px solid var(--rf-border);
1536
1761
  display: flex;
1537
1762
  align-items: center;
1538
1763
  justify-content: center;
1539
1764
  gap: 12px;
1540
- font-size: 11.5px;
1765
+ font-size: 11px;
1541
1766
  color: var(--rf-text-muted);
1542
1767
  }
1543
1768
  .footer[data-dismissible="true"] { justify-content: space-between; }
1544
- .footer a { color: inherit; text-decoration: none; font-weight: 550; }
1769
+ .footer a { color: inherit; text-decoration: none; font-weight: 550; transition: color 120ms var(--rf-ease); }
1545
1770
  .footer a:hover { color: var(--rf-text); }
1546
1771
  .dismiss-btn {
1547
1772
  align-self: stretch;
1548
1773
  color: inherit;
1549
- font-size: 11.5px;
1550
- text-decoration: underline;
1551
- text-underline-offset: 3px;
1774
+ font-size: 11px;
1775
+ transition: color 120ms var(--rf-ease);
1552
1776
  }
1553
1777
  .dismiss-btn:hover { color: var(--rf-text); }
1554
1778
 
1555
- .done { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 32px 20px 36px; text-align: center; }
1779
+ .done { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 30px 20px 34px; text-align: center; }
1556
1780
  .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); }
1781
+ .done h2 { margin: 0; font-size: 14px; font-weight: 600; }
1782
+ .done p { margin: 0; font-size: 12.5px; color: var(--rf-text-muted); }
1559
1783
 
1560
1784
  .spinner {
1561
- width: 15px;
1562
- height: 15px;
1785
+ width: 14px;
1786
+ height: 14px;
1563
1787
  border: 2px solid currentColor;
1564
1788
  border-right-color: transparent;
1565
1789
  border-radius: 50%;
1790
+ opacity: 0.6;
1566
1791
  animation: rf-spin 700ms linear infinite;
1567
1792
  }
1568
1793
  @keyframes rf-spin { to { transform: rotate(360deg); } }
@@ -1575,16 +1800,17 @@ textarea:focus, input:focus { outline: none; border-color: var(--rf-primary); bo
1575
1800
  z-index: 2147483001;
1576
1801
  display: flex;
1577
1802
  flex-direction: column;
1578
- background: rgb(9 10 13 / 78%);
1579
- backdrop-filter: blur(2px);
1803
+ background: rgb(15 15 15 / 76%);
1804
+ backdrop-filter: blur(3px);
1805
+ animation: rf-fade 180ms var(--rf-ease);
1580
1806
  }
1581
1807
 
1582
1808
  .editor { flex: 1; display: flex; align-items: center; justify-content: center; padding: 24px 24px 0; min-height: 0; }
1583
1809
  .editor canvas {
1584
1810
  max-width: 100%;
1585
1811
  max-height: 100%;
1586
- border-radius: 8px;
1587
- box-shadow: 0 24px 60px -12px rgb(0 0 0 / 60%);
1812
+ border-radius: 10px;
1813
+ box-shadow: 0 20px 60px -16px rgb(0 0 0 / 70%);
1588
1814
  cursor: crosshair;
1589
1815
  touch-action: none;
1590
1816
  }
@@ -1600,97 +1826,106 @@ textarea:focus, input:focus { outline: none; border-color: var(--rf-primary); bo
1600
1826
  .toolbar .group {
1601
1827
  display: flex;
1602
1828
  align-items: center;
1603
- gap: 4px;
1604
- padding: 5px;
1829
+ gap: 2px;
1830
+ padding: 4px;
1605
1831
  background: var(--rf-bg);
1606
- border: 1px solid var(--rf-border);
1607
- border-radius: 12px;
1832
+ border-radius: 10px;
1608
1833
  box-shadow: var(--rf-shadow);
1609
1834
  }
1610
1835
  .tool {
1611
1836
  display: inline-flex;
1612
1837
  align-items: center;
1613
1838
  justify-content: center;
1614
- width: 34px;
1615
- height: 34px;
1616
- border-radius: 8px;
1839
+ width: 30px;
1840
+ height: 30px;
1841
+ border-radius: 7px;
1617
1842
  color: var(--rf-text-muted);
1618
- transition: background 140ms var(--rf-ease), color 140ms var(--rf-ease);
1843
+ transition: background 160ms var(--rf-ease), color 160ms var(--rf-ease);
1619
1844
  }
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); }
1845
+ .tool:hover { background: var(--rf-bg-hover); color: var(--rf-text); }
1846
+ .tool[aria-pressed="true"] { background: var(--rf-bg-hover); color: var(--rf-text); }
1622
1847
  .swatch {
1623
- width: 22px;
1624
- height: 22px;
1848
+ width: 20px;
1849
+ height: 20px;
1625
1850
  border-radius: 50%;
1626
1851
  border: 2px solid transparent;
1627
- transition: transform 140ms var(--rf-ease);
1852
+ transition: transform 200ms var(--rf-ease), border-color 160ms var(--rf-ease);
1628
1853
  }
1629
- .swatch[aria-pressed="true"] { border-color: var(--rf-text); transform: scale(1.12); }
1854
+ .swatch[aria-pressed="true"] { border-color: var(--rf-text); transform: scale(1.15); }
1630
1855
  .toolbar .done-btn {
1631
- height: 34px;
1632
- padding: 0 14px;
1856
+ height: 30px;
1857
+ padding: 0 13px;
1858
+ font-size: 13px;
1633
1859
  font-weight: 550;
1634
- border-radius: 8px;
1860
+ border-radius: 7px;
1635
1861
  color: var(--rf-primary-text);
1636
1862
  background: var(--rf-primary);
1863
+ transition: opacity 160ms var(--rf-ease);
1637
1864
  }
1865
+ .toolbar .done-btn:hover { opacity: 0.88; }
1638
1866
 
1639
- .picker { position: fixed; inset: 0; z-index: 2147483002; cursor: crosshair; }
1640
1867
  .picker-box {
1641
1868
  position: fixed;
1642
1869
  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);
1870
+ border-radius: 5px;
1871
+ background: color-mix(in srgb, var(--rf-accent) 10%, transparent);
1872
+ box-shadow: 0 0 0 2px var(--rf-accent), 0 0 0 5px color-mix(in srgb, var(--rf-accent) 18%, transparent);
1873
+ transition: top 130ms var(--rf-ease), left 130ms var(--rf-ease), width 130ms var(--rf-ease), height 130ms var(--rf-ease);
1647
1874
  }
1648
1875
  .picker-label {
1649
1876
  position: fixed;
1650
1877
  pointer-events: none;
1651
- display: flex;
1878
+ display: inline-flex;
1652
1879
  align-items: center;
1653
1880
  gap: 6px;
1654
1881
  max-width: min(420px, 90vw);
1655
- padding: 5px 9px;
1656
- font-size: 12px;
1882
+ height: 24px;
1883
+ padding: 0 8px;
1884
+ font-size: 11.5px;
1657
1885
  font-weight: 500;
1658
- color: var(--rf-primary-text);
1659
- background: var(--rf-primary);
1660
- border-radius: 7px;
1886
+ color: #ffffff;
1887
+ background: var(--rf-accent);
1888
+ border-radius: 6px;
1889
+ box-shadow: 0 4px 12px -3px rgb(15 15 15 / 35%);
1661
1890
  white-space: nowrap;
1662
1891
  overflow: hidden;
1663
- text-overflow: ellipsis;
1892
+ transition: top 130ms var(--rf-ease), left 130ms var(--rf-ease);
1664
1893
  }
1665
- .picker-label code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; opacity: 0.82; }
1894
+ .picker-label strong { flex: none; font-weight: 600; }
1895
+ .picker-label span { min-width: 0; overflow: hidden; text-overflow: ellipsis; opacity: 0.75; }
1666
1896
  .picker-hint {
1667
1897
  position: fixed;
1668
1898
  left: 50%;
1669
1899
  bottom: 24px;
1670
1900
  transform: translateX(-50%);
1671
- padding: 9px 14px;
1672
- font-size: 13px;
1901
+ display: flex;
1902
+ align-items: center;
1903
+ gap: 6px;
1904
+ padding: 7px 12px;
1905
+ font-size: 12.5px;
1673
1906
  color: var(--rf-text);
1674
1907
  background: var(--rf-bg);
1675
- border: 1px solid var(--rf-border);
1676
1908
  border-radius: 10px;
1677
1909
  box-shadow: var(--rf-shadow);
1910
+ animation: rf-fade 240ms var(--rf-ease);
1678
1911
  }
1679
1912
  .picker-hint kbd {
1680
1913
  padding: 1px 5px;
1681
1914
  font: inherit;
1682
- font-size: 11.5px;
1683
- border: 1px solid var(--rf-border);
1915
+ font-size: 11px;
1916
+ color: var(--rf-text-muted);
1684
1917
  border-radius: 4px;
1685
1918
  background: var(--rf-bg-subtle);
1919
+ box-shadow: var(--rf-shadow-inset);
1686
1920
  }
1921
+
1687
1922
  @media (pointer: coarse) {
1688
1923
  .icon-btn, .tool, .swatch {
1689
1924
  width: 44px;
1690
1925
  height: 44px;
1691
1926
  }
1692
1927
 
1693
- .segmented button, .ghost-btn, .submit, .toolbar .done-btn {
1928
+ .launcher, .segmented button, .ghost-btn, .submit, .toolbar .done-btn {
1694
1929
  min-height: 44px;
1695
1930
  }
1696
1931
 
@@ -1705,7 +1940,6 @@ textarea:focus, input:focus { outline: none; border-color: var(--rf-primary); bo
1705
1940
  justify-content: center;
1706
1941
  }
1707
1942
  }
1708
-
1709
1943
  `;
1710
1944
  //#endregion
1711
1945
  //#region src/feedback/ui/shadow-portal.tsx
@@ -1849,20 +2083,20 @@ function buildDescription(params) {
1849
2083
  const contact = isAnonymous && email ? `\n\n---\nContact: ${email}` : "";
1850
2084
  return `[${label}] ${message.trim()}${contact}`;
1851
2085
  }
2086
+ async function upload(transport, image) {
2087
+ const { uploadUrl } = await transport.getScreenshotUploadUrl();
2088
+ return await transport.uploadImage(uploadUrl, image);
2089
+ }
1852
2090
  async function attachScreenshot(transport, feedbackId, submission) {
1853
2091
  const { annotated, annotations, context, screenshot } = submission;
1854
2092
  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
- }
2093
+ const storageId = await upload(transport, screenshot);
2094
+ const annotatedStorageId = annotated ? await upload(transport, annotated) : void 0;
1862
2095
  const wireAnnotations = toWireAnnotations(annotations);
1863
2096
  await transport.saveScreenshot({
1864
2097
  annotatedStorageId,
1865
2098
  annotations: wireAnnotations.length > 0 ? wireAnnotations : void 0,
2099
+ captureSource: "widget",
1866
2100
  feedbackId,
1867
2101
  filename: "screenshot.png",
1868
2102
  height: screenshot.height,
@@ -1874,6 +2108,21 @@ async function attachScreenshot(transport, feedbackId, submission) {
1874
2108
  });
1875
2109
  return true;
1876
2110
  }
2111
+ async function attachElement(transport, feedbackId, submission) {
2112
+ const { context, element } = submission;
2113
+ if (!element) return;
2114
+ await transport.saveScreenshot({
2115
+ captureSource: "element",
2116
+ feedbackId,
2117
+ filename: "element.png",
2118
+ height: element.height,
2119
+ mimeType: element.mimeType,
2120
+ pageUrl: context.url,
2121
+ size: element.blob.size,
2122
+ storageId: await upload(transport, element),
2123
+ width: element.width
2124
+ });
2125
+ }
1877
2126
  /**
1878
2127
  * Creates the feedback first, then attaches the capture. A failed upload is
1879
2128
  * reported back but never discards what the user wrote.
@@ -1886,12 +2135,8 @@ async function submitWidgetFeedback(transport, submission) {
1886
2135
  description: buildDescription(submission),
1887
2136
  title
1888
2137
  });
1889
- let screenshotSaved = false;
1890
- try {
1891
- screenshotSaved = await attachScreenshot(transport, feedbackId, submission);
1892
- } catch {
1893
- screenshotSaved = false;
1894
- }
2138
+ const screenshotSaved = await attachScreenshot(transport, feedbackId, submission).catch(() => false);
2139
+ await attachElement(transport, feedbackId, submission).catch(() => void 0);
1895
2140
  return {
1896
2141
  feedbackId,
1897
2142
  screenshotSaved
@@ -1921,6 +2166,7 @@ function createFeedbackTransport(client) {
1921
2166
  //#endregion
1922
2167
  //#region src/feedback/ui/use-widget-state.ts
1923
2168
  const SUCCESS_CLOSE_DELAY = 2400;
2169
+ const ELEMENT_CAPTURE_MAX_WIDTH = 1200;
1924
2170
  const IS_APPLE = /Mac|iPhone|iPad/;
1925
2171
  const MILLISECONDS_PER_DAY = 864e5;
1926
2172
  const DISMISSAL_STORAGE_PREFIX = "reflet-feedback-dismissed";
@@ -1975,6 +2221,22 @@ function highlightFor(selection, capture) {
1975
2221
  tool: "rectangle"
1976
2222
  };
1977
2223
  }
2224
+ /**
2225
+ * A capture whose object URL is revoked the moment it is replaced or dropped —
2226
+ * holding one without this leaks the blob for the lifetime of the page.
2227
+ */
2228
+ function useOwnedCapture() {
2229
+ const [image, setImage] = useState(null);
2230
+ const ref = useRef(null);
2231
+ useEffect(() => {
2232
+ ref.current = image;
2233
+ }, [image]);
2234
+ useEffect(() => () => releaseCapture(ref.current), []);
2235
+ return [image, useCallback((next) => {
2236
+ releaseCapture(ref.current);
2237
+ setImage(next);
2238
+ }, [])];
2239
+ }
1978
2240
  function useWidgetState(props) {
1979
2241
  const context = useContext(RefletContext);
1980
2242
  const publicKey = props.publicKey ?? context?.publicKey;
@@ -1990,7 +2252,8 @@ function useWidgetState(props) {
1990
2252
  const [message, setMessage] = useState("");
1991
2253
  const [email, setEmail] = useState("");
1992
2254
  const [honeypot, setHoneypot] = useState("");
1993
- const [capture, setCapture] = useState(null);
2255
+ const [capture, replaceCapture] = useOwnedCapture();
2256
+ const [elementCapture, replaceElementCapture] = useOwnedCapture();
1994
2257
  const [annotations, setAnnotations] = useState([]);
1995
2258
  const [selection, setSelection] = useState(null);
1996
2259
  const [isCapturing, setIsCapturing] = useState(false);
@@ -1998,6 +2261,7 @@ function useWidgetState(props) {
1998
2261
  const [error, setError] = useState(null);
1999
2262
  const [dismissedUntil, setDismissedUntil] = useState(() => readDismissedUntil(dismissalKey));
2000
2263
  const captureRef = useRef(null);
2264
+ const pickRef = useRef(0);
2001
2265
  const consoleRef = useRef(null);
2002
2266
  useEffect(() => {
2003
2267
  captureRef.current = capture;
@@ -2014,13 +2278,6 @@ function useWidgetState(props) {
2014
2278
  recorder.stop();
2015
2279
  };
2016
2280
  }, [props.captureConsole]);
2017
- useEffect(() => () => {
2018
- releaseCapture(captureRef.current);
2019
- }, []);
2020
- const replaceCapture = useCallback((next) => {
2021
- releaseCapture(captureRef.current);
2022
- setCapture(next);
2023
- }, []);
2024
2281
  const takeCapture = useCallback((element) => {
2025
2282
  setIsCapturing(true);
2026
2283
  captureViewport({ element }).catch(() => null).then((image) => {
@@ -2029,8 +2286,9 @@ function useWidgetState(props) {
2029
2286
  });
2030
2287
  }, [replaceCapture]);
2031
2288
  const reset = useCallback(() => {
2032
- releaseCapture(captureRef.current);
2033
- setCapture(null);
2289
+ pickRef.current++;
2290
+ replaceCapture(null);
2291
+ replaceElementCapture(null);
2034
2292
  setAnnotations([]);
2035
2293
  setSelection(null);
2036
2294
  setMessage("");
@@ -2039,7 +2297,11 @@ function useWidgetState(props) {
2039
2297
  setError(null);
2040
2298
  setStep("compose");
2041
2299
  setCategory(props.defaultCategory ?? "bug");
2042
- }, [props.defaultCategory]);
2300
+ }, [
2301
+ props.defaultCategory,
2302
+ replaceCapture,
2303
+ replaceElementCapture
2304
+ ]);
2043
2305
  const close = useCallback(() => {
2044
2306
  setIsOpen(false);
2045
2307
  reset();
@@ -2075,11 +2337,21 @@ function useWidgetState(props) {
2075
2337
  setStep("compose");
2076
2338
  const current = captureRef.current;
2077
2339
  if (current) setAnnotations((previous) => [...previous.filter((item) => !item.id.startsWith("selection-")), highlightFor(picked, current)]);
2078
- }, []);
2340
+ const pick = ++pickRef.current;
2341
+ captureViewport({
2342
+ element,
2343
+ maxWidth: ELEMENT_CAPTURE_MAX_WIDTH
2344
+ }).catch(() => null).then((image) => {
2345
+ if (pickRef.current === pick) replaceElementCapture(image);
2346
+ else releaseCapture(image);
2347
+ });
2348
+ }, [replaceElementCapture]);
2079
2349
  const clearSelection = useCallback(() => {
2350
+ pickRef.current++;
2351
+ replaceElementCapture(null);
2080
2352
  setSelection(null);
2081
2353
  setAnnotations((previous) => previous.filter((item) => !item.id.startsWith("selection-")));
2082
- }, []);
2354
+ }, [replaceElementCapture]);
2083
2355
  const removeCapture = useCallback(() => {
2084
2356
  replaceCapture(null);
2085
2357
  setAnnotations([]);
@@ -2119,6 +2391,7 @@ function useWidgetState(props) {
2119
2391
  metadata: props.metadata,
2120
2392
  selection: selection ?? void 0
2121
2393
  },
2394
+ element: elementCapture,
2122
2395
  email,
2123
2396
  isAnonymous,
2124
2397
  message,
@@ -2139,6 +2412,7 @@ function useWidgetState(props) {
2139
2412
  category,
2140
2413
  client,
2141
2414
  close,
2415
+ elementCapture,
2142
2416
  email,
2143
2417
  honeypot,
2144
2418
  isAnonymous,
@@ -2156,6 +2430,7 @@ function useWidgetState(props) {
2156
2430
  close,
2157
2431
  dismiss,
2158
2432
  dismissForDays,
2433
+ elementCapture,
2159
2434
  email,
2160
2435
  error,
2161
2436
  honeypot,
@@ -2238,22 +2513,18 @@ function RefletFeedback(props) {
2238
2513
  primaryColor: props.primaryColor,
2239
2514
  theme,
2240
2515
  children: [
2241
- /* @__PURE__ */ jsx("div", {
2516
+ /* @__PURE__ */ jsxs("div", {
2242
2517
  className: "root",
2243
2518
  "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
- })
2519
+ children: [showPanel && /* @__PURE__ */ jsx(FeedbackPanel, {
2520
+ categories,
2521
+ labels,
2522
+ state
2523
+ }), /* @__PURE__ */ jsx(Launcher, {
2524
+ isOpen,
2525
+ label: labels.trigger,
2526
+ onClick: isOpen ? close : open
2527
+ })]
2257
2528
  }),
2258
2529
  isOpen && state.step === "annotate" && state.capture && /* @__PURE__ */ jsx(Annotator, {
2259
2530
  annotations: state.annotations,