reflet-sdk 0.3.0 → 0.3.2

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
  }
@@ -229,7 +233,7 @@ const URL_QUERY = /[?#]/;
229
233
  const REDACTED = "[redacted]";
230
234
  const ELLIPSIS = "…";
231
235
  const MAX_ATTRIBUTE_LENGTH = 120;
232
- const MAX_TEXT_LENGTH = 200;
236
+ const MAX_TEXT_LENGTH$1 = 200;
233
237
  const MAX_CHILD_DEPTH = 3;
234
238
  const MAX_CLONED_NODES = 400;
235
239
  const DROPPED_TAGS = /* @__PURE__ */ new Set([
@@ -259,6 +263,36 @@ function truncate$1(value, max) {
259
263
  function maskSecrets(value) {
260
264
  return value.replace(EMAIL, REDACTED).replace(LONG_TOKEN, REDACTED);
261
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
+ }
262
296
  function sanitizeUrl(value) {
263
297
  if (OPAQUE_URI.test(value)) return `${value.slice(0, value.indexOf(":"))}:${ELLIPSIS}`;
264
298
  return value.split(URL_QUERY)[0] ?? value;
@@ -293,7 +327,7 @@ function sanitizeTree(element, depth) {
293
327
  continue;
294
328
  }
295
329
  if (child.nodeType === Node.TEXT_NODE) {
296
- child.textContent = truncate$1(maskSecrets(child.textContent ?? ""), MAX_TEXT_LENGTH);
330
+ child.textContent = truncate$1(maskSecrets(child.textContent ?? ""), MAX_TEXT_LENGTH$1);
297
331
  continue;
298
332
  }
299
333
  if (!(child instanceof Element)) continue;
@@ -324,6 +358,7 @@ const SAFE_ID = /^[A-Za-z][\w-]*$/;
324
358
  const WHITESPACE = /\s+/g;
325
359
  const MAX_LABEL_LENGTH = 80;
326
360
  const MAX_HTML_LENGTH = 600;
361
+ const MAX_TEXT_LENGTH = 300;
327
362
  const MAX_REGION_LENGTH = 160;
328
363
  const MAX_HANDLE_VALUE_LENGTH = 80;
329
364
  const MAX_DEPTH = 12;
@@ -340,6 +375,50 @@ const TEST_ATTRIBUTES = [
340
375
  const NAMING_ATTRIBUTES = ["name", "aria-label"];
341
376
  const LANDMARK_SELECTOR = "dialog, [role=\"dialog\"], [role=\"alertdialog\"], main, nav, aside, header, footer, form, section, article";
342
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
+ }
343
422
  function isUnique(selector) {
344
423
  try {
345
424
  return document.querySelectorAll(selector).length === 1;
@@ -427,8 +506,12 @@ function describeLandmark(element) {
427
506
  return name?.trim() ? `${landmark.localName} "${collapseText(name)}"` : landmark.localName;
428
507
  }
429
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);
430
513
  let closest = null;
431
- for (const heading of document.querySelectorAll(HEADING_SELECTOR)) if ((heading.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0) closest = heading;
514
+ for (const heading of scope.querySelectorAll(HEADING_SELECTOR)) if (probe.comparePoint(heading, 0) < 0) closest = heading;
432
515
  return collapseText(closest?.textContent ?? "") || null;
433
516
  }
434
517
  /**
@@ -455,6 +538,7 @@ function getElementRect(element) {
455
538
  /** Everything an agent needs to find the picked element back in the codebase. */
456
539
  function buildElementSelection(element) {
457
540
  const { componentStack, sourceLocation } = inspectReactElement(element);
541
+ const text = truncate(visibleText(element), MAX_TEXT_LENGTH);
458
542
  return {
459
543
  componentStack,
460
544
  html: sanitizeMarkup(element, MAX_HTML_LENGTH),
@@ -462,7 +546,8 @@ function buildElementSelection(element) {
462
546
  rect: getElementRect(element),
463
547
  region: describeRegion(element),
464
548
  selector: truncate(buildSelector(element), MAX_SELECTOR_LENGTH),
465
- sourceLocation: sourceLocation ? truncate(sourceLocation, MAX_SOURCE_LOCATION_LENGTH) : sourceLocation
549
+ sourceLocation: sourceLocation ? truncate(sourceLocation, MAX_SOURCE_LOCATION_LENGTH) : sourceLocation,
550
+ text: text || void 0
466
551
  };
467
552
  }
468
553
  //#endregion
@@ -539,9 +624,13 @@ function collectPageContext(options) {
539
624
  height: window.screen.height,
540
625
  width: window.screen.width
541
626
  },
627
+ scroll: {
628
+ x: window.scrollX,
629
+ y: window.scrollY
630
+ },
542
631
  sdkVersion: options?.sdkVersion,
543
632
  timezone: resolveTimezone(),
544
- url: clip(window.location.href, MAX_URL_LENGTH),
633
+ url: redactUrl(window.location.href),
545
634
  userAgent: clip(userAgent, MAX_USER_AGENT_LENGTH),
546
635
  viewport: {
547
636
  devicePixelRatio: window.devicePixelRatio,
@@ -553,7 +642,7 @@ function collectPageContext(options) {
553
642
  //#endregion
554
643
  //#region src/feedback/types.ts
555
644
  /** Reported with every submission. */
556
- const SDK_VERSION = "0.3.0";
645
+ const SDK_VERSION = "0.3.2";
557
646
  const DEFAULT_WIDGET_LABELS = {
558
647
  annotateHint: "Draw on the screenshot to point at the problem.",
559
648
  attachScreenshot: "Attach a screenshot",
@@ -2066,6 +2155,128 @@ function startConsoleRecorder(options) {
2066
2155
  };
2067
2156
  }
2068
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
2069
2280
  //#region src/feedback/core/submit.ts
2070
2281
  const MAX_TITLE_LENGTH = 100;
2071
2282
  const NEWLINE = /\r?\n/;
@@ -2164,9 +2375,53 @@ function createFeedbackTransport(client) {
2164
2375
  };
2165
2376
  }
2166
2377
  //#endregion
2378
+ //#region src/feedback/ui/use-capture-sync.ts
2379
+ const RESIZE_DEBOUNCE = 400;
2380
+ /**
2381
+ * Calls `refresh` whenever the page the reporter is looking at changes shape:
2382
+ * SPA routes (which never fire popstate on push/replace — history methods must
2383
+ * be patched) and window resizes, debounced. Keeps the frozen screenshot and
2384
+ * page context describing what the reporter currently sees.
2385
+ */
2386
+ function useCaptureSync(enabled, refresh) {
2387
+ useEffect(() => {
2388
+ if (!enabled) return;
2389
+ const history = window.history;
2390
+ const originalPush = history.pushState;
2391
+ const originalReplace = history.replaceState;
2392
+ const hadOwnPush = Object.hasOwn(history, "pushState");
2393
+ const hadOwnReplace = Object.hasOwn(history, "replaceState");
2394
+ history.pushState = function pushState(data, unused, url) {
2395
+ const result = originalPush.call(history, data, unused, url);
2396
+ refresh();
2397
+ return result;
2398
+ };
2399
+ history.replaceState = function replaceState(data, unused, url) {
2400
+ const result = originalReplace.call(history, data, unused, url);
2401
+ refresh();
2402
+ return result;
2403
+ };
2404
+ window.addEventListener("popstate", refresh);
2405
+ let resizeTimer;
2406
+ const onResize = () => {
2407
+ window.clearTimeout(resizeTimer);
2408
+ resizeTimer = window.setTimeout(refresh, RESIZE_DEBOUNCE);
2409
+ };
2410
+ window.addEventListener("resize", onResize);
2411
+ return () => {
2412
+ if (hadOwnPush) history.pushState = originalPush;
2413
+ else Reflect.deleteProperty(history, "pushState");
2414
+ if (hadOwnReplace) history.replaceState = originalReplace;
2415
+ else Reflect.deleteProperty(history, "replaceState");
2416
+ window.removeEventListener("popstate", refresh);
2417
+ window.clearTimeout(resizeTimer);
2418
+ window.removeEventListener("resize", onResize);
2419
+ };
2420
+ }, [enabled, refresh]);
2421
+ }
2422
+ //#endregion
2167
2423
  //#region src/feedback/ui/use-widget-state.ts
2168
2424
  const SUCCESS_CLOSE_DELAY = 2400;
2169
- const ELEMENT_CAPTURE_MAX_WIDTH = 1200;
2170
2425
  const IS_APPLE = /Mac|iPhone|iPad/;
2171
2426
  const MILLISECONDS_PER_DAY = 864e5;
2172
2427
  const DISMISSAL_STORAGE_PREFIX = "reflet-feedback-dismissed";
@@ -2204,23 +2459,6 @@ function matchesHotkey(event, hotkey, platform = typeof navigator === "undefined
2204
2459
  };
2205
2460
  return event.altKey === expected.alt && event.ctrlKey === expected.ctrl && event.metaKey === expected.meta && event.shiftKey === expected.shift;
2206
2461
  }
2207
- function highlightFor(selection, capture) {
2208
- const scale = capture.width / window.innerWidth;
2209
- const { rect } = selection;
2210
- return {
2211
- color: "#4f46e5",
2212
- end: {
2213
- x: (rect.x + rect.width) * scale,
2214
- y: (rect.y + rect.height) * scale
2215
- },
2216
- id: `selection-${rect.x}-${rect.y}`,
2217
- start: {
2218
- x: rect.x * scale,
2219
- y: rect.y * scale
2220
- },
2221
- tool: "rectangle"
2222
- };
2223
- }
2224
2462
  /**
2225
2463
  * A capture whose object URL is revoked the moment it is replaced or dropped —
2226
2464
  * holding one without this leaks the blob for the lifetime of the page.
@@ -2260,12 +2498,9 @@ function useWidgetState(props) {
2260
2498
  const [isSubmitting, setIsSubmitting] = useState(false);
2261
2499
  const [error, setError] = useState(null);
2262
2500
  const [dismissedUntil, setDismissedUntil] = useState(() => readDismissedUntil(dismissalKey));
2263
- const captureRef = useRef(null);
2264
2501
  const pickRef = useRef(0);
2265
2502
  const consoleRef = useRef(null);
2266
- useEffect(() => {
2267
- captureRef.current = capture;
2268
- }, [capture]);
2503
+ const frozenContext = useRef(null);
2269
2504
  useEffect(() => {
2270
2505
  setDismissedUntil(readDismissedUntil(dismissalKey));
2271
2506
  }, [dismissalKey]);
@@ -2278,15 +2513,20 @@ function useWidgetState(props) {
2278
2513
  recorder.stop();
2279
2514
  };
2280
2515
  }, [props.captureConsole]);
2281
- const takeCapture = useCallback((element) => {
2516
+ /** Frozen at the same instant as the screenshot, so shot and URL always match. */
2517
+ const takeCapture = useCallback(() => {
2518
+ const context = collectPageContext({ sdkVersion: SDK_VERSION });
2282
2519
  setIsCapturing(true);
2283
- captureViewport({ element }).catch(() => null).then((image) => {
2520
+ captureViewport().catch(() => null).then((image) => {
2284
2521
  setIsCapturing(false);
2285
2522
  replaceCapture(image);
2523
+ frozenContext.current = image ? context : null;
2286
2524
  });
2287
2525
  }, [replaceCapture]);
2526
+ useCaptureSync(isOpen && step === "compose" && annotations.length === 0 && !selection && props.captureOnOpen !== false, takeCapture);
2288
2527
  const reset = useCallback(() => {
2289
2528
  pickRef.current++;
2529
+ frozenContext.current = null;
2290
2530
  replaceCapture(null);
2291
2531
  replaceElementCapture(null);
2292
2532
  setAnnotations([]);
@@ -2331,21 +2571,24 @@ function useWidgetState(props) {
2331
2571
  props.onOpen,
2332
2572
  takeCapture
2333
2573
  ]);
2334
- const selectElement = useCallback((element) => {
2574
+ /** Fresh pixels so the highlight maps onto the scroll position the element had. */
2575
+ const selectElement = useCallback(async (element) => {
2335
2576
  const picked = buildElementSelection(element);
2336
2577
  setSelection(picked);
2337
2578
  setStep("compose");
2338
- const current = captureRef.current;
2339
- if (current) setAnnotations((previous) => [...previous.filter((item) => !item.id.startsWith("selection-")), highlightFor(picked, current)]);
2340
2579
  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]);
2580
+ const context = collectPageContext({ sdkVersion: SDK_VERSION });
2581
+ const [fresh, closeUp] = await Promise.all([captureViewport().catch(() => null), captureElementCloseUp(element).catch(() => null)]);
2582
+ if (pickRef.current !== pick) {
2583
+ releaseCapture(fresh);
2584
+ releaseCapture(closeUp);
2585
+ return;
2586
+ }
2587
+ replaceCapture(fresh);
2588
+ replaceElementCapture(closeUp);
2589
+ frozenContext.current = fresh ? context : null;
2590
+ setAnnotations(fresh ? [highlightFor(picked, fresh)] : []);
2591
+ }, [replaceCapture, replaceElementCapture]);
2349
2592
  const clearSelection = useCallback(() => {
2350
2593
  pickRef.current++;
2351
2594
  replaceElementCapture(null);
@@ -2353,6 +2596,7 @@ function useWidgetState(props) {
2353
2596
  setAnnotations((previous) => previous.filter((item) => !item.id.startsWith("selection-")));
2354
2597
  }, [replaceElementCapture]);
2355
2598
  const removeCapture = useCallback(() => {
2599
+ frozenContext.current = null;
2356
2600
  replaceCapture(null);
2357
2601
  setAnnotations([]);
2358
2602
  }, [replaceCapture]);
@@ -2386,7 +2630,7 @@ function useWidgetState(props) {
2386
2630
  annotations,
2387
2631
  category,
2388
2632
  context: {
2389
- ...collectPageContext({ sdkVersion: SDK_VERSION }),
2633
+ ...frozenContext.current ?? collectPageContext({ sdkVersion: "0.3.2" }),
2390
2634
  consoleEvents: consoleRef.current?.events(),
2391
2635
  metadata: props.metadata,
2392
2636
  selection: selection ?? void 0