reflet-sdk 0.4.2 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/feedback.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use client";
2
- import { a as REFLET_Z_INDEX, t as RefletContext } from "./react-context-3_IbhIFB.js";
3
2
  import { t as Reflet } from "./client-BYuSKJw5.js";
3
+ import { n as RefletContext, t as REFLET_Z_INDEX } from "./z-index-_Q9PWbfY.js";
4
4
  import { useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
5
5
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
6
6
  import { Slider } from "@base-ui/react/slider";
@@ -810,7 +810,7 @@ function collectPageContext(options) {
810
810
  }
811
811
  //#endregion
812
812
  //#region src/feedback/types.ts
813
- const SDK_VERSION = "0.4.2";
813
+ const SDK_VERSION = "0.4.3";
814
814
  const DEFAULT_WIDGET_LABELS = {
815
815
  annotateHint: "Draw on the screenshot to point at the problem.",
816
816
  attachElement: "Attach this element",
@@ -1169,6 +1169,54 @@ function useTextAnnotation(options) {
1169
1169
  };
1170
1170
  }
1171
1171
  //#endregion
1172
+ //#region src/feedback/ui/widget-keys.ts
1173
+ const KEY_EVENTS = [
1174
+ "keydown",
1175
+ "keyup",
1176
+ "keypress"
1177
+ ];
1178
+ function isWidgetNode(node) {
1179
+ if (node instanceof ShadowRoot) return node.host.hasAttribute(WIDGET_MARKER);
1180
+ return node instanceof Element && node.hasAttribute("data-reflet-widget");
1181
+ }
1182
+ function startsInWidget(event) {
1183
+ return event.composedPath().some(isWidgetNode);
1184
+ }
1185
+ function relayInsideWidget(event) {
1186
+ const target = event.composedPath()[0];
1187
+ if (!(event instanceof KeyboardEvent && target && startsInWidget(event))) return;
1188
+ event.stopImmediatePropagation();
1189
+ const relay = new KeyboardEvent(event.type, {
1190
+ altKey: event.altKey,
1191
+ bubbles: true,
1192
+ cancelable: true,
1193
+ charCode: event.charCode,
1194
+ code: event.code,
1195
+ ctrlKey: event.ctrlKey,
1196
+ isComposing: event.isComposing,
1197
+ key: event.key,
1198
+ keyCode: event.keyCode,
1199
+ location: event.location,
1200
+ metaKey: event.metaKey,
1201
+ repeat: event.repeat,
1202
+ shiftKey: event.shiftKey
1203
+ });
1204
+ target.dispatchEvent(relay);
1205
+ if (relay.defaultPrevented) event.preventDefault();
1206
+ }
1207
+ if (typeof window !== "undefined") for (const type of KEY_EVENTS) window.addEventListener(type, relayInsideWidget, true);
1208
+ function listenToKeydown(anchor, onKeyDown, capture = false) {
1209
+ const widgetRoot = anchor?.getRootNode();
1210
+ const targets = widgetRoot instanceof ShadowRoot ? [document, widgetRoot] : [document];
1211
+ const listener = (event) => {
1212
+ if (event instanceof KeyboardEvent) onKeyDown(event);
1213
+ };
1214
+ for (const target of targets) target.addEventListener("keydown", listener, capture);
1215
+ return () => {
1216
+ for (const target of targets) target.removeEventListener("keydown", listener, capture);
1217
+ };
1218
+ }
1219
+ //#endregion
1172
1220
  //#region src/feedback/ui/floating/use-details-popover.ts
1173
1221
  function useDetailsPopover() {
1174
1222
  const [open, setOpen] = useState(false);
@@ -1196,11 +1244,11 @@ function useDetailsPopover() {
1196
1244
  summaryRef.current?.focus();
1197
1245
  };
1198
1246
  document.addEventListener("pointerdown", dismissOutside);
1199
- document.addEventListener("keydown", dismissWithEscape, true);
1247
+ const stopListeningToKeys = listenToKeydown(detailsRef.current, dismissWithEscape, true);
1200
1248
  window.addEventListener("resize", reposition);
1201
1249
  return () => {
1202
1250
  document.removeEventListener("pointerdown", dismissOutside);
1203
- document.removeEventListener("keydown", dismissWithEscape, true);
1251
+ stopListeningToKeys();
1204
1252
  window.removeEventListener("resize", reposition);
1205
1253
  };
1206
1254
  }, []);
@@ -2456,1726 +2504,1732 @@ function SelectionOutline({ node }) {
2456
2504
  });
2457
2505
  }
2458
2506
  //#endregion
2459
- //#region src/feedback/ui/visible-viewport.ts
2460
- /**
2461
- * The part of the page the reporter can actually see, in fixed-position
2462
- * coordinates. On mobile the keyboard shrinks this without touching
2463
- * `window.innerHeight`, so anything that must stay reachable measures here.
2464
- */
2465
- function visibleViewport() {
2466
- const viewport = window.visualViewport;
2467
- const left = viewport?.offsetLeft ?? 0;
2468
- const top = viewport?.offsetTop ?? 0;
2469
- const width = viewport?.width ?? window.innerWidth;
2470
- const height = viewport?.height ?? window.innerHeight;
2471
- return {
2472
- bottom: window.innerHeight - top - height,
2473
- height,
2474
- left,
2475
- right: window.innerWidth - left - width,
2476
- top,
2477
- width
2478
- };
2507
+ //#region src/feedback/core/console-recorder.ts
2508
+ const DEFAULT_LIMIT = 30;
2509
+ const MAX_MESSAGE_LENGTH = 1e3;
2510
+ const RECORDED_METHODS = ["error", "warn"];
2511
+ function serializeArgument(value) {
2512
+ if (typeof value === "string") return value;
2513
+ if (value instanceof Error) return `${value.name}: ${value.message}`;
2514
+ if (value === null || value === void 0) return String(value);
2515
+ if (typeof value === "object") try {
2516
+ return JSON.stringify(value) ?? "[unserializable]";
2517
+ } catch {
2518
+ return "[unserializable]";
2519
+ }
2520
+ if (typeof value === "function") return "[function]";
2521
+ return String(value);
2479
2522
  }
2480
- function onViewportChange(update) {
2481
- window.addEventListener("resize", update);
2482
- window.visualViewport?.addEventListener("resize", update);
2483
- window.visualViewport?.addEventListener("scroll", update);
2484
- return () => {
2485
- window.removeEventListener("resize", update);
2486
- window.visualViewport?.removeEventListener("resize", update);
2487
- window.visualViewport?.removeEventListener("scroll", update);
2488
- };
2523
+ function formatMessage(args) {
2524
+ const message = args.map(serializeArgument).join(" ");
2525
+ return message.length > MAX_MESSAGE_LENGTH ? `${message.slice(0, 999)}…` : message;
2489
2526
  }
2490
- //#endregion
2491
- //#region src/feedback/ui/floating/use-floating-position.ts
2492
- const EDGE_GAP$1 = 12;
2493
- const KEYBOARD_STEP = 24;
2494
- const KEY_DIRECTIONS = {
2495
- ArrowDown: {
2496
- x: 0,
2497
- y: KEYBOARD_STEP
2498
- },
2499
- ArrowLeft: {
2500
- x: -24,
2501
- y: 0
2502
- },
2503
- ArrowRight: {
2504
- x: KEYBOARD_STEP,
2505
- y: 0
2506
- },
2507
- ArrowUp: {
2508
- x: 0,
2509
- y: -24
2510
- }
2511
- };
2512
- function clampPosition(point, bounds, viewport = visibleViewport()) {
2513
- return {
2514
- x: Math.max(viewport.left + EDGE_GAP$1, Math.min(point.x, viewport.left + viewport.width - bounds.width - EDGE_GAP$1)),
2515
- y: Math.max(viewport.top + EDGE_GAP$1, Math.min(point.y, viewport.top + viewport.height - bounds.height - EDGE_GAP$1))
2527
+ /**
2528
+ * Buffers the console noise a user saw right before reporting a bug.
2529
+ * Wrappers delegate to the previous implementation, so nothing is swallowed.
2530
+ */
2531
+ function startConsoleRecorder(options) {
2532
+ const limit = options?.limit ?? DEFAULT_LIMIT;
2533
+ const buffer = [];
2534
+ let active = true;
2535
+ const push = (level, message) => {
2536
+ if (!(active && message)) return;
2537
+ buffer.push({
2538
+ level,
2539
+ message,
2540
+ timestamp: Date.now()
2541
+ });
2542
+ if (buffer.length > limit) buffer.splice(0, buffer.length - limit);
2516
2543
  };
2517
- }
2518
- function useFloatingPosition(corner = "bottom-right", isPanelOpen = false) {
2519
- const rootRef = useRef(null);
2520
- const gesture = useRef(null);
2521
- const [position, setPosition] = useState(null);
2522
- const [positionedPanelOpen, setPositionedPanelOpen] = useState(isPanelOpen);
2523
- const [viewport, setViewport] = useState(visibleViewport);
2524
- const horizontalEdge = corner.endsWith("right") ? "right" : "left";
2525
- const verticalEdge = corner.startsWith("bottom") ? "bottom" : "top";
2526
- if (positionedPanelOpen !== isPanelOpen) {
2527
- setPositionedPanelOpen(isPanelOpen);
2528
- setPosition(null);
2529
- }
2530
- const moveTo = useCallback((point, bounds) => {
2531
- const fitted = clampPosition(point, bounds);
2532
- const visible = visibleViewport();
2533
- const anchor = {
2534
- x: horizontalEdge === "right" ? visible.left + visible.width - fitted.x - bounds.width : fitted.x - visible.left,
2535
- y: verticalEdge === "bottom" ? visible.top + visible.height - fitted.y - bounds.height : fitted.y - visible.top
2536
- };
2537
- setPosition((current) => current?.x === anchor.x && current.y === anchor.y ? current : anchor);
2538
- }, [horizontalEdge, verticalEdge]);
2539
- const keepInViewport = useCallback(() => {
2540
- const bounds = rootRef.current?.getBoundingClientRect();
2541
- if (!bounds) return;
2542
- const fitted = clampPosition(bounds, bounds, viewport);
2543
- if (Math.abs(fitted.x - bounds.x) > .5 || Math.abs(fitted.y - bounds.y) > .5) moveTo(fitted, bounds);
2544
- }, [moveTo, viewport]);
2545
- useLayoutEffect(() => {
2546
- keepInViewport();
2547
- }, [keepInViewport]);
2548
- useEffect(() => {
2549
- const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(keepInViewport);
2550
- if (rootRef.current) observer?.observe(rootRef.current);
2551
- const stopListening = onViewportChange(() => setViewport(visibleViewport()));
2552
- return () => {
2553
- observer?.disconnect();
2554
- stopListening();
2555
- };
2556
- }, [keepInViewport]);
2557
- const onPointerDown = (event) => {
2558
- const bounds = rootRef.current?.getBoundingClientRect();
2559
- if (!bounds || event.button !== 0) return;
2560
- event.currentTarget.setPointerCapture(event.pointerId);
2561
- gesture.current = {
2562
- x: event.clientX - bounds.x,
2563
- y: event.clientY - bounds.y
2544
+ const originals = /* @__PURE__ */ new Map();
2545
+ const wrappers = /* @__PURE__ */ new Map();
2546
+ for (const method of RECORDED_METHODS) {
2547
+ const original = console[method];
2548
+ const wrapper = (...args) => {
2549
+ push(method, formatMessage(args));
2550
+ Reflect.apply(original, console, args);
2564
2551
  };
2552
+ originals.set(method, original);
2553
+ wrappers.set(method, wrapper);
2554
+ console[method] = wrapper;
2555
+ }
2556
+ const onError = (event) => {
2557
+ const where = event.filename ? ` (${event.filename}:${event.lineno}:${event.colno})` : "";
2558
+ push("error", `${event.message}${where}`);
2565
2559
  };
2566
- const onPointerMove = (event) => {
2567
- const bounds = rootRef.current?.getBoundingClientRect();
2568
- const grab = gesture.current;
2569
- if (!(bounds && grab && event.currentTarget.hasPointerCapture(event.pointerId))) return;
2570
- moveTo({
2571
- x: event.clientX - grab.x,
2572
- y: event.clientY - grab.y
2573
- }, bounds);
2574
- };
2575
- const onKeyDown = (event) => {
2576
- const direction = KEY_DIRECTIONS[event.key];
2577
- const bounds = rootRef.current?.getBoundingClientRect();
2578
- if (!(bounds && direction)) return;
2579
- event.preventDefault();
2580
- moveTo({
2581
- x: bounds.x + direction.x,
2582
- y: bounds.y + direction.y
2583
- }, bounds);
2560
+ const onRejection = (event) => {
2561
+ push("error", `Unhandled rejection: ${serializeArgument(event.reason)}`);
2584
2562
  };
2585
- const viewportStyles = `.root { --rf-viewport-width: ${viewport.width}px; --rf-viewport-top: ${viewport.top}px; --rf-viewport-bottom: ${viewport.bottom}px; --rf-viewport-left: ${viewport.left}px; --rf-viewport-right: ${viewport.right}px; }`;
2586
- const anchorStyles = position ? `.root[data-moved="true"] { left: auto; right: auto; top: auto; bottom: auto; ${horizontalEdge}: ${position.x + viewport[horizontalEdge]}px; ${verticalEdge}: ${position.y + viewport[verticalEdge]}px; }` : "";
2563
+ window.addEventListener("error", onError);
2564
+ window.addEventListener("unhandledrejection", onRejection);
2587
2565
  return {
2588
- handleProps: {
2589
- onKeyDown,
2590
- onLostPointerCapture: () => {
2591
- gesture.current = null;
2592
- },
2593
- onPointerCancel: () => {
2594
- gesture.current = null;
2595
- },
2596
- onPointerDown,
2597
- onPointerMove,
2598
- onPointerUp: () => {
2599
- gesture.current = null;
2566
+ events: () => buffer.slice(),
2567
+ stop: () => {
2568
+ if (!active) return;
2569
+ active = false;
2570
+ buffer.length = 0;
2571
+ for (const method of RECORDED_METHODS) {
2572
+ const original = originals.get(method);
2573
+ if (original && console[method] === wrappers.get(method)) console[method] = original;
2600
2574
  }
2601
- },
2602
- position,
2603
- rootRef,
2604
- styles: viewportStyles + anchorStyles
2575
+ window.removeEventListener("error", onError);
2576
+ window.removeEventListener("unhandledrejection", onRejection);
2577
+ }
2605
2578
  };
2606
2579
  }
2607
2580
  //#endregion
2608
- //#region src/feedback/ui/floating/floating-widget.tsx
2609
- function FloatingWidget({ labels, position, state }) {
2610
- const [minimized, setMinimized] = useState(false);
2611
- const launcherRef = useRef(null);
2612
- const showPanel = state.isOpen && !minimized;
2613
- const floating = useFloatingPosition(position, showPanel);
2614
- const minimize = () => {
2615
- setMinimized(true);
2616
- requestAnimationFrame(() => launcherRef.current?.focus());
2617
- };
2618
- useEffect(() => {
2619
- if (state.step === "compose") state.annotationTrigger?.button.focus({ preventScroll: true });
2620
- }, [state.step, state.annotationTrigger]);
2621
- useEffect(() => {
2622
- if (!showPanel || state.step !== "compose") return;
2623
- const onKeyDown = (event) => {
2624
- if (event instanceof KeyboardEvent && event.key === "Escape" && !event.defaultPrevented) {
2625
- setMinimized(true);
2626
- requestAnimationFrame(() => launcherRef.current?.focus());
2627
- }
2628
- };
2629
- const widgetRoot = floating.rootRef.current?.getRootNode();
2630
- document.addEventListener("keydown", onKeyDown);
2631
- widgetRoot?.addEventListener("keydown", onKeyDown);
2632
- return () => {
2633
- document.removeEventListener("keydown", onKeyDown);
2634
- widgetRoot?.removeEventListener("keydown", onKeyDown);
2635
- };
2636
- }, [
2637
- floating.rootRef,
2638
- showPanel,
2639
- state.step
2640
- ]);
2641
- return /* @__PURE__ */ jsxs("div", {
2642
- className: "root",
2643
- "data-editing": state.isOpen && (state.step === "picking" || state.step === "annotate"),
2644
- "data-moved": Boolean(floating.position),
2645
- "data-position": position,
2646
- ref: floating.rootRef,
2647
- children: [
2648
- /* @__PURE__ */ jsx("style", { children: floating.styles }),
2649
- showPanel && state.step === "compose" && state.screenshots.map(({ id, selectedNode }) => selectedNode ? /* @__PURE__ */ jsx(SelectionOutline, { node: selectedNode }, id) : null),
2650
- showPanel ? /* @__PURE__ */ jsx(FeedbackPanel, {
2651
- floatingControls: /* @__PURE__ */ jsxs("div", {
2652
- className: "floating-controls",
2653
- children: [/* @__PURE__ */ jsx("button", {
2654
- "aria-label": labels.moveFeedback,
2655
- className: "icon-btn drag-handle",
2656
- title: labels.moveFeedback,
2657
- type: "button",
2658
- ...floating.handleProps,
2659
- children: /* @__PURE__ */ jsx(GripIcon, {})
2660
- }), /* @__PURE__ */ jsx("button", {
2661
- "aria-label": labels.minimize,
2662
- className: "icon-btn",
2663
- onClick: minimize,
2664
- title: labels.minimize,
2665
- type: "button",
2666
- children: /* @__PURE__ */ jsx(CloseIcon, {})
2667
- })]
2668
- }),
2669
- labels,
2670
- state
2671
- }) : /* @__PURE__ */ jsx(Launcher, {
2672
- buttonRef: launcherRef,
2673
- isOpen: false,
2674
- label: state.isOpen ? labels.resume : labels.trigger,
2675
- onClick: () => {
2676
- setMinimized(false);
2677
- if (!state.isOpen) state.open();
2678
- }
2679
- })
2680
- ]
2681
- });
2682
- }
2683
- //#endregion
2684
- //#region src/feedback/ui/floating/element-walk.ts
2685
- const ARROW_SIBLING = {
2686
- ArrowLeft: "previousElementSibling",
2687
- ArrowRight: "nextElementSibling"
2688
- };
2689
- const AIM_KEYS = [
2690
- "Tab",
2691
- "ArrowUp",
2692
- "ArrowDown",
2693
- "ArrowLeft",
2694
- "ArrowRight"
2695
- ];
2696
- function isWidgetOwned(element) {
2697
- return element.closest(`[${WIDGET_MARKER}]`) !== null;
2698
- }
2699
- function isPickable(element) {
2700
- if (!element || element === document.documentElement || isWidgetOwned(element)) return false;
2701
- const rect = element.getBoundingClientRect();
2702
- return rect.width > 0 && rect.height > 0;
2581
+ //#region src/feedback/core/submission/upload-screenshots.ts
2582
+ async function uploadImage(transport, image) {
2583
+ const { uploadUrl } = await transport.getScreenshotUploadUrl();
2584
+ return transport.uploadImage(uploadUrl, image);
2703
2585
  }
2704
- function pickableElements() {
2705
- return Array.from(document.body.querySelectorAll("*")).filter(isPickable);
2586
+ async function prepareScreenshot(transport, screenshot, index) {
2587
+ const { closeUp, image, annotations, context } = screenshot;
2588
+ const annotated = annotations.length > 0 ? await renderAnnotatedImage(image, annotations) : null;
2589
+ if (annotations.length > 0 && !annotated) throw new Error("Could not render your annotations. Please try again.");
2590
+ try {
2591
+ const storageId = await uploadImage(transport, image);
2592
+ const prepared = [{
2593
+ annotatedStorageId: annotated ? await uploadImage(transport, annotated) : void 0,
2594
+ annotations: annotations.length > 0 ? toWireAnnotations(annotations) : void 0,
2595
+ captureSource: "widget",
2596
+ filename: `screenshot-${index + 1}.png`,
2597
+ height: image.height,
2598
+ mimeType: image.mimeType,
2599
+ pageUrl: context.url,
2600
+ size: image.blob.size,
2601
+ storageId,
2602
+ width: image.width
2603
+ }];
2604
+ if (closeUp) prepared.push({
2605
+ captureSource: "element",
2606
+ filename: `element-${index + 1}.png`,
2607
+ height: closeUp.height,
2608
+ mimeType: closeUp.mimeType,
2609
+ pageUrl: context.url,
2610
+ size: closeUp.blob.size,
2611
+ storageId: await uploadImage(transport, closeUp),
2612
+ width: closeUp.width
2613
+ });
2614
+ return prepared;
2615
+ } finally {
2616
+ releaseCapture(annotated);
2617
+ }
2706
2618
  }
2707
- function documentStep(from, backwards) {
2708
- const all = pickableElements();
2709
- const index = all.indexOf(from);
2710
- if (all.length === 0) return null;
2711
- if (index === -1) return all[0] ?? null;
2712
- return all[(index + (backwards ? -1 : 1) + all.length) % all.length] ?? null;
2619
+ async function uploadScreenshots(transport, submission) {
2620
+ return (await Promise.allSettled(submission.screenshots.map((screenshot, index) => prepareScreenshot(transport, screenshot, index)))).flatMap((result) => {
2621
+ if (result.status === "rejected") throw result.reason;
2622
+ return result.value;
2623
+ });
2713
2624
  }
2714
- function climb(from) {
2715
- let candidate = from.parentElement;
2716
- while (candidate && !isPickable(candidate)) candidate = candidate.parentElement;
2717
- return candidate;
2625
+ //#endregion
2626
+ //#region src/feedback/core/submit.ts
2627
+ const MAX_TITLE_LENGTH = 100;
2628
+ const NEWLINE = /\r?\n/;
2629
+ function deriveTitle(message) {
2630
+ const firstLine = message.split(NEWLINE).map((line) => line.trim()).find(Boolean) ?? "";
2631
+ if (firstLine.length <= MAX_TITLE_LENGTH) return firstLine;
2632
+ const clipped = firstLine.slice(0, 99);
2633
+ const lastSpace = clipped.lastIndexOf(" ");
2634
+ return `${(lastSpace > 0 ? clipped.slice(0, lastSpace) : clipped).trimEnd()}…`;
2718
2635
  }
2719
- function descend(from) {
2720
- let candidate = from.firstElementChild;
2721
- while (candidate && !isPickable(candidate)) candidate = candidate.nextElementSibling;
2722
- return candidate;
2636
+ function buildDescription(params) {
2637
+ const { email, isAnonymous, message } = params;
2638
+ const contact = isAnonymous && email ? `\n\n---\nContact: ${email}` : "";
2639
+ return `${message.trim()}${contact}`;
2723
2640
  }
2724
- function sibling(from, direction) {
2725
- let candidate = from[direction];
2726
- while (candidate && !isPickable(candidate)) candidate = candidate[direction];
2727
- return candidate;
2641
+ async function attachPreparedScreenshots(transport, result) {
2642
+ const results = await Promise.allSettled(result.pendingScreenshots.map((screenshot) => transport.saveScreenshot({
2643
+ ...screenshot,
2644
+ feedbackId: result.feedbackId
2645
+ })));
2646
+ const pendingScreenshots = result.pendingScreenshots.filter((_, index) => results[index]?.status === "rejected");
2647
+ return {
2648
+ feedbackId: result.feedbackId,
2649
+ pendingScreenshots
2650
+ };
2728
2651
  }
2729
- function stepAim(from, key, backwards) {
2730
- if (!(from && isPickable(from))) return pickableElements()[0] ?? null;
2731
- if (key === "Tab") return documentStep(from, backwards);
2732
- if (key === "ArrowUp") return climb(from);
2733
- if (key === "ArrowDown") return descend(from);
2734
- const direction = ARROW_SIBLING[key];
2735
- return direction ? sibling(from, direction) : null;
2652
+ async function createReportWithScreenshots(transport, submission, pendingScreenshots) {
2653
+ const { feedbackId } = await transport.create({
2654
+ context: submission.context,
2655
+ description: buildDescription(submission),
2656
+ title: deriveTitle(submission.message)
2657
+ });
2658
+ return attachPreparedScreenshots(transport, {
2659
+ feedbackId,
2660
+ pendingScreenshots
2661
+ });
2736
2662
  }
2737
- //#endregion
2738
- //#region src/feedback/ui/picker.tsx
2739
- const LABEL_HEIGHT = 24;
2740
- const LABEL_GAP = 6;
2741
- const NOTE_WIDTH = 300;
2742
- const NOTE_GAP = 10;
2743
- const EDGE_GAP = 12;
2744
- /** Events raised by the picker's own note card must reach it, never be swallowed. */
2745
- function targetsWidget(event) {
2746
- return event.composedPath().some((node) => node instanceof Element && isWidgetOwned(node));
2663
+ async function submitWidgetFeedback(transport, submission) {
2664
+ if (!deriveTitle(submission.message)) throw new Error("Please describe your feedback before sending it.");
2665
+ return createReportWithScreenshots(transport, submission, await uploadScreenshots(transport, submission));
2747
2666
  }
2748
- function elementUnder(x, y) {
2749
- const element = document.elementFromPoint(x, y);
2750
- if (!element || isWidgetOwned(element)) return null;
2751
- return element;
2667
+ function isStorageResponse(value) {
2668
+ return typeof value === "object" && value !== null && typeof Reflect.get(value, "storageId") === "string";
2752
2669
  }
2753
- function describeTarget(element) {
2670
+ function createFeedbackTransport(client) {
2754
2671
  return {
2755
- componentName: resolveComponentStack(getFiberFromNode(element))[0],
2756
- element,
2757
- label: describeElement(element),
2758
- rect: element.getBoundingClientRect(),
2759
- region: describeRegion(element)
2672
+ create: (params) => client.create(params),
2673
+ getScreenshotUploadUrl: () => client.getScreenshotUploadUrl(),
2674
+ saveScreenshot: (params) => client.saveScreenshot(params),
2675
+ uploadImage: async (uploadUrl, image) => {
2676
+ const response = await fetch(uploadUrl, {
2677
+ body: image.blob,
2678
+ headers: { "Content-Type": image.mimeType },
2679
+ method: "POST"
2680
+ });
2681
+ if (!response.ok) throw new Error(`Screenshot upload failed (${response.status})`);
2682
+ const payload = await response.json();
2683
+ if (!isStorageResponse(payload)) throw new Error("Screenshot upload returned no storage id");
2684
+ return payload.storageId;
2685
+ }
2760
2686
  };
2761
2687
  }
2762
- /**
2763
- * Pins the note card to the element: below it, flipped above when the visible
2764
- * viewport — what is left of the screen once a mobile keyboard is up — has no
2765
- * room there. Measures against the part of the element actually on screen, so
2766
- * picking a full-height section still leaves the card in view.
2767
- */
2768
- function noteStyle(rect, view) {
2769
- const left = Math.min(Math.max(view.left + EDGE_GAP, rect.left), Math.max(view.left + EDGE_GAP, view.left + view.width - NOTE_WIDTH - EDGE_GAP));
2770
- const visibleTop = Math.max(rect.top, view.top + EDGE_GAP);
2771
- const visibleBottom = Math.min(rect.bottom, view.top + view.height - EDGE_GAP);
2772
- if (view.top + view.height - visibleBottom > visibleTop - view.top) return {
2773
- left,
2774
- top: visibleBottom + NOTE_GAP
2688
+ //#endregion
2689
+ //#region src/feedback/ui/state/use-feedback-submission.ts
2690
+ function useFeedbackSubmission(options) {
2691
+ const [activeRequest, setActiveRequest] = useState(null);
2692
+ const [pendingReport, setPendingReport] = useState(null);
2693
+ const requestRef = useRef(null);
2694
+ useEffect(() => () => {
2695
+ requestRef.current = null;
2696
+ }, []);
2697
+ const resetSubmission = useCallback(() => {
2698
+ requestRef.current = null;
2699
+ setPendingReport(null);
2700
+ setActiveRequest(null);
2701
+ }, []);
2702
+ const finishSubmission = (result) => {
2703
+ if (result.pendingScreenshots.length > 0) {
2704
+ setPendingReport(result);
2705
+ options.onError(options.partialUploadMessage);
2706
+ return;
2707
+ }
2708
+ setPendingReport(null);
2709
+ options.onSubmitted(result.feedbackId);
2710
+ };
2711
+ const submit = async (draft) => {
2712
+ if (requestRef.current) return;
2713
+ if (!options.client) {
2714
+ options.onError("Reflet is missing a publicKey. Pass one to RefletFeedback or RefletProvider.");
2715
+ return;
2716
+ }
2717
+ const request = {};
2718
+ requestRef.current = request;
2719
+ setActiveRequest(request);
2720
+ options.onError(null);
2721
+ try {
2722
+ const transport = createFeedbackTransport(options.client);
2723
+ const result = pendingReport ? await attachPreparedScreenshots(transport, pendingReport) : await submitWidgetFeedback(transport, draft);
2724
+ if (requestRef.current === request) finishSubmission(result);
2725
+ } catch (error) {
2726
+ if (requestRef.current === request) options.onError(error instanceof Error ? error.message : "Something went wrong. Please try again.");
2727
+ } finally {
2728
+ if (requestRef.current === request) {
2729
+ requestRef.current = null;
2730
+ setActiveRequest(null);
2731
+ }
2732
+ }
2775
2733
  };
2776
2734
  return {
2777
- bottom: window.innerHeight - visibleTop + NOTE_GAP,
2778
- left
2735
+ hasPendingAttachments: pendingReport !== null,
2736
+ isEditingDisabled: activeRequest !== null || pendingReport !== null,
2737
+ isSubmitting: activeRequest !== null,
2738
+ resetSubmission,
2739
+ submit
2779
2740
  };
2780
2741
  }
2781
- /**
2782
- * Points at any element in the page the way a coding agent needs it: the React
2783
- * component that owns it plus the page region it lives in. Picking never
2784
- * commits on its own — it pins a note card to the element, so the reporter
2785
- * writes about that element right there and confirms. Aiming stays live until
2786
- * then, which is what makes the flow usable without hover, on touch.
2787
- */
2788
- function ElementPicker({ labels, onCancel, onPick }) {
2789
- const [target, setTarget] = useState(null);
2790
- const [pinned, setPinned] = useState(null);
2791
- const [note, setNote] = useState("");
2792
- const noteRef = useRef(null);
2793
- const pinnedRef = useRef(false);
2794
- const [view, setView] = useState(visibleViewport);
2795
- const aimRef = useRef(null);
2796
- useEffect(() => onViewportChange(() => setView(visibleViewport())), []);
2797
- useEffect(() => {
2798
- pinnedRef.current = pinned !== null;
2799
- if (pinned) noteRef.current?.focus({ preventScroll: true });
2800
- }, [pinned]);
2801
- useEffect(() => {
2802
- aimRef.current = target?.element ?? null;
2803
- }, [target]);
2742
+ //#endregion
2743
+ //#region src/feedback/ui/state/use-screenshot-drafts.ts
2744
+ function releaseDraft(draft) {
2745
+ releaseCapture(draft?.image);
2746
+ releaseCapture(draft?.closeUp);
2747
+ }
2748
+ function useScreenshotDrafts(captureFailed, onError) {
2749
+ const [screenshots, setScreenshots] = useState([]);
2750
+ const [activeId, setActiveId] = useState(null);
2751
+ const [pendingCapture, setPendingCapture] = useState(null);
2752
+ const ownedDrafts = useRef(/* @__PURE__ */ new Map());
2753
+ const captureRequest = useRef(null);
2804
2754
  useEffect(() => {
2805
- const swallow = (event) => {
2806
- if (targetsWidget(event)) return;
2807
- event.preventDefault();
2808
- event.stopPropagation();
2809
- };
2810
- const aimAt = (x, y) => {
2811
- const element = elementUnder(x, y);
2812
- if (!element) return;
2813
- setTarget((current) => current?.element === element ? {
2814
- ...current,
2815
- rect: element.getBoundingClientRect()
2816
- } : describeTarget(element));
2817
- };
2818
- const onMove = (event) => {
2819
- if (pinnedRef.current || targetsWidget(event)) return;
2820
- aimAt(event.clientX, event.clientY);
2821
- };
2822
- const onPointerDown = (event) => {
2823
- if (targetsWidget(event)) return;
2824
- swallow(event);
2825
- if (!pinnedRef.current) aimAt(event.clientX, event.clientY);
2826
- };
2827
- const onPointerUp = (event) => {
2828
- if (targetsWidget(event)) return;
2829
- swallow(event);
2830
- if (pinnedRef.current) return;
2831
- const element = elementUnder(event.clientX, event.clientY);
2832
- if (element) setPinned(describeTarget(element));
2833
- };
2834
- const moveAim = (key, backwards) => {
2835
- const next = stepAim(aimRef.current, key, backwards);
2836
- if (!next) return;
2837
- next.scrollIntoView({
2838
- block: "nearest",
2839
- inline: "nearest"
2840
- });
2841
- aimRef.current = next;
2842
- setTarget(describeTarget(next));
2843
- };
2844
- const onEscape = () => {
2845
- if (pinnedRef.current) {
2846
- setPinned(null);
2847
- setNote("");
2848
- return;
2849
- }
2850
- onCancel();
2851
- };
2852
- const onKeyDown = (event) => {
2853
- if (event.key === "Escape") {
2854
- swallow(event);
2855
- onEscape();
2856
- return;
2857
- }
2858
- if (pinnedRef.current || targetsWidget(event)) return;
2859
- if (event.key === "Enter") {
2860
- swallow(event);
2861
- if (aimRef.current) setPinned(describeTarget(aimRef.current));
2862
- return;
2863
- }
2864
- if (!AIM_KEYS.includes(event.key)) return;
2865
- swallow(event);
2866
- moveAim(event.key, event.shiftKey);
2867
- };
2868
- const onScroll = () => {
2869
- setTarget((current) => current ? {
2870
- ...current,
2871
- rect: current.element.getBoundingClientRect()
2872
- } : current);
2873
- setPinned((current) => current ? {
2874
- ...current,
2875
- rect: current.element.getBoundingClientRect()
2876
- } : current);
2877
- };
2878
- document.addEventListener("pointermove", onMove, true);
2879
- document.addEventListener("pointerdown", onPointerDown, true);
2880
- document.addEventListener("pointerup", onPointerUp, true);
2881
- document.addEventListener("mousedown", swallow, true);
2882
- document.addEventListener("mouseup", swallow, true);
2883
- document.addEventListener("click", swallow, true);
2884
- document.addEventListener("keydown", onKeyDown, true);
2885
- window.addEventListener("scroll", onScroll, {
2886
- capture: true,
2887
- passive: true
2888
- });
2889
- const previousCursor = document.body.style.cursor;
2890
- document.body.style.cursor = "crosshair";
2755
+ const drafts = ownedDrafts.current;
2891
2756
  return () => {
2892
- document.removeEventListener("pointermove", onMove, true);
2893
- document.removeEventListener("pointerdown", onPointerDown, true);
2894
- document.removeEventListener("pointerup", onPointerUp, true);
2895
- document.removeEventListener("mousedown", swallow, true);
2896
- document.removeEventListener("mouseup", swallow, true);
2897
- document.removeEventListener("click", swallow, true);
2898
- document.removeEventListener("keydown", onKeyDown, true);
2899
- window.removeEventListener("scroll", onScroll, true);
2900
- document.body.style.cursor = previousCursor;
2757
+ captureRequest.current = null;
2758
+ for (const draft of drafts.values()) releaseDraft(draft);
2759
+ drafts.clear();
2901
2760
  };
2902
- }, [onCancel]);
2903
- const rect = (pinned ?? target)?.rect;
2904
- const labelAbove = rect ? rect.top > 30 : true;
2905
- return /* @__PURE__ */ jsxs("div", { children: [
2906
- rect && /* @__PURE__ */ jsx("div", {
2907
- className: "picker-box",
2908
- "data-pinned": pinned !== null,
2909
- style: {
2910
- height: rect.height,
2911
- translate: `${rect.left}px ${rect.top}px`,
2912
- width: rect.width
2761
+ }, []);
2762
+ const storeScreenshot = useCallback((screenshot) => {
2763
+ releaseDraft(ownedDrafts.current.get(screenshot.id));
2764
+ ownedDrafts.current.set(screenshot.id, screenshot);
2765
+ setScreenshots((previous) => {
2766
+ return previous.some(({ id }) => id === screenshot.id) ? previous.map((item) => item.id === screenshot.id ? screenshot : item) : [...previous, screenshot];
2767
+ });
2768
+ setActiveId(screenshot.id);
2769
+ }, []);
2770
+ const cancelPendingCapture = useCallback(() => {
2771
+ captureRequest.current = null;
2772
+ setPendingCapture(null);
2773
+ }, []);
2774
+ const takeScreenshot = useCallback(async (request) => {
2775
+ captureRequest.current = request;
2776
+ setPendingCapture(request);
2777
+ onError(null);
2778
+ const context = collectPageContext({ sdkVersion: SDK_VERSION });
2779
+ const image = await captureViewport().catch(() => null);
2780
+ if (captureRequest.current !== request) {
2781
+ releaseCapture(image);
2782
+ return;
2783
+ }
2784
+ cancelPendingCapture();
2785
+ if (!image) {
2786
+ onError(captureFailed);
2787
+ return;
2788
+ }
2789
+ storeScreenshot({
2790
+ ...request,
2791
+ annotations: [],
2792
+ context,
2793
+ image
2794
+ });
2795
+ }, [
2796
+ cancelPendingCapture,
2797
+ captureFailed,
2798
+ onError,
2799
+ storeScreenshot
2800
+ ]);
2801
+ const takeCapture = useCallback(() => {
2802
+ if (!captureRequest.current) takeScreenshot({
2803
+ id: crypto.randomUUID(),
2804
+ source: "manual"
2805
+ });
2806
+ }, [takeScreenshot]);
2807
+ const refreshAutomatic = useCallback(() => {
2808
+ takeScreenshot({
2809
+ id: ownedDrafts.current.keys().next().value ?? captureRequest.current?.id ?? crypto.randomUUID(),
2810
+ source: "automatic"
2811
+ });
2812
+ }, [takeScreenshot]);
2813
+ const retakeCapture = useCallback((id) => {
2814
+ if (!captureRequest.current && ownedDrafts.current.has(id)) takeScreenshot({
2815
+ id,
2816
+ source: "manual"
2817
+ });
2818
+ }, [takeScreenshot]);
2819
+ const removeCapture = useCallback((id) => {
2820
+ if (captureRequest.current?.id === id) cancelPendingCapture();
2821
+ releaseDraft(ownedDrafts.current.get(id));
2822
+ ownedDrafts.current.delete(id);
2823
+ setScreenshots((previous) => previous.filter((item) => item.id !== id));
2824
+ setActiveId((current) => current === id ? null : current);
2825
+ }, [cancelPendingCapture]);
2826
+ const resetScreenshots = useCallback(() => {
2827
+ cancelPendingCapture();
2828
+ for (const draft of ownedDrafts.current.values()) releaseDraft(draft);
2829
+ ownedDrafts.current.clear();
2830
+ setScreenshots([]);
2831
+ setActiveId(null);
2832
+ }, [cancelPendingCapture]);
2833
+ const setAnnotations = useCallback((annotations) => {
2834
+ setScreenshots((previous) => previous.map((item) => item.id === activeId ? {
2835
+ ...item,
2836
+ annotations
2837
+ } : item));
2838
+ }, [activeId]);
2839
+ const setSelectionComment = useCallback((id, comment) => {
2840
+ setScreenshots((previous) => previous.map((item) => item.id === id && item.selection ? {
2841
+ ...item,
2842
+ selection: {
2843
+ ...item.selection,
2844
+ comment
2913
2845
  }
2914
- }),
2915
- rect && target && !pinned && /* @__PURE__ */ jsxs("div", {
2916
- className: "picker-label",
2917
- style: { translate: `${Math.max(4, rect.left)}px ${labelAbove ? rect.top - LABEL_HEIGHT - LABEL_GAP : rect.bottom + LABEL_GAP}px` },
2918
- children: [/* @__PURE__ */ jsx("strong", { children: target.componentName ? `<${target.componentName}>` : target.label }), target.region && /* @__PURE__ */ jsx("span", { children: target.region })]
2919
- }),
2920
- pinned ? /* @__PURE__ */ jsxs("form", {
2921
- className: "picker-note glass",
2922
- onSubmit: (event) => {
2923
- event.preventDefault();
2924
- onPick(pinned.element, note.trim());
2925
- },
2926
- style: noteStyle(pinned.rect, view),
2927
- children: [
2928
- /* @__PURE__ */ jsxs("p", {
2929
- className: "picker-note-target",
2930
- children: [/* @__PURE__ */ jsx("strong", { children: pinned.componentName ? `<${pinned.componentName}>` : pinned.label }), pinned.region && /* @__PURE__ */ jsx("span", { children: pinned.region })]
2931
- }),
2932
- /* @__PURE__ */ jsx("textarea", {
2933
- "aria-label": labels.elementNote,
2934
- maxLength: MAX_SELECTION_COMMENT_LENGTH,
2935
- onChange: (event) => setNote(event.target.value),
2936
- onKeyDown: (event) => {
2937
- if (event.key === "Enter" && !event.shiftKey) {
2938
- event.preventDefault();
2939
- onPick(pinned.element, note.trim());
2940
- }
2941
- },
2942
- placeholder: labels.elementNotePlaceholder,
2943
- ref: noteRef,
2944
- rows: 2,
2945
- value: note
2946
- }),
2947
- /* @__PURE__ */ jsxs("div", {
2948
- className: "picker-note-actions",
2949
- children: [/* @__PURE__ */ jsx("button", {
2950
- className: "picker-cancel",
2951
- onClick: () => {
2952
- setPinned(null);
2953
- setNote("");
2954
- },
2955
- type: "button",
2956
- children: labels.pickAnother
2957
- }), /* @__PURE__ */ jsx("button", {
2958
- "aria-label": labels.attachElement,
2959
- className: "submit",
2960
- title: labels.attachElement,
2961
- type: "submit",
2962
- children: /* @__PURE__ */ jsx(ArrowIcon, {})
2963
- })]
2964
- })
2965
- ]
2966
- }) : /* @__PURE__ */ jsxs("div", {
2967
- className: "picker-hint",
2968
- children: [/* @__PURE__ */ jsxs("span", {
2969
- className: "picker-instruction",
2970
- children: [
2971
- labels.pickElementHint,
2972
- " ",
2973
- /* @__PURE__ */ jsx("kbd", { children: "Tab" }),
2974
- /* @__PURE__ */ jsx("kbd", { children: "Enter" }),
2975
- /* @__PURE__ */ jsx("kbd", { children: "Esc" })
2976
- ]
2977
- }), /* @__PURE__ */ jsx("button", {
2978
- className: "picker-cancel",
2979
- onClick: onCancel,
2980
- type: "button",
2981
- children: labels.cancel
2982
- })]
2983
- })
2984
- ] });
2846
+ } : item));
2847
+ }, []);
2848
+ return {
2849
+ activeScreenshot: screenshots.find(({ id }) => id === activeId) ?? screenshots.at(-1) ?? null,
2850
+ cancelPendingCapture,
2851
+ pendingCapture,
2852
+ refreshAutomatic,
2853
+ removeCapture,
2854
+ resetScreenshots,
2855
+ retakeCapture,
2856
+ screenshots,
2857
+ selectScreenshot: setActiveId,
2858
+ setAnnotations,
2859
+ setSelectionComment,
2860
+ storeScreenshot,
2861
+ takeCapture
2862
+ };
2985
2863
  }
2986
2864
  //#endregion
2987
- //#region src/feedback/ui/annotation/styles.ts
2988
- const ANNOTATOR_STYLES = `
2989
- .overlay { margin: 0; border: 0; padding: 0; width: 100vw; max-width: none; height: 100dvh; max-height: none; position: fixed; inset: 0; z-index: ${REFLET_Z_INDEX.annotatorOverlay}; display: flex; flex-direction: column; background: transparent; color: var(--rf-text); font: 13px/1.45 ui-sans-serif, system-ui, sans-serif; }
2990
- .overlay::before { content: ""; position: fixed; inset: 0; z-index: -1; pointer-events: none; background: color-mix(in srgb, var(--rf-bg) 94%, transparent); animation: rf-editor-in 260ms var(--rf-ease); transition: opacity 260ms var(--rf-ease); }
2991
- .overlay::backdrop { background: transparent; }
2992
- @keyframes rf-editor-in { from { opacity: 0; } }
2993
- .editor { flex: 1; position: relative; display: flex; align-items: center; justify-content: center; padding: 20px; min-height: 0; container-type: size; }
2994
- .editor-image { width: min(100cqw, calc(100cqh * var(--rf-capture-ratio))); aspect-ratio: var(--rf-capture-ratio); flex: none; position: relative; }
2995
- .editor-visual { position: absolute; inset: 0; border-radius: var(--rf-radius); box-shadow: var(--rf-shadow); transform-origin: top left; overflow: hidden; }
2996
- .editor-visual img, .editor-visual canvas { position: absolute; inset: 0; width: 100%; height: 100%; border-radius: inherit; object-fit: cover; object-position: top center; outline: 1px solid var(--rf-hairline); outline-offset: -1px; }
2997
- .editor-visual canvas { cursor: crosshair; touch-action: none; }
2998
- .overlay[data-opening="true"] .editor { pointer-events: none; }
2999
- .overlay[data-closing="true"] { pointer-events: none; }
3000
- .overlay[data-closing="true"]::before { opacity: 0; }
3001
- .overlay[data-closing="true"] .toolbar, .overlay[data-closing="true"] .element-note { opacity: 0; transform: translateY(8px); }
3002
-
3003
- .element-note { order: 1; display: flex; align-items: flex-start; gap: 10px; width: min(560px, calc(100vw - 32px)); margin: 0 auto 10px; padding: 10px 14px; border-radius: var(--rf-radius); transition: opacity 180ms var(--rf-ease), transform 180ms var(--rf-ease); }
3004
- .element-note svg { flex: none; margin-top: 4px; opacity: 0.7; }
3005
- .element-note textarea { flex: 1; min-width: 0; height: 42px; max-height: 96px; padding: 0; border: 0; background: transparent; color: inherit; font: inherit; resize: none; }
3006
- .element-note textarea:focus { outline: none; }
3007
- .element-note:focus-within { outline: 2px solid var(--rf-accent); outline-offset: 2px; }
3008
-
3009
- .toolbar { order: 2; display: flex; align-items: center; justify-content: center; gap: 8px; flex-wrap: wrap; transition: opacity 180ms var(--rf-ease), transform 180ms var(--rf-ease); padding: 4px 12px max(16px, env(safe-area-inset-bottom)); }
3010
- .toolbar .group { display: flex; align-items: center; gap: 2px; padding: 4px; border-radius: var(--rf-pill); }
3011
- .tool[aria-pressed="true"], .tool[data-selected="true"] { background: var(--rf-primary); color: var(--rf-primary-text); }
3012
- .color-picker { position: relative; }
3013
- .color-picker summary { display: flex; justify-content: center; align-items: center; }
3014
- .color-preview { width: 18px; height: 18px; border-radius: 50%; background: var(--annotation-color); box-shadow: inset 0 0 0 1px var(--rf-border); }
3015
- .color-popover { --rf-color-width: 200px; position: absolute; bottom: calc(100% + 14px); left: clamp(calc(12px - var(--rf-color-anchor)), 0px, calc(100vw - var(--rf-color-anchor) - var(--rf-color-width) - 12px)); display: flex; align-items: center; gap: 6px; width: var(--rf-color-width); padding: 4px 4px 4px 14px; border-radius: var(--rf-pill); animation: rf-in 180ms var(--rf-ease); }
3016
- .color-picker[data-placement="below"] .color-popover { top: calc(100% + 14px); bottom: auto; }
3017
- .color-slider { flex: 1; min-width: 0; }
3018
- .color-slider-control { display: flex; align-items: center; height: 40px; width: 100%; touch-action: none; user-select: none; cursor: pointer; }
3019
- .color-slider-track { height: 12px; width: 100%; border-radius: var(--rf-pill); background: linear-gradient(to right, var(--rf-swatch-base), var(--annotation-base-color)); box-shadow: inset 0 0 0 1px var(--rf-border); }
3020
- .color-slider-thumb { width: 16px; height: 16px; border: 2px solid var(--rf-swatch-base); border-radius: 50%; background: var(--annotation-color); box-shadow: var(--rf-shadow-low); transition: scale 160ms var(--rf-ease); }
3021
- .color-slider-thumb[data-dragging] { scale: 1.12; }
3022
- .color-slider-thumb:has(:focus-visible) { outline: 2px solid var(--rf-accent); outline-offset: 3px; }
3023
- .color-spectrum { position: relative; display: flex; align-items: center; justify-content: center; flex: none; width: 40px; height: 40px; border-radius: 50%; cursor: pointer; }
3024
- .color-spectrum::before { content: ""; width: 16px; height: 16px; border-radius: 50%; background: var(--rf-spectrum); box-shadow: inset 0 0 0 1px var(--rf-glass-highlight); }
3025
- .color-spectrum:hover { background: var(--rf-bg-hover); }
3026
- .color-spectrum button { position: absolute; inset: 0; width: 100%; height: 100%; border-radius: inherit; }
3027
- .color-spectrum button:focus-visible { outline: 2px solid var(--rf-accent); outline-offset: 3px; }
3028
- .color-spectrum input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; pointer-events: none; }
3029
- .done-btn { display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; padding: 0; border-radius: var(--rf-pill); color: var(--rf-primary-text); background: var(--rf-primary); }
3030
- .drawing-menu { position: relative; }
3031
- .drawing-popover { position: absolute; z-index: 2; right: 0; bottom: calc(100% + 12px); width: 200px; max-height: calc(50dvh - 68px); overflow-y: auto; padding: 6px; border-radius: var(--rf-radius); }
3032
- .drawing-menu[data-placement="below"] .drawing-popover { top: calc(100% + 12px); bottom: auto; }
3033
- .drawing-option { display: flex; align-items: center; width: 100%; min-height: 40px; gap: 10px; padding: 0 10px; border-radius: 12px; text-align: left; }
3034
- .drawing-option:hover, .drawing-option[aria-pressed="true"] { background: var(--rf-bg-hover); }
3035
- .drawing-divider { height: 1px; margin: 5px; background: var(--rf-border); }
3036
- .editor canvas[data-tool="text"] { cursor: text; }
3037
- .text-annotation-editor { position: fixed; z-index: 3; width: 240px; padding: 8px; border-radius: var(--rf-radius); }
3038
- .text-annotation-editor:focus-within { outline: 2px solid var(--rf-accent); outline-offset: 2px; }
3039
- .text-annotation-editor textarea { display: block; width: 100%; min-height: 64px; max-height: 112px; padding: 6px; border: 0; background: transparent; resize: vertical; font-size: 16px; line-height: 24px; }
3040
- .text-annotation-editor textarea:focus { outline: none; }
3041
- .text-annotation-actions { display: flex; align-items: center; justify-content: flex-end; gap: 4px; }
3042
- @media (pointer: coarse) {
3043
- .drawing-option { min-height: 44px; }
3044
- .done-btn { width: 44px; height: 44px; }
3045
- .color-popover { --rf-color-width: 232px; padding-left: 16px; }
3046
- .color-slider-control, .color-spectrum { height: 44px; }
3047
- .color-spectrum { width: 44px; }
3048
- .color-slider-thumb, .color-spectrum::before { width: 18px; height: 18px; }
3049
- .editor { padding: 8px; }
3050
- .toolbar { gap: 6px; }
3051
- .toolbar .group { padding: 3px; gap: 0; }
2865
+ //#region src/feedback/ui/state/use-widget-config.ts
2866
+ function dismissalDuration(days) {
2867
+ return days !== void 0 && Number.isFinite(days) && days > 0 ? days : null;
3052
2868
  }
3053
- @media (pointer: coarse) and (max-width: 360px) {
3054
- .toolbar { gap: 4px; padding-inline: 2px; }
3055
- .toolbar .group { padding: 0; }
2869
+ function useWidgetConfig(props) {
2870
+ const context = useContext(RefletContext);
2871
+ const publicKey = props.publicKey ?? context?.publicKey;
2872
+ const baseUrl = props.baseUrl ?? context?.baseUrl;
2873
+ const user = props.user ?? context?.user;
2874
+ const userToken = props.userToken ?? context?.userToken;
2875
+ return {
2876
+ client: useMemo(() => publicKey ? new Reflet({
2877
+ baseUrl,
2878
+ publicKey,
2879
+ user,
2880
+ userToken
2881
+ }) : null, [
2882
+ baseUrl,
2883
+ publicKey,
2884
+ user,
2885
+ userToken
2886
+ ]),
2887
+ dismissalKey: `reflet-feedback-dismissed:${publicKey ?? "default"}:${user?.id ?? "anonymous"}`,
2888
+ dismissForDays: dismissalDuration(props.dismissForDays),
2889
+ isAnonymous: !(user || userToken)
2890
+ };
3056
2891
  }
3057
- `;
3058
2892
  //#endregion
3059
- //#region src/feedback/ui/composer/styles.ts
3060
- const COMPOSER_STYLES = `
3061
- .panel { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 8px; outline: none; animation: rf-in 240ms var(--rf-ease); }
3062
- .success-panel { border-radius: var(--rf-radius); }
3063
- .composer { position: relative; display: flex; flex-direction: column; gap: 8px; }
3064
- .message-row { display: flex; flex-direction: column; gap: 2px; padding: 6px; border-radius: 26px; }
3065
- .message-row textarea {
3066
- width: 100%; height: 76px; max-height: min(140px, 30dvh);
3067
- margin: 0; padding: 6px 6px 0 8px; border: 0; resize: none;
3068
- background: transparent; line-height: 20px;
3069
- }
3070
- .message-actions { display: flex; align-items: center; gap: 2px; }
3071
- .message-row textarea::placeholder { color: var(--rf-text-muted); }
3072
- .message-row textarea:focus { outline: none; }
3073
- .message-row:has(textarea:focus-visible) { border-color: color-mix(in srgb, var(--rf-accent) 40%, var(--rf-glass-stroke)); }
3074
- .submit { display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; flex: none; border-radius: var(--rf-pill); background: var(--rf-primary); color: var(--rf-primary-text); transition: opacity 160ms var(--rf-ease), transform 160ms var(--rf-ease); }
3075
- .submit svg { transform: rotate(-45deg); }
3076
- .submit:hover:enabled { transform: scale(1.05); }
3077
- .submit:disabled { background: var(--rf-bg-hover); color: var(--rf-text-muted); opacity: .65; }
3078
- .composer-toolbar { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
3079
- .composer-options { position: relative; }
3080
- summary { cursor: pointer; list-style: none; }
3081
- summary::-webkit-details-marker { display: none; }
3082
- .options-popover { position: absolute; z-index: 2; right: 0; bottom: calc(100% + 12px); width: min(240px, calc(100vw - 40px)); max-height: calc(50dvh - 68px); overflow-y: auto; display: flex; flex-direction: column; gap: 8px; padding: 12px; border-radius: var(--rf-radius); animation: rf-in 160ms var(--rf-ease); }
3083
- .composer-options[data-placement="below"] .options-popover { top: calc(100% + 12px); bottom: auto; }
3084
- .email-field { display: flex; flex-direction: column; gap: 6px; color: var(--rf-text-muted); font-size: 12px; }
3085
- .email-field [role="alert"] { color: var(--rf-danger); }
3086
- .email-field input { width: 100%; padding: 9px; border: 1px solid var(--rf-border); border-radius: var(--rf-radius-sm); background: var(--rf-bg-subtle); color: var(--rf-text); }
3087
- .dismiss-btn { min-height: 40px; text-align: left; padding: 0 8px; border-radius: var(--rf-radius-sm); color: var(--rf-text-muted); }
3088
- .dismiss-btn:hover { background: var(--rf-bg-hover); }
3089
- .version-info { font-size: 10px; color: var(--rf-text-muted); padding: 4px 8px 0; border-top: 1px solid var(--rf-border); text-decoration: none; }
3090
- .version-info:hover { color: var(--rf-text); }
3091
- .email-scrim { display: none; }
3092
- .email-prompt { display: flex; flex-direction: column; gap: 12px; padding: 16px; border-radius: 22px; animation: rf-in 240ms var(--rf-ease); }
3093
- .email-prompt h2 { margin: 0; font-size: 14px; font-weight: 600; line-height: 1.3; }
3094
- .email-prompt p { margin: 0; font-size: 12px; line-height: 1.45; color: var(--rf-text-muted); }
3095
- .email-prompt .email-field input { min-height: 40px; border-radius: 14px; padding: 0 12px; }
3096
- .email-prompt-actions { display: flex; align-items: center; justify-content: flex-end; gap: 4px; }
3097
- .email-prompt-actions .ghost-btn { padding: 0 12px; color: var(--rf-text-muted); }
3098
- .primary-btn { display: flex; align-items: center; justify-content: center; min-height: 40px; padding: 0 16px; border-radius: var(--rf-pill); background: var(--rf-primary); color: var(--rf-primary-text); font-weight: 500; }
3099
- .primary-btn:hover { opacity: .9; }
3100
- .attachments { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; }
3101
- .attachment-row { display: flex; align-items: flex-end; gap: 8px; min-width: 0; max-width: 100%; }
3102
- .screenshot-scroll-area { position: relative; min-width: 0; margin: -14px -10px -20px; }
3103
- .screenshot-strip { width: 100%; padding: 14px 10px 20px; overscroll-behavior-x: contain; --rf-fade-start: 0px; --rf-fade-end: 0px; mask-image: linear-gradient(to right, transparent, black var(--rf-fade-start), black calc(100% - var(--rf-fade-end)), transparent); mask-repeat: no-repeat; }
3104
- .screenshot-strip[data-overflow-x-start] { --rf-fade-start: calc(10px + min(24px, var(--scroll-area-overflow-x-start))); }
3105
- .screenshot-strip[data-overflow-x-end] { --rf-fade-end: calc(10px + min(24px, var(--scroll-area-overflow-x-end))); }
3106
- .screenshot-strip-content { display: flex; align-items: flex-end; gap: 8px; }
3107
- .screenshot-scroll-area:has(.screenshot-strip-content:empty) { display: none; }
3108
- .capture-slot { display: flex; flex-direction: column; gap: 2px; flex: none; width: 56px; height: var(--rf-attachment-height); padding: 3px; border: 1px dashed var(--rf-border); border-radius: var(--rf-radius); box-shadow: none; }
3109
- .capture-action { display: flex; flex: 1; min-height: 0; align-items: center; justify-content: center; border-radius: 5px; color: var(--rf-text-muted); transition: background 160ms var(--rf-ease), color 160ms var(--rf-ease); }
3110
- .capture-action:first-child { border-radius: 16px 16px 5px 5px; }
3111
- .capture-action:last-child { border-radius: 5px 5px 16px 16px; }
3112
- .capture-action:hover:enabled { background: var(--rf-bg-hover); color: var(--rf-text); }
3113
- .screenshot-scrollbar { height: 4px; margin: 0 8px 2px; opacity: 0; transition: opacity 150ms var(--rf-ease); }
3114
- .screenshot-scrollbar[data-hovering], .screenshot-scrollbar[data-scrolling] { opacity: 1; }
3115
- .screenshot-scroll-thumb { border-radius: var(--rf-pill); background: var(--rf-border); }
3116
- .capture-progress { position: absolute; inset: 0; display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 6px; border-radius: inherit; color: var(--rf-text-muted); font-size: 10px; }
3117
- .attachments:empty { display: none; }
3118
- .screenshot-attachment, .attachment-skeleton { position: relative; flex: none; margin: 0; min-width: 0; width: 128px; height: var(--rf-attachment-height); padding: 0; border-radius: 16px; animation: rf-in 260ms var(--rf-ease); }
3119
- .screenshot-attachment { border: 0; }
3120
- .screenshot-preview { display: block; position: relative; width: 100%; height: 100%; padding: 0; border-radius: inherit; overflow: hidden; }
3121
- .screenshot-preview img, .screenshot-preview canvas { width: 100%; height: 100%; display: block; object-fit: cover; object-position: top center; }
3122
- .screenshot-preview canvas { position: absolute; inset: 0; pointer-events: none; }
3123
- .annotation-count { position: absolute; left: 5px; bottom: 5px; min-width: 20px; padding: 2px 6px; border-radius: var(--rf-pill); font-size: 10px; font-weight: 600; font-variant-numeric: tabular-nums; text-align: center; color: var(--rf-text); }
3124
- .attachment-actions { position: absolute; right: 4px; bottom: 4px; display: flex; gap: 1px; padding: 1px; border-radius: var(--rf-pill); }
3125
- .attachment-actions .icon-btn { width: 28px; height: 28px; }
3126
- .attachment-actions svg { width: 14px; height: 14px; }
3127
- .attachment-skeleton { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; color: var(--rf-text-muted); font-size: 11px; }
3128
- .attachment-skeleton::after { content: ""; position: absolute; inset: 0; border-radius: inherit; background: var(--rf-bg-subtle); animation: rf-fade 800ms alternate infinite; }
3129
- .selection-badge { position: absolute; left: 5px; top: 5px; display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--rf-pill); color: var(--rf-accent); }
3130
- .selection-badge svg { width: 12px; height: 12px; }
3131
- @media (pointer: coarse) {
3132
- .message-row { border-radius: 28px; }
3133
- .capture-slot { width: 64px; }
3134
- .message-row textarea { font-size: 16px; }
3135
- .submit { width: 44px; height: 44px; }
3136
- :host { --rf-attachment-height: 96px; }
3137
- .attachment-actions { right: 5px; bottom: 5px; }
3138
- .attachment-actions .icon-btn { width: 34px; height: 34px; }
3139
- .selection-badge { width: 24px; height: 24px; }
3140
- .email-field input { font-size: 16px; }
3141
- .dismiss-btn { min-height: 44px; }
3142
- }
3143
- @media (max-width: 480px) {
3144
- .email-scrim { display: block; position: fixed; inset: 0; background: rgb(0 0 0 / 40%); animation: rf-fade 200ms var(--rf-ease); }
3145
- .email-prompt { position: fixed; left: 0; right: 0; bottom: 0; gap: 14px; padding: 20px 16px calc(20px + env(safe-area-inset-bottom)); border-radius: 26px 26px 0 0; animation: rf-drawer-in 280ms var(--rf-ease); }
3146
- .email-prompt h2 { font-size: 17px; }
3147
- .email-prompt p { font-size: 13px; }
3148
- .email-prompt-actions { flex-direction: column-reverse; align-items: stretch; gap: 8px; }
3149
- .email-prompt-actions .ghost-btn, .email-prompt-actions .primary-btn { justify-content: center; min-height: 48px; }
2893
+ //#region src/feedback/ui/use-capture-sync.ts
2894
+ const RESIZE_DEBOUNCE = 400;
2895
+ /**
2896
+ * Calls `refresh` whenever the page the reporter is looking at changes shape:
2897
+ * SPA routes (which never fire popstate on push/replace — history methods must
2898
+ * be patched) and window resizes, debounced. Keeps the frozen screenshot and
2899
+ * page context describing what the reporter currently sees.
2900
+ */
2901
+ function useCaptureSync(enabled, refresh) {
2902
+ useEffect(() => {
2903
+ if (!enabled) return;
2904
+ const history = window.history;
2905
+ const originalPush = history.pushState;
2906
+ const originalReplace = history.replaceState;
2907
+ const hadOwnPush = Object.hasOwn(history, "pushState");
2908
+ const hadOwnReplace = Object.hasOwn(history, "replaceState");
2909
+ history.pushState = function pushState(data, unused, url) {
2910
+ const result = originalPush.call(history, data, unused, url);
2911
+ refresh();
2912
+ return result;
2913
+ };
2914
+ history.replaceState = function replaceState(data, unused, url) {
2915
+ const result = originalReplace.call(history, data, unused, url);
2916
+ refresh();
2917
+ return result;
2918
+ };
2919
+ window.addEventListener("popstate", refresh);
2920
+ let resizeTimer;
2921
+ const onResize = () => {
2922
+ window.clearTimeout(resizeTimer);
2923
+ resizeTimer = window.setTimeout(refresh, RESIZE_DEBOUNCE);
2924
+ };
2925
+ window.addEventListener("resize", onResize);
2926
+ return () => {
2927
+ if (hadOwnPush) history.pushState = originalPush;
2928
+ else Reflect.deleteProperty(history, "pushState");
2929
+ if (hadOwnReplace) history.replaceState = originalReplace;
2930
+ else Reflect.deleteProperty(history, "replaceState");
2931
+ window.removeEventListener("popstate", refresh);
2932
+ window.clearTimeout(resizeTimer);
2933
+ window.removeEventListener("resize", onResize);
2934
+ };
2935
+ }, [enabled, refresh]);
3150
2936
  }
3151
- @keyframes rf-drawer-in { from { transform: translateY(100%); } }
3152
- `;
3153
2937
  //#endregion
3154
- //#region src/feedback/ui/floating/picker-styles.ts
3155
- const PICKER_STYLES = `
3156
- .picker-box {
3157
- position: fixed;
3158
- top: 0;
3159
- left: 0;
3160
- pointer-events: none;
3161
- border-radius: 5px;
3162
- background: color-mix(in srgb, var(--rf-accent) 10%, transparent);
3163
- box-shadow: 0 0 0 2px var(--rf-accent), 0 0 0 5px color-mix(in srgb, var(--rf-accent) 18%, transparent);
3164
- will-change: translate;
3165
- transition: translate 130ms var(--rf-ease);
2938
+ //#region src/feedback/ui/use-widget-state.ts
2939
+ const SUCCESS_CLOSE_DELAY = 2400;
2940
+ const IS_APPLE = /Mac|iPhone|iPad/;
2941
+ const MILLISECONDS_PER_DAY = 864e5;
2942
+ function readDismissedUntil(key) {
2943
+ if (typeof window === "undefined") return 0;
2944
+ try {
2945
+ const value = Number.parseInt(window.localStorage.getItem(key) ?? "", 10);
2946
+ if (Number.isFinite(value) && value > Date.now()) return value;
2947
+ window.localStorage.removeItem(key);
2948
+ } catch {
2949
+ return 0;
2950
+ }
2951
+ return 0;
3166
2952
  }
3167
- .picker-box[data-pinned="true"] { transition: none; }
3168
- .picker-label {
3169
- position: fixed;
3170
- top: 0;
3171
- left: 0;
3172
- pointer-events: none;
3173
- display: inline-flex;
3174
- align-items: center;
3175
- gap: 6px;
3176
- max-width: min(420px, 90vw);
3177
- height: 24px;
3178
- padding: 0 8px;
3179
- font-size: 11.5px;
3180
- font-weight: 500;
3181
- color: var(--rf-accent-text);
3182
- background: var(--rf-accent);
3183
- border-radius: 6px;
3184
- box-shadow: var(--rf-shadow-float);
3185
- white-space: nowrap;
3186
- overflow: hidden;
3187
- will-change: translate;
3188
- transition: translate 130ms var(--rf-ease);
3189
- }
3190
- .picker-label strong { flex: none; font-weight: 600; }
3191
- .picker-label span { min-width: 0; overflow: hidden; text-overflow: ellipsis; opacity: 0.75; }
3192
- .selection-outline {
3193
- position: fixed;
3194
- top: 0;
3195
- left: 0;
3196
- pointer-events: none;
3197
- border-radius: 5px;
3198
- background: color-mix(in srgb, var(--rf-accent) 7%, transparent);
3199
- box-shadow: 0 0 0 2px color-mix(in srgb, var(--rf-accent) 70%, transparent), 0 0 0 5px color-mix(in srgb, var(--rf-accent) 14%, transparent);
3200
- transition: opacity 160ms var(--rf-ease);
3201
- }
3202
- .picker-hint {
3203
- position: fixed;
3204
- left: 50%;
3205
- bottom: calc(24px + env(safe-area-inset-bottom));
3206
- transform: translateX(-50%);
3207
- display: flex;
3208
- align-items: center;
3209
- gap: 6px;
3210
- max-width: calc(100vw - 24px);
3211
- padding: 5px 6px 5px 12px;
3212
- font-size: 12.5px;
3213
- color: var(--rf-text);
3214
- background: var(--rf-bg);
3215
- border-radius: var(--rf-pill);
3216
- box-shadow: var(--rf-ring), var(--rf-shadow);
3217
- animation: rf-fade 240ms var(--rf-ease);
3218
- }
3219
- .picker-instruction { display: flex; align-items: center; gap: 6px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
3220
- .picker-hint kbd {
3221
- padding: 1px 5px;
3222
- font: inherit;
3223
- font-size: 11px;
3224
- color: var(--rf-text-muted);
3225
- border-radius: 4px;
3226
- background: var(--rf-bg-subtle);
3227
- box-shadow: var(--rf-shadow-inset);
3228
- }
3229
- .picker-cancel {
3230
- display: inline-flex;
3231
- align-items: center;
3232
- height: 40px;
3233
- padding: 0 14px;
3234
- border-radius: var(--rf-pill);
3235
- font-size: 12.5px;
3236
- font-weight: 500;
3237
- white-space: nowrap;
3238
- color: var(--rf-text-muted);
3239
- }
3240
- .picker-cancel:hover { background: var(--rf-bg-hover); color: var(--rf-text); }
3241
- .picker-note {
3242
- position: fixed;
3243
- z-index: ${REFLET_Z_INDEX.annotatorOverlay};
3244
- display: flex;
3245
- flex-direction: column;
3246
- gap: 6px;
3247
- width: 300px;
3248
- max-width: calc(100vw - 24px);
3249
- padding: 6px 6px 6px 8px;
3250
- border-radius: 18px;
3251
- animation: rf-in 180ms var(--rf-ease);
3252
- }
3253
- .picker-note:focus-within { outline: 2px solid var(--rf-accent); outline-offset: 2px; }
3254
- .picker-note-target {
3255
- display: flex;
3256
- align-items: center;
3257
- gap: 6px;
3258
- margin: 0;
3259
- padding: 2px 4px 0;
3260
- font-size: 11px;
3261
- color: var(--rf-text-muted);
3262
- white-space: nowrap;
2953
+ function writeDismissedUntil(key, until) {
2954
+ try {
2955
+ window.localStorage.setItem(key, String(until));
2956
+ } catch {}
3263
2957
  }
3264
- .picker-note-target strong { flex: none; font-weight: 600; color: var(--rf-accent); }
3265
- .picker-note-target span { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
3266
- .picker-note textarea {
3267
- width: 100%;
3268
- min-height: 46px;
3269
- max-height: min(160px, 30dvh);
3270
- margin: 0;
3271
- padding: 7px 6px 0;
3272
- border: 0;
3273
- resize: none;
3274
- background: transparent;
3275
- color: var(--rf-text);
3276
- font-size: 13px;
3277
- line-height: 19px;
2958
+ function matchesHotkey(event, hotkey, platform = typeof navigator === "undefined" ? "" : navigator.platform) {
2959
+ const parts = hotkey.toLowerCase().split("+").map((part) => part.trim());
2960
+ const key = parts.at(-1);
2961
+ if (!key || event.key.toLowerCase() !== key) return false;
2962
+ const isApple = IS_APPLE.test(platform);
2963
+ const wantsMod = parts.includes("mod");
2964
+ const expected = {
2965
+ alt: parts.includes("alt") || parts.includes("option"),
2966
+ ctrl: parts.includes("ctrl") || wantsMod && !isApple,
2967
+ meta: parts.includes("meta") || parts.includes("cmd") || wantsMod && isApple,
2968
+ shift: parts.includes("shift")
2969
+ };
2970
+ return event.altKey === expected.alt && event.ctrlKey === expected.ctrl && event.metaKey === expected.meta && event.shiftKey === expected.shift;
3278
2971
  }
3279
- .picker-note textarea:focus { outline: none; }
3280
- .picker-note textarea::placeholder { color: var(--rf-text-muted); }
3281
- .picker-note-actions { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
3282
- @media (pointer: coarse) {
3283
- .picker-hint { padding: 6px 8px 6px 14px; }
3284
- .picker-hint kbd { display: none; }
3285
- .picker-cancel { height: 44px; padding: 0 18px; font-size: 14px; }
3286
- .picker-note textarea { font-size: 16px; min-height: 56px; }
2972
+ function useWidgetState(props) {
2973
+ const { client, dismissalKey, dismissForDays, isAnonymous } = useWidgetConfig(props);
2974
+ const [isOpen, setIsOpen] = useState(false);
2975
+ const [annotationTrigger, setAnnotationTrigger] = useState(null);
2976
+ const [step, setStep] = useState("compose");
2977
+ const [message, setMessage] = useState("");
2978
+ const [email, setEmail] = useState("");
2979
+ const [honeypot, setHoneypot] = useState("");
2980
+ const [isElementCapturing, setIsElementCapturing] = useState(false);
2981
+ const successTimer = useRef(null);
2982
+ const [error, setError] = useState(null);
2983
+ const [dismissedUntil, setDismissedUntil] = useState(() => readDismissedUntil(dismissalKey));
2984
+ const pickRef = useRef(0);
2985
+ useEffect(() => () => {
2986
+ pickRef.current++;
2987
+ if (successTimer.current) clearTimeout(successTimer.current);
2988
+ }, []);
2989
+ const consoleRef = useRef(null);
2990
+ useEffect(() => {
2991
+ setDismissedUntil(readDismissedUntil(dismissalKey));
2992
+ }, [dismissalKey]);
2993
+ useEffect(() => {
2994
+ if (props.captureConsole === false) return;
2995
+ const recorder = startConsoleRecorder();
2996
+ consoleRef.current = recorder;
2997
+ return () => {
2998
+ consoleRef.current = null;
2999
+ recorder.stop();
3000
+ };
3001
+ }, [props.captureConsole]);
3002
+ const captureFailed = props.labels?.captureFailed ?? DEFAULT_WIDGET_LABELS.captureFailed;
3003
+ const gallery = useScreenshotDrafts(captureFailed, setError);
3004
+ const { activeScreenshot, cancelPendingCapture, refreshAutomatic, resetScreenshots, storeScreenshot } = gallery;
3005
+ const capture = activeScreenshot?.image ?? null;
3006
+ const annotations = activeScreenshot?.annotations ?? [];
3007
+ const isCapturing = gallery.pendingCapture !== null || isElementCapturing;
3008
+ const submission = useFeedbackSubmission({
3009
+ client,
3010
+ onError: setError,
3011
+ onSubmitted: (feedbackId) => {
3012
+ setStep("success");
3013
+ props.onSubmit?.({ feedbackId });
3014
+ successTimer.current = setTimeout(close, SUCCESS_CLOSE_DELAY);
3015
+ },
3016
+ partialUploadMessage: props.labels?.attachmentUploadFailed ?? DEFAULT_WIDGET_LABELS.attachmentUploadFailed
3017
+ });
3018
+ const { resetSubmission } = submission;
3019
+ const firstCapture = gallery.pendingCapture ?? gallery.screenshots[0];
3020
+ const canRefreshAutomatically = gallery.screenshots.length <= 1 && firstCapture?.source === "automatic";
3021
+ useCaptureSync(isOpen && !submission.isEditingDisabled && step === "compose" && canRefreshAutomatically && annotations.length === 0 && !gallery.screenshots.some((draft) => draft.selection) && props.captureOnOpen !== false, refreshAutomatic);
3022
+ const reset = useCallback(() => {
3023
+ if (successTimer.current) clearTimeout(successTimer.current);
3024
+ setAnnotationTrigger(null);
3025
+ resetSubmission();
3026
+ pickRef.current++;
3027
+ setIsElementCapturing(false);
3028
+ resetScreenshots();
3029
+ setMessage("");
3030
+ setEmail("");
3031
+ setHoneypot("");
3032
+ setError(null);
3033
+ setStep("compose");
3034
+ }, [resetSubmission, resetScreenshots]);
3035
+ const close = useCallback(() => {
3036
+ setIsOpen(false);
3037
+ reset();
3038
+ props.onClose?.();
3039
+ }, [reset, props.onClose]);
3040
+ const dismiss = useCallback(() => {
3041
+ if (dismissForDays === null) return;
3042
+ const until = Date.now() + dismissForDays * MILLISECONDS_PER_DAY;
3043
+ writeDismissedUntil(dismissalKey, until);
3044
+ setIsOpen(false);
3045
+ reset();
3046
+ setDismissedUntil(until);
3047
+ props.onDismiss?.({ until });
3048
+ }, [
3049
+ dismissForDays,
3050
+ dismissalKey,
3051
+ props.onDismiss,
3052
+ reset
3053
+ ]);
3054
+ const open = useCallback(() => {
3055
+ setIsOpen(true);
3056
+ setStep("compose");
3057
+ props.onOpen?.();
3058
+ if (props.captureOnOpen !== false) refreshAutomatic();
3059
+ }, [
3060
+ props.captureOnOpen,
3061
+ props.onOpen,
3062
+ refreshAutomatic
3063
+ ]);
3064
+ const selectElement = useCallback(async (element, note = "") => {
3065
+ cancelPendingCapture();
3066
+ setIsElementCapturing(true);
3067
+ const picked = note ? {
3068
+ ...buildElementSelection(element),
3069
+ comment: note.slice(0, MAX_SELECTION_COMMENT_LENGTH)
3070
+ } : buildElementSelection(element);
3071
+ setStep("compose");
3072
+ const pick = ++pickRef.current;
3073
+ const context = collectPageContext({ sdkVersion: SDK_VERSION });
3074
+ const [fresh, closeUp] = await Promise.all([captureViewport().catch(() => null), captureElementCloseUp(element).catch(() => null)]);
3075
+ if (pickRef.current !== pick) {
3076
+ releaseCapture(fresh);
3077
+ releaseCapture(closeUp);
3078
+ return;
3079
+ }
3080
+ setIsElementCapturing(false);
3081
+ if (!fresh) {
3082
+ releaseCapture(closeUp);
3083
+ setError(captureFailed);
3084
+ return;
3085
+ }
3086
+ storeScreenshot({
3087
+ annotations: [highlightFor(picked, fresh)],
3088
+ closeUp,
3089
+ context,
3090
+ id: crypto.randomUUID(),
3091
+ image: fresh,
3092
+ selectedNode: element,
3093
+ selection: picked,
3094
+ source: "manual"
3095
+ });
3096
+ }, [
3097
+ cancelPendingCapture,
3098
+ captureFailed,
3099
+ storeScreenshot
3100
+ ]);
3101
+ const selections = gallery.screenshots.flatMap(({ selection }) => selection ? [selection] : []);
3102
+ const firstElementNote = selections.find(({ comment }) => comment)?.comment;
3103
+ const reportedMessage = message.trim() || firstElementNote || "";
3104
+ const submit = () => {
3105
+ if (honeypot) {
3106
+ setStep("success");
3107
+ return;
3108
+ }
3109
+ setStep("compose");
3110
+ return submission.submit({
3111
+ context: {
3112
+ ...activeScreenshot?.context ?? collectPageContext({ sdkVersion: "0.4.3" }),
3113
+ consoleEvents: consoleRef.current?.events(),
3114
+ metadata: props.metadata,
3115
+ selections
3116
+ },
3117
+ email,
3118
+ isAnonymous,
3119
+ message: reportedMessage,
3120
+ screenshots: gallery.screenshots
3121
+ });
3122
+ };
3123
+ const requestSubmit = () => {
3124
+ if (isAnonymous && email.length === 0 && !submission.hasPendingAttachments && !honeypot) {
3125
+ setStep("email");
3126
+ return;
3127
+ }
3128
+ return submit();
3129
+ };
3130
+ return {
3131
+ activeScreenshot,
3132
+ annotateScreenshot: (id, trigger) => {
3133
+ setAnnotationTrigger(trigger);
3134
+ gallery.selectScreenshot(id);
3135
+ setStep("annotate");
3136
+ },
3137
+ annotations,
3138
+ annotationTrigger,
3139
+ canDismiss: dismissForDays !== null,
3140
+ canSubmit: reportedMessage.length > 0,
3141
+ capture,
3142
+ close,
3143
+ dismiss,
3144
+ dismissForDays,
3145
+ email,
3146
+ error,
3147
+ hasPendingAttachments: submission.hasPendingAttachments,
3148
+ honeypot,
3149
+ isAnonymous,
3150
+ isCapturing,
3151
+ isDismissed: dismissedUntil > Date.now(),
3152
+ isEditingDisabled: submission.isEditingDisabled,
3153
+ isOpen,
3154
+ isSubmitting: submission.isSubmitting,
3155
+ message,
3156
+ open,
3157
+ pendingCapture: gallery.pendingCapture,
3158
+ removeCapture: gallery.removeCapture,
3159
+ requestSubmit,
3160
+ retakeCapture: gallery.retakeCapture,
3161
+ screenshots: gallery.screenshots,
3162
+ selectElement,
3163
+ selections,
3164
+ setAnnotations: gallery.setAnnotations,
3165
+ setEmail,
3166
+ setHoneypot,
3167
+ setMessage,
3168
+ setSelectionComment: gallery.setSelectionComment,
3169
+ setStep,
3170
+ step,
3171
+ submit,
3172
+ takeCapture: gallery.takeCapture
3173
+ };
3287
3174
  }
3288
-
3289
- `;
3290
3175
  //#endregion
3291
- //#region src/feedback/ui/styles.ts
3292
- const WIDGET_STYLES = `
3293
- :host {
3294
- --rf-offset: 20px;
3295
- --rf-bg: #ffffff;
3296
- --rf-bg-subtle: rgb(55 53 47 / 4%);
3297
- --rf-bg-hover: rgb(55 53 47 / 7%);
3298
- --rf-border: rgb(55 53 47 / 9%);
3299
- --rf-hairline: rgb(15 15 15 / 10%);
3300
- --rf-text: #37352f;
3301
- --rf-text-muted: rgb(55 53 47 / 68%);
3302
- --rf-primary: #37352f;
3303
- --rf-primary-text: #ffffff;
3304
- --rf-accent: #2383e2;
3305
- --rf-accent-text: #ffffff;
3306
- --rf-danger: #d44c47;
3307
- --rf-success: #448361;
3308
- --rf-swatch-base: #ffffff;
3309
- --rf-spectrum: conic-gradient(#ff5757, #ffde59, #6bdb78, #4ac9fa, #8b5cf6, #ef73dc, #ff5757);
3310
- --rf-radius: 20px;
3311
- --rf-pill: 999px;
3312
- --rf-attachment-height: 78px;
3313
- --rf-width: 360px;
3314
- --rf-glass: rgb(255 255 255 / 72%);
3315
- --rf-glass-highlight: rgb(255 255 255 / 90%);
3316
- --rf-glass-stroke: rgb(255 255 255 / 70%);
3317
- --rf-radius-sm: 8px;
3318
- --rf-shadow-inset: inset 0 0 0 1px var(--rf-border);
3319
- --rf-ring: 0 0 0 1px rgb(15 15 15 / 4%);
3320
- --rf-shadow-low: 0 1px 2px rgb(15 15 15 / 6%), 0 4px 10px -4px rgb(15 15 15 / 10%);
3321
- --rf-shadow: 0 2px 5px -1px rgb(15 15 15 / 7%), 0 12px 32px -8px rgb(15 15 15 / 16%);
3322
- --rf-shadow-float: 0 4px 12px -3px rgb(15 15 15 / 35%);
3323
- --rf-ease: cubic-bezier(0.16, 1, 0.3, 1);
3324
- color-scheme: light;
3325
- all: initial;
3326
- font-family: var(--rf-font, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif);
3327
- }
3328
-
3329
- :host([data-theme="dark"]) {
3330
- --rf-bg: #202020;
3331
- --rf-bg-subtle: rgb(255 255 255 / 5%);
3332
- --rf-bg-hover: rgb(255 255 255 / 9%);
3333
- --rf-border: rgb(255 255 255 / 9%);
3334
- --rf-hairline: rgb(255 255 255 / 12%);
3335
- --rf-text: #ebeae8;
3336
- --rf-text-muted: rgb(255 255 255 / 68%);
3337
- --rf-glass: rgb(32 32 32 / 78%);
3338
- --rf-glass-highlight: rgb(255 255 255 / 14%);
3339
- --rf-glass-stroke: rgb(255 255 255 / 12%);
3340
- --rf-primary: #f1efec;
3341
- --rf-primary-text: #1c1c1b;
3342
- --rf-accent: #529cca;
3343
- --rf-danger: #eb5757;
3344
- --rf-success: #4dab74;
3345
- --rf-ring: 0 0 0 1px rgb(255 255 255 / 7%);
3346
- --rf-shadow-low: 0 1px 2px rgb(0 0 0 / 30%), 0 4px 10px -4px rgb(0 0 0 / 40%);
3347
- --rf-shadow: 0 2px 5px -1px rgb(0 0 0 / 35%), 0 14px 36px -10px rgb(0 0 0 / 55%);
3348
- --rf-shadow-float: 0 4px 14px -3px rgb(0 0 0 / 55%);
3349
- color-scheme: dark;
3176
+ //#region src/feedback/ui/visible-viewport.ts
3177
+ /**
3178
+ * The part of the page the reporter can actually see, in fixed-position
3179
+ * coordinates. On mobile the keyboard shrinks this without touching
3180
+ * `window.innerHeight`, so anything that must stay reachable measures here.
3181
+ */
3182
+ function visibleViewport() {
3183
+ const viewport = window.visualViewport;
3184
+ const left = viewport?.offsetLeft ?? 0;
3185
+ const top = viewport?.offsetTop ?? 0;
3186
+ const width = viewport?.width ?? window.innerWidth;
3187
+ const height = viewport?.height ?? window.innerHeight;
3188
+ return {
3189
+ bottom: window.innerHeight - top - height,
3190
+ height,
3191
+ left,
3192
+ right: window.innerWidth - left - width,
3193
+ top,
3194
+ width
3195
+ };
3350
3196
  }
3351
-
3352
- *, *::before, *::after { box-sizing: border-box; }
3353
-
3354
-
3355
- .root {
3356
- position: fixed;
3357
- z-index: ${REFLET_Z_INDEX.widgetRoot};
3358
- display: flex;
3359
- align-items: flex-end;
3360
- gap: 8px;
3361
- font: 13px/1.45 var(--rf-font, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif);
3362
- color: var(--rf-text);
3197
+ function onViewportChange(update) {
3198
+ window.addEventListener("resize", update);
3199
+ window.visualViewport?.addEventListener("resize", update);
3200
+ window.visualViewport?.addEventListener("scroll", update);
3201
+ return () => {
3202
+ window.removeEventListener("resize", update);
3203
+ window.visualViewport?.removeEventListener("resize", update);
3204
+ window.visualViewport?.removeEventListener("scroll", update);
3205
+ };
3363
3206
  }
3364
- .root:has(.panel) { width: min(var(--rf-width), calc(var(--rf-viewport-width, 100vw) - 24px)); }
3365
- .root[data-position$="right"] { right: calc(var(--rf-viewport-right, 0px) + max(12px, min(var(--rf-offset), calc((var(--rf-viewport-width, 100vw) - var(--rf-width)) / 2)))); }
3366
- .root[data-position$="left"] { left: calc(var(--rf-viewport-left, 0px) + max(12px, min(var(--rf-offset), calc((var(--rf-viewport-width, 100vw) - var(--rf-width)) / 2)))); }
3367
- .root[data-position^="bottom"] { bottom: calc(var(--rf-viewport-bottom, 0px) + max(var(--rf-offset), env(safe-area-inset-bottom))); }
3368
- .root[data-position^="top"] { top: calc(var(--rf-viewport-top, 0px) + max(var(--rf-offset), env(safe-area-inset-top))); align-items: flex-start; }
3369
- .root[data-position="bottom-right"] .panel { transform-origin: bottom right; }
3370
- .root[data-position="bottom-left"] .panel { transform-origin: bottom left; }
3371
- .root[data-position^="top"] .panel { transform-origin: top center; }
3372
- .root[data-editing="true"] { visibility: hidden; pointer-events: none; }
3373
- button, select, input, textarea { font: inherit; color: inherit; }
3374
- button { margin: 0; cursor: pointer; border: 0; background: none; transition: scale 120ms var(--rf-ease); }
3375
- button:disabled { cursor: not-allowed; opacity: .4; }
3376
- button:active:not(:disabled) { scale: .97; }
3377
- svg { flex-shrink: 0; }
3378
- :focus-visible { outline: 2px solid var(--rf-accent); outline-offset: 3px; }
3379
- .icon-btn, .tool {
3380
- display: inline-flex; align-items: center; justify-content: center;
3381
- width: 40px; height: 40px; flex: none; border-radius: var(--rf-pill); color: var(--rf-text-muted);
3382
- transition: background 160ms var(--rf-ease), color 160ms var(--rf-ease), scale 120ms var(--rf-ease);
3207
+ //#endregion
3208
+ //#region src/feedback/ui/floating/use-floating-position.ts
3209
+ const EDGE_GAP$1 = 12;
3210
+ const KEYBOARD_STEP = 24;
3211
+ const KEY_DIRECTIONS = {
3212
+ ArrowDown: {
3213
+ x: 0,
3214
+ y: KEYBOARD_STEP
3215
+ },
3216
+ ArrowLeft: {
3217
+ x: -24,
3218
+ y: 0
3219
+ },
3220
+ ArrowRight: {
3221
+ x: KEYBOARD_STEP,
3222
+ y: 0
3223
+ },
3224
+ ArrowUp: {
3225
+ x: 0,
3226
+ y: -24
3227
+ }
3228
+ };
3229
+ function clampPosition(point, bounds, viewport = visibleViewport()) {
3230
+ return {
3231
+ x: Math.max(viewport.left + EDGE_GAP$1, Math.min(point.x, viewport.left + viewport.width - bounds.width - EDGE_GAP$1)),
3232
+ y: Math.max(viewport.top + EDGE_GAP$1, Math.min(point.y, viewport.top + viewport.height - bounds.height - EDGE_GAP$1))
3233
+ };
3383
3234
  }
3384
- .icon-btn:hover, .tool:hover { background: var(--rf-bg-hover); color: var(--rf-text); }
3385
- .launcher {
3386
- display: inline-flex; align-items: center; gap: 7px; height: 40px; padding: 0 15px;
3387
- border-radius: var(--rf-pill); color: var(--rf-text);
3388
- font-weight: 500;
3389
- transition: transform 200ms var(--rf-ease), box-shadow 200ms var(--rf-ease), scale 120ms var(--rf-ease);
3235
+ function useFloatingPosition(corner = "bottom-right", isPanelOpen = false) {
3236
+ const rootRef = useRef(null);
3237
+ const gesture = useRef(null);
3238
+ const [position, setPosition] = useState(null);
3239
+ const [positionedPanelOpen, setPositionedPanelOpen] = useState(isPanelOpen);
3240
+ const [viewport, setViewport] = useState(visibleViewport);
3241
+ const horizontalEdge = corner.endsWith("right") ? "right" : "left";
3242
+ const verticalEdge = corner.startsWith("bottom") ? "bottom" : "top";
3243
+ if (positionedPanelOpen !== isPanelOpen) {
3244
+ setPositionedPanelOpen(isPanelOpen);
3245
+ setPosition(null);
3246
+ }
3247
+ const moveTo = useCallback((point, bounds) => {
3248
+ const fitted = clampPosition(point, bounds);
3249
+ const visible = visibleViewport();
3250
+ const anchor = {
3251
+ x: horizontalEdge === "right" ? visible.left + visible.width - fitted.x - bounds.width : fitted.x - visible.left,
3252
+ y: verticalEdge === "bottom" ? visible.top + visible.height - fitted.y - bounds.height : fitted.y - visible.top
3253
+ };
3254
+ setPosition((current) => current?.x === anchor.x && current.y === anchor.y ? current : anchor);
3255
+ }, [horizontalEdge, verticalEdge]);
3256
+ const keepInViewport = useCallback(() => {
3257
+ const bounds = rootRef.current?.getBoundingClientRect();
3258
+ if (!bounds) return;
3259
+ const fitted = clampPosition(bounds, bounds, viewport);
3260
+ if (Math.abs(fitted.x - bounds.x) > .5 || Math.abs(fitted.y - bounds.y) > .5) moveTo(fitted, bounds);
3261
+ }, [moveTo, viewport]);
3262
+ useLayoutEffect(() => {
3263
+ keepInViewport();
3264
+ }, [keepInViewport]);
3265
+ useEffect(() => {
3266
+ const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(keepInViewport);
3267
+ if (rootRef.current) observer?.observe(rootRef.current);
3268
+ const stopListening = onViewportChange(() => setViewport(visibleViewport()));
3269
+ return () => {
3270
+ observer?.disconnect();
3271
+ stopListening();
3272
+ };
3273
+ }, [keepInViewport]);
3274
+ const onPointerDown = (event) => {
3275
+ const bounds = rootRef.current?.getBoundingClientRect();
3276
+ if (!bounds || event.button !== 0) return;
3277
+ event.currentTarget.setPointerCapture(event.pointerId);
3278
+ gesture.current = {
3279
+ x: event.clientX - bounds.x,
3280
+ y: event.clientY - bounds.y
3281
+ };
3282
+ };
3283
+ const onPointerMove = (event) => {
3284
+ const bounds = rootRef.current?.getBoundingClientRect();
3285
+ const grab = gesture.current;
3286
+ if (!(bounds && grab && event.currentTarget.hasPointerCapture(event.pointerId))) return;
3287
+ moveTo({
3288
+ x: event.clientX - grab.x,
3289
+ y: event.clientY - grab.y
3290
+ }, bounds);
3291
+ };
3292
+ const onKeyDown = (event) => {
3293
+ const direction = KEY_DIRECTIONS[event.key];
3294
+ const bounds = rootRef.current?.getBoundingClientRect();
3295
+ if (!(bounds && direction)) return;
3296
+ event.preventDefault();
3297
+ moveTo({
3298
+ x: bounds.x + direction.x,
3299
+ y: bounds.y + direction.y
3300
+ }, bounds);
3301
+ };
3302
+ const viewportStyles = `.root { --rf-viewport-width: ${viewport.width}px; --rf-viewport-top: ${viewport.top}px; --rf-viewport-bottom: ${viewport.bottom}px; --rf-viewport-left: ${viewport.left}px; --rf-viewport-right: ${viewport.right}px; }`;
3303
+ const anchorStyles = position ? `.root[data-moved="true"] { left: auto; right: auto; top: auto; bottom: auto; ${horizontalEdge}: ${position.x + viewport[horizontalEdge]}px; ${verticalEdge}: ${position.y + viewport[verticalEdge]}px; }` : "";
3304
+ return {
3305
+ handleProps: {
3306
+ onKeyDown,
3307
+ onLostPointerCapture: () => {
3308
+ gesture.current = null;
3309
+ },
3310
+ onPointerCancel: () => {
3311
+ gesture.current = null;
3312
+ },
3313
+ onPointerDown,
3314
+ onPointerMove,
3315
+ onPointerUp: () => {
3316
+ gesture.current = null;
3317
+ }
3318
+ },
3319
+ position,
3320
+ rootRef,
3321
+ styles: viewportStyles + anchorStyles
3322
+ };
3390
3323
  }
3391
- .launcher:hover { transform: translateY(-2px); box-shadow: var(--rf-shadow); }
3392
- .launcher-icon { display: flex; width: 15px; height: 15px; }
3393
- .launcher-icon .on-close { display: none; }
3394
- .floating-controls {
3395
- display: flex; align-items: center; flex: none;
3396
- margin-right: auto; padding-left: 3px;
3397
- animation: rf-in 220ms var(--rf-ease);
3324
+ //#endregion
3325
+ //#region src/feedback/ui/floating/floating-widget.tsx
3326
+ function FloatingWidget({ hotkey, labels, position, state }) {
3327
+ const [minimized, setMinimized] = useState(false);
3328
+ const launcherRef = useRef(null);
3329
+ const showPanel = state.isOpen && !minimized;
3330
+ const floating = useFloatingPosition(position, showPanel);
3331
+ const minimize = () => {
3332
+ setMinimized(true);
3333
+ requestAnimationFrame(() => launcherRef.current?.focus());
3334
+ };
3335
+ useEffect(() => {
3336
+ if (state.step === "compose") state.annotationTrigger?.button.focus({ preventScroll: true });
3337
+ }, [state.step, state.annotationTrigger]);
3338
+ useEffect(() => {
3339
+ if (!showPanel || state.step !== "compose") return;
3340
+ const onKeyDown = (event) => {
3341
+ if (event.key === "Escape" && !event.defaultPrevented) {
3342
+ setMinimized(true);
3343
+ requestAnimationFrame(() => launcherRef.current?.focus());
3344
+ }
3345
+ };
3346
+ return listenToKeydown(floating.rootRef.current, onKeyDown);
3347
+ }, [
3348
+ floating.rootRef,
3349
+ showPanel,
3350
+ state.step
3351
+ ]);
3352
+ const { close, isOpen, open } = state;
3353
+ useEffect(() => {
3354
+ if (!hotkey) return;
3355
+ const toggleOnHotkey = (event) => {
3356
+ if (matchesHotkey(event, hotkey)) {
3357
+ event.preventDefault();
3358
+ if (isOpen) close();
3359
+ else open();
3360
+ }
3361
+ };
3362
+ return listenToKeydown(floating.rootRef.current, toggleOnHotkey, true);
3363
+ }, [
3364
+ close,
3365
+ floating.rootRef,
3366
+ hotkey,
3367
+ isOpen,
3368
+ open
3369
+ ]);
3370
+ return /* @__PURE__ */ jsxs("div", {
3371
+ className: "root",
3372
+ "data-editing": state.isOpen && (state.step === "picking" || state.step === "annotate"),
3373
+ "data-moved": Boolean(floating.position),
3374
+ "data-position": position,
3375
+ ref: floating.rootRef,
3376
+ children: [
3377
+ /* @__PURE__ */ jsx("style", { children: floating.styles }),
3378
+ showPanel && state.step === "compose" && state.screenshots.map(({ id, selectedNode }) => selectedNode ? /* @__PURE__ */ jsx(SelectionOutline, { node: selectedNode }, id) : null),
3379
+ showPanel ? /* @__PURE__ */ jsx(FeedbackPanel, {
3380
+ floatingControls: /* @__PURE__ */ jsxs("div", {
3381
+ className: "floating-controls",
3382
+ children: [/* @__PURE__ */ jsx("button", {
3383
+ "aria-label": labels.moveFeedback,
3384
+ className: "icon-btn drag-handle",
3385
+ title: labels.moveFeedback,
3386
+ type: "button",
3387
+ ...floating.handleProps,
3388
+ children: /* @__PURE__ */ jsx(GripIcon, {})
3389
+ }), /* @__PURE__ */ jsx("button", {
3390
+ "aria-label": labels.minimize,
3391
+ className: "icon-btn",
3392
+ onClick: minimize,
3393
+ title: labels.minimize,
3394
+ type: "button",
3395
+ children: /* @__PURE__ */ jsx(CloseIcon, {})
3396
+ })]
3397
+ }),
3398
+ labels,
3399
+ state
3400
+ }) : /* @__PURE__ */ jsx(Launcher, {
3401
+ buttonRef: launcherRef,
3402
+ isOpen: false,
3403
+ label: state.isOpen ? labels.resume : labels.trigger,
3404
+ onClick: () => {
3405
+ setMinimized(false);
3406
+ if (!state.isOpen) state.open();
3407
+ }
3408
+ })
3409
+ ]
3410
+ });
3398
3411
  }
3399
- .drag-handle { touch-action: none; cursor: grab; }
3400
- .drag-handle:active { cursor: grabbing; }
3401
- .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
3402
- .ghost-btn { display: flex; align-items: center; gap: 8px; min-height: 40px; padding: 0 8px; border-radius: var(--rf-radius-sm); }
3403
- .ghost-btn:hover { background: var(--rf-bg-hover); }
3404
- .error { margin: 0; padding: 10px 14px; border-radius: var(--rf-radius); font-size: 12px; color: var(--rf-danger); }
3405
- .done { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 24px; text-align: center; }
3406
- .done svg { color: var(--rf-success); }
3407
- .done h2 { margin: 0; font-size: 14px; font-weight: 600; }
3408
- .done p { margin: 0; font-size: 12px; color: var(--rf-text-muted); }
3409
- .spinner { width: 14px; height: 14px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: rf-spin 700ms linear infinite; }
3410
- .hp { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
3411
- @keyframes rf-spin { to { transform: rotate(360deg); } }
3412
- @keyframes rf-in { from { opacity: 0; transform: translateY(5px) scale(.98); } }
3413
- @keyframes rf-fade { from { opacity: 0; } }
3414
- @media (pointer: coarse) {
3415
- .icon-btn, .tool { width: 44px; height: 44px; }
3416
- .launcher, .ghost-btn { min-height: 44px; }
3412
+ //#endregion
3413
+ //#region src/feedback/ui/floating/element-walk.ts
3414
+ const ARROW_SIBLING = {
3415
+ ArrowLeft: "previousElementSibling",
3416
+ ArrowRight: "nextElementSibling"
3417
+ };
3418
+ const AIM_KEYS = [
3419
+ "Tab",
3420
+ "ArrowUp",
3421
+ "ArrowDown",
3422
+ "ArrowLeft",
3423
+ "ArrowRight"
3424
+ ];
3425
+ function isWidgetOwned(element) {
3426
+ return element.closest(`[${WIDGET_MARKER}]`) !== null;
3417
3427
  }
3418
- .glass {
3419
- position: relative;
3420
- isolation: isolate;
3421
- border: 1px solid var(--rf-glass-stroke);
3422
- box-shadow: inset 0 1px 0 var(--rf-glass-highlight), var(--rf-shadow);
3428
+ function isPickable(element) {
3429
+ if (!element || element === document.documentElement || isWidgetOwned(element)) return false;
3430
+ const rect = element.getBoundingClientRect();
3431
+ return rect.width > 0 && rect.height > 0;
3423
3432
  }
3424
- .glass::before {
3425
- content: "";
3426
- position: absolute;
3427
- inset: 0;
3428
- z-index: -1;
3429
- border-radius: inherit;
3430
- background: var(--rf-bg);
3431
- pointer-events: none;
3433
+ function pickableElements() {
3434
+ return Array.from(document.body.querySelectorAll("*")).filter(isPickable);
3432
3435
  }
3433
- @supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) {
3434
- .glass::before {
3435
- background: var(--rf-glass);
3436
- -webkit-backdrop-filter: blur(20px) saturate(1.6);
3437
- backdrop-filter: blur(20px) saturate(1.6);
3438
- }
3436
+ function documentStep(from, backwards) {
3437
+ const all = pickableElements();
3438
+ const index = all.indexOf(from);
3439
+ if (all.length === 0) return null;
3440
+ if (index === -1) return all[0] ?? null;
3441
+ return all[(index + (backwards ? -1 : 1) + all.length) % all.length] ?? null;
3439
3442
  }
3440
- @media (prefers-reduced-transparency: reduce), (prefers-contrast: more) {
3441
- .glass::before { background: var(--rf-bg); backdrop-filter: none; -webkit-backdrop-filter: none; }
3443
+ function climb(from) {
3444
+ let candidate = from.parentElement;
3445
+ while (candidate && !isPickable(candidate)) candidate = candidate.parentElement;
3446
+ return candidate;
3442
3447
  }
3443
- .capture-halo { position: fixed; inset: 0; z-index: ${REFLET_Z_INDEX.captureHalo}; pointer-events: none; opacity: 0; transition: opacity 500ms var(--rf-ease); }
3444
- .capture-halo[data-active="true"] { opacity: 1; transition-duration: 140ms; }
3445
- .capture-halo::before { content: ""; position: absolute; inset: 0; border-radius: 20px; box-shadow: inset 14px 0 36px -16px var(--rf-accent), inset -14px 0 36px -16px var(--rf-accent), inset 0 0 4px 1px color-mix(in srgb, var(--rf-accent) 35%, transparent); }
3446
- .capture-halo::after { content: ""; position: absolute; inset: 0; background: linear-gradient(90deg, color-mix(in srgb, var(--rf-accent) 20%, transparent), transparent 5%, transparent 95%, color-mix(in srgb, var(--rf-accent) 20%, transparent)); }
3447
- ${COMPOSER_STYLES}
3448
- ${ANNOTATOR_STYLES}
3449
- ${PICKER_STYLES}
3450
- @media (prefers-reduced-motion: reduce) {
3451
- *, *::before, *::after { animation: none !important; transition: none !important; }
3452
- button:active:not(:disabled) { scale: 1; }
3448
+ function descend(from) {
3449
+ let candidate = from.firstElementChild;
3450
+ while (candidate && !isPickable(candidate)) candidate = candidate.nextElementSibling;
3451
+ return candidate;
3453
3452
  }
3454
- `;
3455
- //#endregion
3456
- //#region src/feedback/ui/shadow-portal.tsx
3457
- const DARK_QUERY = "(prefers-color-scheme: dark)";
3458
- const KEY_EVENTS = [
3459
- "keydown",
3460
- "keyup",
3461
- "keypress"
3462
- ];
3463
- function useResolvedTheme(theme) {
3464
- const [systemDark, setSystemDark] = useState(false);
3465
- useEffect(() => {
3466
- if (theme !== "auto" || typeof window.matchMedia !== "function") return;
3467
- const query = window.matchMedia(DARK_QUERY);
3468
- setSystemDark(query.matches);
3469
- const onChange = (event) => setSystemDark(event.matches);
3470
- query.addEventListener("change", onChange);
3471
- return () => query.removeEventListener("change", onChange);
3472
- }, [theme]);
3473
- if (theme === "auto") return systemDark ? "dark" : "light";
3474
- return theme;
3453
+ function sibling(from, direction) {
3454
+ let candidate = from[direction];
3455
+ while (candidate && !isPickable(candidate)) candidate = candidate[direction];
3456
+ return candidate;
3475
3457
  }
3476
- /**
3477
- * Hosts the widget in a shadow root: the app's CSS cannot reach the panel and
3478
- * the panel's CSS cannot reach the app. Direct DOM work is unavoidable here —
3479
- * React has no declarative way to create a shadow root.
3480
- */
3481
- function ShadowPortal({ children, offset, primaryColor, theme }) {
3482
- const [shadowRoot, setShadowRoot] = useState(null);
3483
- const resolvedTheme = useResolvedTheme(theme);
3484
- useEffect(() => {
3485
- const host = document.createElement("div");
3486
- host.setAttribute(WIDGET_MARKER, "");
3487
- const root = host.attachShadow({ mode: "open" });
3488
- for (const type of KEY_EVENTS) root.addEventListener(type, (event) => event.stopPropagation());
3489
- const style = document.createElement("style");
3490
- style.textContent = WIDGET_STYLES;
3491
- root.append(style);
3492
- document.body.append(host);
3493
- setShadowRoot(root);
3494
- return () => host.remove();
3495
- }, []);
3496
- useEffect(() => {
3497
- const host = shadowRoot?.host;
3498
- if (!(host instanceof HTMLElement)) return;
3499
- host.setAttribute("data-theme", resolvedTheme);
3500
- host.style.setProperty("--rf-offset", `${offset}px`);
3501
- if (primaryColor) host.style.setProperty("--rf-primary", primaryColor);
3502
- }, [
3503
- shadowRoot,
3504
- resolvedTheme,
3505
- primaryColor,
3506
- offset
3507
- ]);
3508
- if (!shadowRoot) return null;
3509
- return createPortal(children, shadowRoot);
3458
+ function stepAim(from, key, backwards) {
3459
+ if (!(from && isPickable(from))) return pickableElements()[0] ?? null;
3460
+ if (key === "Tab") return documentStep(from, backwards);
3461
+ if (key === "ArrowUp") return climb(from);
3462
+ if (key === "ArrowDown") return descend(from);
3463
+ const direction = ARROW_SIBLING[key];
3464
+ return direction ? sibling(from, direction) : null;
3510
3465
  }
3511
3466
  //#endregion
3512
- //#region src/feedback/core/console-recorder.ts
3513
- const DEFAULT_LIMIT = 30;
3514
- const MAX_MESSAGE_LENGTH = 1e3;
3515
- const RECORDED_METHODS = ["error", "warn"];
3516
- function serializeArgument(value) {
3517
- if (typeof value === "string") return value;
3518
- if (value instanceof Error) return `${value.name}: ${value.message}`;
3519
- if (value === null || value === void 0) return String(value);
3520
- if (typeof value === "object") try {
3521
- return JSON.stringify(value) ?? "[unserializable]";
3522
- } catch {
3523
- return "[unserializable]";
3524
- }
3525
- if (typeof value === "function") return "[function]";
3526
- return String(value);
3467
+ //#region src/feedback/ui/picker.tsx
3468
+ const LABEL_HEIGHT = 24;
3469
+ const LABEL_GAP = 6;
3470
+ const NOTE_WIDTH = 300;
3471
+ const NOTE_GAP = 10;
3472
+ const EDGE_GAP = 12;
3473
+ function elementUnder(x, y) {
3474
+ const element = document.elementFromPoint(x, y);
3475
+ if (!element || isWidgetOwned(element)) return null;
3476
+ return element;
3527
3477
  }
3528
- function formatMessage(args) {
3529
- const message = args.map(serializeArgument).join(" ");
3530
- return message.length > MAX_MESSAGE_LENGTH ? `${message.slice(0, 999)}…` : message;
3478
+ function describeTarget(element) {
3479
+ return {
3480
+ componentName: resolveComponentStack(getFiberFromNode(element))[0],
3481
+ element,
3482
+ label: describeElement(element),
3483
+ rect: element.getBoundingClientRect(),
3484
+ region: describeRegion(element)
3485
+ };
3531
3486
  }
3532
3487
  /**
3533
- * Buffers the console noise a user saw right before reporting a bug.
3534
- * Wrappers delegate to the previous implementation, so nothing is swallowed.
3488
+ * Pins the note card to the element: below it, flipped above when the visible
3489
+ * viewport — what is left of the screen once a mobile keyboard is up — has no
3490
+ * room there. Measures against the part of the element actually on screen, so
3491
+ * picking a full-height section still leaves the card in view.
3535
3492
  */
3536
- function startConsoleRecorder(options) {
3537
- const limit = options?.limit ?? DEFAULT_LIMIT;
3538
- const buffer = [];
3539
- let active = true;
3540
- const push = (level, message) => {
3541
- if (!(active && message)) return;
3542
- buffer.push({
3543
- level,
3544
- message,
3545
- timestamp: Date.now()
3546
- });
3547
- if (buffer.length > limit) buffer.splice(0, buffer.length - limit);
3548
- };
3549
- const originals = /* @__PURE__ */ new Map();
3550
- const wrappers = /* @__PURE__ */ new Map();
3551
- for (const method of RECORDED_METHODS) {
3552
- const original = console[method];
3553
- const wrapper = (...args) => {
3554
- push(method, formatMessage(args));
3555
- Reflect.apply(original, console, args);
3556
- };
3557
- originals.set(method, original);
3558
- wrappers.set(method, wrapper);
3559
- console[method] = wrapper;
3560
- }
3561
- const onError = (event) => {
3562
- const where = event.filename ? ` (${event.filename}:${event.lineno}:${event.colno})` : "";
3563
- push("error", `${event.message}${where}`);
3564
- };
3565
- const onRejection = (event) => {
3566
- push("error", `Unhandled rejection: ${serializeArgument(event.reason)}`);
3493
+ function noteStyle(rect, view) {
3494
+ const left = Math.min(Math.max(view.left + EDGE_GAP, rect.left), Math.max(view.left + EDGE_GAP, view.left + view.width - NOTE_WIDTH - EDGE_GAP));
3495
+ const visibleTop = Math.max(rect.top, view.top + EDGE_GAP);
3496
+ const visibleBottom = Math.min(rect.bottom, view.top + view.height - EDGE_GAP);
3497
+ if (view.top + view.height - visibleBottom > visibleTop - view.top) return {
3498
+ left,
3499
+ top: visibleBottom + NOTE_GAP
3567
3500
  };
3568
- window.addEventListener("error", onError);
3569
- window.addEventListener("unhandledrejection", onRejection);
3570
3501
  return {
3571
- events: () => buffer.slice(),
3572
- stop: () => {
3573
- if (!active) return;
3574
- active = false;
3575
- buffer.length = 0;
3576
- for (const method of RECORDED_METHODS) {
3577
- const original = originals.get(method);
3578
- if (original && console[method] === wrappers.get(method)) console[method] = original;
3579
- }
3580
- window.removeEventListener("error", onError);
3581
- window.removeEventListener("unhandledrejection", onRejection);
3582
- }
3502
+ bottom: window.innerHeight - visibleTop + NOTE_GAP,
3503
+ left
3583
3504
  };
3584
3505
  }
3506
+ /**
3507
+ * Points at any element in the page the way a coding agent needs it: the React
3508
+ * component that owns it plus the page region it lives in. Picking never
3509
+ * commits on its own — it pins a note card to the element, so the reporter
3510
+ * writes about that element right there and confirms. Aiming stays live until
3511
+ * then, which is what makes the flow usable without hover, on touch.
3512
+ */
3513
+ function ElementPicker({ labels, onCancel, onPick }) {
3514
+ const [target, setTarget] = useState(null);
3515
+ const [pinned, setPinned] = useState(null);
3516
+ const [note, setNote] = useState("");
3517
+ const pickerRef = useRef(null);
3518
+ const noteRef = useRef(null);
3519
+ const pinnedRef = useRef(false);
3520
+ const [view, setView] = useState(visibleViewport);
3521
+ const aimRef = useRef(null);
3522
+ useEffect(() => onViewportChange(() => setView(visibleViewport())), []);
3523
+ useEffect(() => {
3524
+ pinnedRef.current = pinned !== null;
3525
+ if (pinned) noteRef.current?.focus({ preventScroll: true });
3526
+ }, [pinned]);
3527
+ useEffect(() => {
3528
+ aimRef.current = target?.element ?? null;
3529
+ }, [target]);
3530
+ useEffect(() => {
3531
+ const swallow = (event) => {
3532
+ if (startsInWidget(event)) return;
3533
+ event.preventDefault();
3534
+ event.stopPropagation();
3535
+ };
3536
+ const aimAt = (x, y) => {
3537
+ const element = elementUnder(x, y);
3538
+ if (!element) return;
3539
+ setTarget((current) => current?.element === element ? {
3540
+ ...current,
3541
+ rect: element.getBoundingClientRect()
3542
+ } : describeTarget(element));
3543
+ };
3544
+ const onMove = (event) => {
3545
+ if (pinnedRef.current || startsInWidget(event)) return;
3546
+ aimAt(event.clientX, event.clientY);
3547
+ };
3548
+ const onPointerDown = (event) => {
3549
+ if (startsInWidget(event)) return;
3550
+ swallow(event);
3551
+ if (!pinnedRef.current) aimAt(event.clientX, event.clientY);
3552
+ };
3553
+ const onPointerUp = (event) => {
3554
+ if (startsInWidget(event)) return;
3555
+ swallow(event);
3556
+ if (pinnedRef.current) return;
3557
+ const element = elementUnder(event.clientX, event.clientY);
3558
+ if (element) setPinned(describeTarget(element));
3559
+ };
3560
+ const moveAim = (key, backwards) => {
3561
+ const next = stepAim(aimRef.current, key, backwards);
3562
+ if (!next) return;
3563
+ next.scrollIntoView({
3564
+ block: "nearest",
3565
+ inline: "nearest"
3566
+ });
3567
+ aimRef.current = next;
3568
+ setTarget(describeTarget(next));
3569
+ };
3570
+ const onEscape = () => {
3571
+ if (pinnedRef.current) {
3572
+ setPinned(null);
3573
+ setNote("");
3574
+ return;
3575
+ }
3576
+ onCancel();
3577
+ };
3578
+ const onKeyDown = (event) => {
3579
+ if (event.key === "Escape") {
3580
+ swallow(event);
3581
+ onEscape();
3582
+ return;
3583
+ }
3584
+ if (pinnedRef.current || startsInWidget(event)) return;
3585
+ if (event.key === "Enter") {
3586
+ swallow(event);
3587
+ if (aimRef.current) setPinned(describeTarget(aimRef.current));
3588
+ return;
3589
+ }
3590
+ if (!AIM_KEYS.includes(event.key)) return;
3591
+ swallow(event);
3592
+ moveAim(event.key, event.shiftKey);
3593
+ };
3594
+ const onScroll = () => {
3595
+ setTarget((current) => current ? {
3596
+ ...current,
3597
+ rect: current.element.getBoundingClientRect()
3598
+ } : current);
3599
+ setPinned((current) => current ? {
3600
+ ...current,
3601
+ rect: current.element.getBoundingClientRect()
3602
+ } : current);
3603
+ };
3604
+ document.addEventListener("pointermove", onMove, true);
3605
+ document.addEventListener("pointerdown", onPointerDown, true);
3606
+ document.addEventListener("pointerup", onPointerUp, true);
3607
+ document.addEventListener("mousedown", swallow, true);
3608
+ document.addEventListener("mouseup", swallow, true);
3609
+ document.addEventListener("click", swallow, true);
3610
+ const stopListeningToKeys = listenToKeydown(pickerRef.current, onKeyDown, true);
3611
+ window.addEventListener("scroll", onScroll, {
3612
+ capture: true,
3613
+ passive: true
3614
+ });
3615
+ const previousCursor = document.body.style.cursor;
3616
+ document.body.style.cursor = "crosshair";
3617
+ return () => {
3618
+ document.removeEventListener("pointermove", onMove, true);
3619
+ document.removeEventListener("pointerdown", onPointerDown, true);
3620
+ document.removeEventListener("pointerup", onPointerUp, true);
3621
+ document.removeEventListener("mousedown", swallow, true);
3622
+ document.removeEventListener("mouseup", swallow, true);
3623
+ document.removeEventListener("click", swallow, true);
3624
+ stopListeningToKeys();
3625
+ window.removeEventListener("scroll", onScroll, true);
3626
+ document.body.style.cursor = previousCursor;
3627
+ };
3628
+ }, [onCancel]);
3629
+ const rect = (pinned ?? target)?.rect;
3630
+ const labelAbove = rect ? rect.top > 30 : true;
3631
+ return /* @__PURE__ */ jsxs("div", {
3632
+ ref: pickerRef,
3633
+ children: [
3634
+ rect && /* @__PURE__ */ jsx("div", {
3635
+ className: "picker-box",
3636
+ "data-pinned": pinned !== null,
3637
+ style: {
3638
+ height: rect.height,
3639
+ translate: `${rect.left}px ${rect.top}px`,
3640
+ width: rect.width
3641
+ }
3642
+ }),
3643
+ rect && target && !pinned && /* @__PURE__ */ jsxs("div", {
3644
+ className: "picker-label",
3645
+ style: { translate: `${Math.max(4, rect.left)}px ${labelAbove ? rect.top - LABEL_HEIGHT - LABEL_GAP : rect.bottom + LABEL_GAP}px` },
3646
+ children: [/* @__PURE__ */ jsx("strong", { children: target.componentName ? `<${target.componentName}>` : target.label }), target.region && /* @__PURE__ */ jsx("span", { children: target.region })]
3647
+ }),
3648
+ pinned ? /* @__PURE__ */ jsxs("form", {
3649
+ className: "picker-note glass",
3650
+ onSubmit: (event) => {
3651
+ event.preventDefault();
3652
+ onPick(pinned.element, note.trim());
3653
+ },
3654
+ style: noteStyle(pinned.rect, view),
3655
+ children: [
3656
+ /* @__PURE__ */ jsxs("p", {
3657
+ className: "picker-note-target",
3658
+ children: [/* @__PURE__ */ jsx("strong", { children: pinned.componentName ? `<${pinned.componentName}>` : pinned.label }), pinned.region && /* @__PURE__ */ jsx("span", { children: pinned.region })]
3659
+ }),
3660
+ /* @__PURE__ */ jsx("textarea", {
3661
+ "aria-label": labels.elementNote,
3662
+ maxLength: MAX_SELECTION_COMMENT_LENGTH,
3663
+ onChange: (event) => setNote(event.target.value),
3664
+ onKeyDown: (event) => {
3665
+ if (event.key === "Enter" && !event.shiftKey) {
3666
+ event.preventDefault();
3667
+ onPick(pinned.element, note.trim());
3668
+ }
3669
+ },
3670
+ placeholder: labels.elementNotePlaceholder,
3671
+ ref: noteRef,
3672
+ rows: 2,
3673
+ value: note
3674
+ }),
3675
+ /* @__PURE__ */ jsxs("div", {
3676
+ className: "picker-note-actions",
3677
+ children: [/* @__PURE__ */ jsx("button", {
3678
+ className: "picker-cancel",
3679
+ onClick: () => {
3680
+ setPinned(null);
3681
+ setNote("");
3682
+ },
3683
+ type: "button",
3684
+ children: labels.pickAnother
3685
+ }), /* @__PURE__ */ jsx("button", {
3686
+ "aria-label": labels.attachElement,
3687
+ className: "submit",
3688
+ title: labels.attachElement,
3689
+ type: "submit",
3690
+ children: /* @__PURE__ */ jsx(ArrowIcon, {})
3691
+ })]
3692
+ })
3693
+ ]
3694
+ }) : /* @__PURE__ */ jsxs("div", {
3695
+ className: "picker-hint",
3696
+ children: [/* @__PURE__ */ jsxs("span", {
3697
+ className: "picker-instruction",
3698
+ children: [
3699
+ labels.pickElementHint,
3700
+ " ",
3701
+ /* @__PURE__ */ jsx("kbd", { children: "Tab" }),
3702
+ /* @__PURE__ */ jsx("kbd", { children: "Enter" }),
3703
+ /* @__PURE__ */ jsx("kbd", { children: "Esc" })
3704
+ ]
3705
+ }), /* @__PURE__ */ jsx("button", {
3706
+ className: "picker-cancel",
3707
+ onClick: onCancel,
3708
+ type: "button",
3709
+ children: labels.cancel
3710
+ })]
3711
+ })
3712
+ ]
3713
+ });
3714
+ }
3715
+ //#endregion
3716
+ //#region src/feedback/ui/annotation/styles.ts
3717
+ const ANNOTATOR_STYLES = `
3718
+ .overlay { margin: 0; border: 0; padding: 0; width: 100vw; max-width: none; height: 100dvh; max-height: none; position: fixed; inset: 0; z-index: ${REFLET_Z_INDEX.annotatorOverlay}; display: flex; flex-direction: column; background: transparent; color: var(--rf-text); font: 13px/1.45 ui-sans-serif, system-ui, sans-serif; }
3719
+ .overlay::before { content: ""; position: fixed; inset: 0; z-index: -1; pointer-events: none; background: color-mix(in srgb, var(--rf-bg) 94%, transparent); animation: rf-editor-in 260ms var(--rf-ease); transition: opacity 260ms var(--rf-ease); }
3720
+ .overlay::backdrop { background: transparent; }
3721
+ @keyframes rf-editor-in { from { opacity: 0; } }
3722
+ .editor { flex: 1; position: relative; display: flex; align-items: center; justify-content: center; padding: 20px; min-height: 0; container-type: size; }
3723
+ .editor-image { width: min(100cqw, calc(100cqh * var(--rf-capture-ratio))); aspect-ratio: var(--rf-capture-ratio); flex: none; position: relative; }
3724
+ .editor-visual { position: absolute; inset: 0; border-radius: var(--rf-radius); box-shadow: var(--rf-shadow); transform-origin: top left; overflow: hidden; }
3725
+ .editor-visual img, .editor-visual canvas { position: absolute; inset: 0; width: 100%; height: 100%; border-radius: inherit; object-fit: cover; object-position: top center; outline: 1px solid var(--rf-hairline); outline-offset: -1px; }
3726
+ .editor-visual canvas { cursor: crosshair; touch-action: none; }
3727
+ .overlay[data-opening="true"] .editor { pointer-events: none; }
3728
+ .overlay[data-closing="true"] { pointer-events: none; }
3729
+ .overlay[data-closing="true"]::before { opacity: 0; }
3730
+ .overlay[data-closing="true"] .toolbar, .overlay[data-closing="true"] .element-note { opacity: 0; transform: translateY(8px); }
3731
+
3732
+ .element-note { order: 1; display: flex; align-items: flex-start; gap: 10px; width: min(560px, calc(100vw - 32px)); margin: 0 auto 10px; padding: 10px 14px; border-radius: var(--rf-radius); transition: opacity 180ms var(--rf-ease), transform 180ms var(--rf-ease); }
3733
+ .element-note svg { flex: none; margin-top: 4px; opacity: 0.7; }
3734
+ .element-note textarea { flex: 1; min-width: 0; height: 42px; max-height: 96px; padding: 0; border: 0; background: transparent; color: inherit; font: inherit; resize: none; }
3735
+ .element-note textarea:focus { outline: none; }
3736
+ .element-note:focus-within { outline: 2px solid var(--rf-accent); outline-offset: 2px; }
3737
+
3738
+ .toolbar { order: 2; display: flex; align-items: center; justify-content: center; gap: 8px; flex-wrap: wrap; transition: opacity 180ms var(--rf-ease), transform 180ms var(--rf-ease); padding: 4px 12px max(16px, env(safe-area-inset-bottom)); }
3739
+ .toolbar .group { display: flex; align-items: center; gap: 2px; padding: 4px; border-radius: var(--rf-pill); }
3740
+ .tool[aria-pressed="true"], .tool[data-selected="true"] { background: var(--rf-primary); color: var(--rf-primary-text); }
3741
+ .color-picker { position: relative; }
3742
+ .color-picker summary { display: flex; justify-content: center; align-items: center; }
3743
+ .color-preview { width: 18px; height: 18px; border-radius: 50%; background: var(--annotation-color); box-shadow: inset 0 0 0 1px var(--rf-border); }
3744
+ .color-popover { --rf-color-width: 200px; position: absolute; bottom: calc(100% + 14px); left: clamp(calc(12px - var(--rf-color-anchor)), 0px, calc(100vw - var(--rf-color-anchor) - var(--rf-color-width) - 12px)); display: flex; align-items: center; gap: 6px; width: var(--rf-color-width); padding: 4px 4px 4px 14px; border-radius: var(--rf-pill); animation: rf-in 180ms var(--rf-ease); }
3745
+ .color-picker[data-placement="below"] .color-popover { top: calc(100% + 14px); bottom: auto; }
3746
+ .color-slider { flex: 1; min-width: 0; }
3747
+ .color-slider-control { display: flex; align-items: center; height: 40px; width: 100%; touch-action: none; user-select: none; cursor: pointer; }
3748
+ .color-slider-track { height: 12px; width: 100%; border-radius: var(--rf-pill); background: linear-gradient(to right, var(--rf-swatch-base), var(--annotation-base-color)); box-shadow: inset 0 0 0 1px var(--rf-border); }
3749
+ .color-slider-thumb { width: 16px; height: 16px; border: 2px solid var(--rf-swatch-base); border-radius: 50%; background: var(--annotation-color); box-shadow: var(--rf-shadow-low); transition: scale 160ms var(--rf-ease); }
3750
+ .color-slider-thumb[data-dragging] { scale: 1.12; }
3751
+ .color-slider-thumb:has(:focus-visible) { outline: 2px solid var(--rf-accent); outline-offset: 3px; }
3752
+ .color-spectrum { position: relative; display: flex; align-items: center; justify-content: center; flex: none; width: 40px; height: 40px; border-radius: 50%; cursor: pointer; }
3753
+ .color-spectrum::before { content: ""; width: 16px; height: 16px; border-radius: 50%; background: var(--rf-spectrum); box-shadow: inset 0 0 0 1px var(--rf-glass-highlight); }
3754
+ .color-spectrum:hover { background: var(--rf-bg-hover); }
3755
+ .color-spectrum button { position: absolute; inset: 0; width: 100%; height: 100%; border-radius: inherit; }
3756
+ .color-spectrum button:focus-visible { outline: 2px solid var(--rf-accent); outline-offset: 3px; }
3757
+ .color-spectrum input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; pointer-events: none; }
3758
+ .done-btn { display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; padding: 0; border-radius: var(--rf-pill); color: var(--rf-primary-text); background: var(--rf-primary); }
3759
+ .drawing-menu { position: relative; }
3760
+ .drawing-popover { position: absolute; z-index: 2; right: 0; bottom: calc(100% + 12px); width: 200px; max-height: calc(50dvh - 68px); overflow-y: auto; padding: 6px; border-radius: var(--rf-radius); }
3761
+ .drawing-menu[data-placement="below"] .drawing-popover { top: calc(100% + 12px); bottom: auto; }
3762
+ .drawing-option { display: flex; align-items: center; width: 100%; min-height: 40px; gap: 10px; padding: 0 10px; border-radius: 12px; text-align: left; }
3763
+ .drawing-option:hover, .drawing-option[aria-pressed="true"] { background: var(--rf-bg-hover); }
3764
+ .drawing-divider { height: 1px; margin: 5px; background: var(--rf-border); }
3765
+ .editor canvas[data-tool="text"] { cursor: text; }
3766
+ .text-annotation-editor { position: fixed; z-index: 3; width: 240px; padding: 8px; border-radius: var(--rf-radius); }
3767
+ .text-annotation-editor:focus-within { outline: 2px solid var(--rf-accent); outline-offset: 2px; }
3768
+ .text-annotation-editor textarea { display: block; width: 100%; min-height: 64px; max-height: 112px; padding: 6px; border: 0; background: transparent; resize: vertical; font-size: 16px; line-height: 24px; }
3769
+ .text-annotation-editor textarea:focus { outline: none; }
3770
+ .text-annotation-actions { display: flex; align-items: center; justify-content: flex-end; gap: 4px; }
3771
+ @media (pointer: coarse) {
3772
+ .drawing-option { min-height: 44px; }
3773
+ .done-btn { width: 44px; height: 44px; }
3774
+ .color-popover { --rf-color-width: 232px; padding-left: 16px; }
3775
+ .color-slider-control, .color-spectrum { height: 44px; }
3776
+ .color-spectrum { width: 44px; }
3777
+ .color-slider-thumb, .color-spectrum::before { width: 18px; height: 18px; }
3778
+ .editor { padding: 8px; }
3779
+ .toolbar { gap: 6px; }
3780
+ .toolbar .group { padding: 3px; gap: 0; }
3781
+ }
3782
+ @media (pointer: coarse) and (max-width: 360px) {
3783
+ .toolbar { gap: 4px; padding-inline: 2px; }
3784
+ .toolbar .group { padding: 0; }
3785
+ }
3786
+ `;
3585
3787
  //#endregion
3586
- //#region src/feedback/core/submission/upload-screenshots.ts
3587
- async function uploadImage(transport, image) {
3588
- const { uploadUrl } = await transport.getScreenshotUploadUrl();
3589
- return transport.uploadImage(uploadUrl, image);
3788
+ //#region src/feedback/ui/composer/styles.ts
3789
+ const COMPOSER_STYLES = `
3790
+ .panel { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 8px; outline: none; animation: rf-in 240ms var(--rf-ease); }
3791
+ .success-panel { border-radius: var(--rf-radius); }
3792
+ .composer { position: relative; display: flex; flex-direction: column; gap: 8px; }
3793
+ .message-row { display: flex; flex-direction: column; gap: 2px; padding: 6px; border-radius: 26px; }
3794
+ .message-row textarea {
3795
+ width: 100%; height: 76px; max-height: min(140px, 30dvh);
3796
+ margin: 0; padding: 6px 6px 0 8px; border: 0; resize: none;
3797
+ background: transparent; line-height: 20px;
3590
3798
  }
3591
- async function prepareScreenshot(transport, screenshot, index) {
3592
- const { closeUp, image, annotations, context } = screenshot;
3593
- const annotated = annotations.length > 0 ? await renderAnnotatedImage(image, annotations) : null;
3594
- if (annotations.length > 0 && !annotated) throw new Error("Could not render your annotations. Please try again.");
3595
- try {
3596
- const storageId = await uploadImage(transport, image);
3597
- const prepared = [{
3598
- annotatedStorageId: annotated ? await uploadImage(transport, annotated) : void 0,
3599
- annotations: annotations.length > 0 ? toWireAnnotations(annotations) : void 0,
3600
- captureSource: "widget",
3601
- filename: `screenshot-${index + 1}.png`,
3602
- height: image.height,
3603
- mimeType: image.mimeType,
3604
- pageUrl: context.url,
3605
- size: image.blob.size,
3606
- storageId,
3607
- width: image.width
3608
- }];
3609
- if (closeUp) prepared.push({
3610
- captureSource: "element",
3611
- filename: `element-${index + 1}.png`,
3612
- height: closeUp.height,
3613
- mimeType: closeUp.mimeType,
3614
- pageUrl: context.url,
3615
- size: closeUp.blob.size,
3616
- storageId: await uploadImage(transport, closeUp),
3617
- width: closeUp.width
3618
- });
3619
- return prepared;
3620
- } finally {
3621
- releaseCapture(annotated);
3622
- }
3799
+ .message-actions { display: flex; align-items: center; gap: 2px; }
3800
+ .message-row textarea::placeholder { color: var(--rf-text-muted); }
3801
+ .message-row textarea:focus { outline: none; }
3802
+ .message-row:has(textarea:focus-visible) { border-color: color-mix(in srgb, var(--rf-accent) 40%, var(--rf-glass-stroke)); }
3803
+ .submit { display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; flex: none; border-radius: var(--rf-pill); background: var(--rf-primary); color: var(--rf-primary-text); transition: opacity 160ms var(--rf-ease), transform 160ms var(--rf-ease); }
3804
+ .submit svg { transform: rotate(-45deg); }
3805
+ .submit:hover:enabled { transform: scale(1.05); }
3806
+ .submit:disabled { background: var(--rf-bg-hover); color: var(--rf-text-muted); opacity: .65; }
3807
+ .composer-toolbar { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
3808
+ .composer-options { position: relative; }
3809
+ summary { cursor: pointer; list-style: none; }
3810
+ summary::-webkit-details-marker { display: none; }
3811
+ .options-popover { position: absolute; z-index: 2; right: 0; bottom: calc(100% + 12px); width: min(240px, calc(100vw - 40px)); max-height: calc(50dvh - 68px); overflow-y: auto; display: flex; flex-direction: column; gap: 8px; padding: 12px; border-radius: var(--rf-radius); animation: rf-in 160ms var(--rf-ease); }
3812
+ .composer-options[data-placement="below"] .options-popover { top: calc(100% + 12px); bottom: auto; }
3813
+ .email-field { display: flex; flex-direction: column; gap: 6px; color: var(--rf-text-muted); font-size: 12px; }
3814
+ .email-field [role="alert"] { color: var(--rf-danger); }
3815
+ .email-field input { width: 100%; padding: 9px; border: 1px solid var(--rf-border); border-radius: var(--rf-radius-sm); background: var(--rf-bg-subtle); color: var(--rf-text); }
3816
+ .dismiss-btn { min-height: 40px; text-align: left; padding: 0 8px; border-radius: var(--rf-radius-sm); color: var(--rf-text-muted); }
3817
+ .dismiss-btn:hover { background: var(--rf-bg-hover); }
3818
+ .version-info { font-size: 10px; color: var(--rf-text-muted); padding: 4px 8px 0; border-top: 1px solid var(--rf-border); text-decoration: none; }
3819
+ .version-info:hover { color: var(--rf-text); }
3820
+ .email-scrim { display: none; }
3821
+ .email-prompt { display: flex; flex-direction: column; gap: 12px; padding: 16px; border-radius: 22px; animation: rf-in 240ms var(--rf-ease); }
3822
+ .email-prompt h2 { margin: 0; font-size: 14px; font-weight: 600; line-height: 1.3; }
3823
+ .email-prompt p { margin: 0; font-size: 12px; line-height: 1.45; color: var(--rf-text-muted); }
3824
+ .email-prompt .email-field input { min-height: 40px; border-radius: 14px; padding: 0 12px; }
3825
+ .email-prompt-actions { display: flex; align-items: center; justify-content: flex-end; gap: 4px; }
3826
+ .email-prompt-actions .ghost-btn { padding: 0 12px; color: var(--rf-text-muted); }
3827
+ .primary-btn { display: flex; align-items: center; justify-content: center; min-height: 40px; padding: 0 16px; border-radius: var(--rf-pill); background: var(--rf-primary); color: var(--rf-primary-text); font-weight: 500; }
3828
+ .primary-btn:hover { opacity: .9; }
3829
+ .attachments { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; }
3830
+ .attachment-row { display: flex; align-items: flex-end; gap: 8px; min-width: 0; max-width: 100%; }
3831
+ .screenshot-scroll-area { position: relative; min-width: 0; margin: -14px -10px -20px; }
3832
+ .screenshot-strip { width: 100%; padding: 14px 10px 20px; overscroll-behavior-x: contain; --rf-fade-start: 0px; --rf-fade-end: 0px; mask-image: linear-gradient(to right, transparent, black var(--rf-fade-start), black calc(100% - var(--rf-fade-end)), transparent); mask-repeat: no-repeat; }
3833
+ .screenshot-strip[data-overflow-x-start] { --rf-fade-start: calc(10px + min(24px, var(--scroll-area-overflow-x-start))); }
3834
+ .screenshot-strip[data-overflow-x-end] { --rf-fade-end: calc(10px + min(24px, var(--scroll-area-overflow-x-end))); }
3835
+ .screenshot-strip-content { display: flex; align-items: flex-end; gap: 8px; }
3836
+ .screenshot-scroll-area:has(.screenshot-strip-content:empty) { display: none; }
3837
+ .capture-slot { display: flex; flex-direction: column; gap: 2px; flex: none; width: 56px; height: var(--rf-attachment-height); padding: 3px; border: 1px dashed var(--rf-border); border-radius: var(--rf-radius); box-shadow: none; }
3838
+ .capture-action { display: flex; flex: 1; min-height: 0; align-items: center; justify-content: center; border-radius: 5px; color: var(--rf-text-muted); transition: background 160ms var(--rf-ease), color 160ms var(--rf-ease); }
3839
+ .capture-action:first-child { border-radius: 16px 16px 5px 5px; }
3840
+ .capture-action:last-child { border-radius: 5px 5px 16px 16px; }
3841
+ .capture-action:hover:enabled { background: var(--rf-bg-hover); color: var(--rf-text); }
3842
+ .screenshot-scrollbar { height: 4px; margin: 0 8px 2px; opacity: 0; transition: opacity 150ms var(--rf-ease); }
3843
+ .screenshot-scrollbar[data-hovering], .screenshot-scrollbar[data-scrolling] { opacity: 1; }
3844
+ .screenshot-scroll-thumb { border-radius: var(--rf-pill); background: var(--rf-border); }
3845
+ .capture-progress { position: absolute; inset: 0; display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 6px; border-radius: inherit; color: var(--rf-text-muted); font-size: 10px; }
3846
+ .attachments:empty { display: none; }
3847
+ .screenshot-attachment, .attachment-skeleton { position: relative; flex: none; margin: 0; min-width: 0; width: 128px; height: var(--rf-attachment-height); padding: 0; border-radius: 16px; animation: rf-in 260ms var(--rf-ease); }
3848
+ .screenshot-attachment { border: 0; }
3849
+ .screenshot-preview { display: block; position: relative; width: 100%; height: 100%; padding: 0; border-radius: inherit; overflow: hidden; }
3850
+ .screenshot-preview img, .screenshot-preview canvas { width: 100%; height: 100%; display: block; object-fit: cover; object-position: top center; }
3851
+ .screenshot-preview canvas { position: absolute; inset: 0; pointer-events: none; }
3852
+ .annotation-count { position: absolute; left: 5px; bottom: 5px; min-width: 20px; padding: 2px 6px; border-radius: var(--rf-pill); font-size: 10px; font-weight: 600; font-variant-numeric: tabular-nums; text-align: center; color: var(--rf-text); }
3853
+ .attachment-actions { position: absolute; right: 4px; bottom: 4px; display: flex; gap: 1px; padding: 1px; border-radius: var(--rf-pill); }
3854
+ .attachment-actions .icon-btn { width: 28px; height: 28px; }
3855
+ .attachment-actions svg { width: 14px; height: 14px; }
3856
+ .attachment-skeleton { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; color: var(--rf-text-muted); font-size: 11px; }
3857
+ .attachment-skeleton::after { content: ""; position: absolute; inset: 0; border-radius: inherit; background: var(--rf-bg-subtle); animation: rf-fade 800ms alternate infinite; }
3858
+ .selection-badge { position: absolute; left: 5px; top: 5px; display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--rf-pill); color: var(--rf-accent); }
3859
+ .selection-badge svg { width: 12px; height: 12px; }
3860
+ @media (pointer: coarse) {
3861
+ .message-row { border-radius: 28px; }
3862
+ .capture-slot { width: 64px; }
3863
+ .message-row textarea { font-size: 16px; }
3864
+ .submit { width: 44px; height: 44px; }
3865
+ :host { --rf-attachment-height: 96px; }
3866
+ .attachment-actions { right: 5px; bottom: 5px; }
3867
+ .attachment-actions .icon-btn { width: 34px; height: 34px; }
3868
+ .selection-badge { width: 24px; height: 24px; }
3869
+ .email-field input { font-size: 16px; }
3870
+ .dismiss-btn { min-height: 44px; }
3623
3871
  }
3624
- async function uploadScreenshots(transport, submission) {
3625
- return (await Promise.allSettled(submission.screenshots.map((screenshot, index) => prepareScreenshot(transport, screenshot, index)))).flatMap((result) => {
3626
- if (result.status === "rejected") throw result.reason;
3627
- return result.value;
3628
- });
3872
+ @media (max-width: 480px) {
3873
+ .email-scrim { display: block; position: fixed; inset: 0; background: rgb(0 0 0 / 40%); animation: rf-fade 200ms var(--rf-ease); }
3874
+ .email-prompt { position: fixed; left: 0; right: 0; bottom: 0; gap: 14px; padding: 20px 16px calc(20px + env(safe-area-inset-bottom)); border-radius: 26px 26px 0 0; animation: rf-drawer-in 280ms var(--rf-ease); }
3875
+ .email-prompt h2 { font-size: 17px; }
3876
+ .email-prompt p { font-size: 13px; }
3877
+ .email-prompt-actions { flex-direction: column-reverse; align-items: stretch; gap: 8px; }
3878
+ .email-prompt-actions .ghost-btn, .email-prompt-actions .primary-btn { justify-content: center; min-height: 48px; }
3629
3879
  }
3880
+ @keyframes rf-drawer-in { from { transform: translateY(100%); } }
3881
+ `;
3630
3882
  //#endregion
3631
- //#region src/feedback/core/submit.ts
3632
- const MAX_TITLE_LENGTH = 100;
3633
- const NEWLINE = /\r?\n/;
3634
- function deriveTitle(message) {
3635
- const firstLine = message.split(NEWLINE).map((line) => line.trim()).find(Boolean) ?? "";
3636
- if (firstLine.length <= MAX_TITLE_LENGTH) return firstLine;
3637
- const clipped = firstLine.slice(0, 99);
3638
- const lastSpace = clipped.lastIndexOf(" ");
3639
- return `${(lastSpace > 0 ? clipped.slice(0, lastSpace) : clipped).trimEnd()}…`;
3883
+ //#region src/feedback/ui/floating/picker-styles.ts
3884
+ const PICKER_STYLES = `
3885
+ .picker-box {
3886
+ position: fixed;
3887
+ top: 0;
3888
+ left: 0;
3889
+ pointer-events: none;
3890
+ border-radius: 5px;
3891
+ background: color-mix(in srgb, var(--rf-accent) 10%, transparent);
3892
+ box-shadow: 0 0 0 2px var(--rf-accent), 0 0 0 5px color-mix(in srgb, var(--rf-accent) 18%, transparent);
3893
+ will-change: translate;
3894
+ transition: translate 130ms var(--rf-ease);
3640
3895
  }
3641
- function buildDescription(params) {
3642
- const { email, isAnonymous, message } = params;
3643
- const contact = isAnonymous && email ? `\n\n---\nContact: ${email}` : "";
3644
- return `${message.trim()}${contact}`;
3896
+ .picker-box[data-pinned="true"] { transition: none; }
3897
+ .picker-label {
3898
+ position: fixed;
3899
+ top: 0;
3900
+ left: 0;
3901
+ pointer-events: none;
3902
+ display: inline-flex;
3903
+ align-items: center;
3904
+ gap: 6px;
3905
+ max-width: min(420px, 90vw);
3906
+ height: 24px;
3907
+ padding: 0 8px;
3908
+ font-size: 11.5px;
3909
+ font-weight: 500;
3910
+ color: var(--rf-accent-text);
3911
+ background: var(--rf-accent);
3912
+ border-radius: 6px;
3913
+ box-shadow: var(--rf-shadow-float);
3914
+ white-space: nowrap;
3915
+ overflow: hidden;
3916
+ will-change: translate;
3917
+ transition: translate 130ms var(--rf-ease);
3645
3918
  }
3646
- async function attachPreparedScreenshots(transport, result) {
3647
- const results = await Promise.allSettled(result.pendingScreenshots.map((screenshot) => transport.saveScreenshot({
3648
- ...screenshot,
3649
- feedbackId: result.feedbackId
3650
- })));
3651
- const pendingScreenshots = result.pendingScreenshots.filter((_, index) => results[index]?.status === "rejected");
3652
- return {
3653
- feedbackId: result.feedbackId,
3654
- pendingScreenshots
3655
- };
3919
+ .picker-label strong { flex: none; font-weight: 600; }
3920
+ .picker-label span { min-width: 0; overflow: hidden; text-overflow: ellipsis; opacity: 0.75; }
3921
+ .selection-outline {
3922
+ position: fixed;
3923
+ top: 0;
3924
+ left: 0;
3925
+ pointer-events: none;
3926
+ border-radius: 5px;
3927
+ background: color-mix(in srgb, var(--rf-accent) 7%, transparent);
3928
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--rf-accent) 70%, transparent), 0 0 0 5px color-mix(in srgb, var(--rf-accent) 14%, transparent);
3929
+ transition: opacity 160ms var(--rf-ease);
3656
3930
  }
3657
- async function createReportWithScreenshots(transport, submission, pendingScreenshots) {
3658
- const { feedbackId } = await transport.create({
3659
- context: submission.context,
3660
- description: buildDescription(submission),
3661
- title: deriveTitle(submission.message)
3662
- });
3663
- return attachPreparedScreenshots(transport, {
3664
- feedbackId,
3665
- pendingScreenshots
3666
- });
3931
+ .picker-hint {
3932
+ position: fixed;
3933
+ left: 50%;
3934
+ bottom: calc(24px + env(safe-area-inset-bottom));
3935
+ transform: translateX(-50%);
3936
+ display: flex;
3937
+ align-items: center;
3938
+ gap: 6px;
3939
+ max-width: calc(100vw - 24px);
3940
+ padding: 5px 6px 5px 12px;
3941
+ font-size: 12.5px;
3942
+ color: var(--rf-text);
3943
+ background: var(--rf-bg);
3944
+ border-radius: var(--rf-pill);
3945
+ box-shadow: var(--rf-ring), var(--rf-shadow);
3946
+ animation: rf-fade 240ms var(--rf-ease);
3947
+ }
3948
+ .picker-instruction { display: flex; align-items: center; gap: 6px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
3949
+ .picker-hint kbd {
3950
+ padding: 1px 5px;
3951
+ font: inherit;
3952
+ font-size: 11px;
3953
+ color: var(--rf-text-muted);
3954
+ border-radius: 4px;
3955
+ background: var(--rf-bg-subtle);
3956
+ box-shadow: var(--rf-shadow-inset);
3667
3957
  }
3668
- async function submitWidgetFeedback(transport, submission) {
3669
- if (!deriveTitle(submission.message)) throw new Error("Please describe your feedback before sending it.");
3670
- return createReportWithScreenshots(transport, submission, await uploadScreenshots(transport, submission));
3958
+ .picker-cancel {
3959
+ display: inline-flex;
3960
+ align-items: center;
3961
+ height: 40px;
3962
+ padding: 0 14px;
3963
+ border-radius: var(--rf-pill);
3964
+ font-size: 12.5px;
3965
+ font-weight: 500;
3966
+ white-space: nowrap;
3967
+ color: var(--rf-text-muted);
3671
3968
  }
3672
- function isStorageResponse(value) {
3673
- return typeof value === "object" && value !== null && typeof Reflect.get(value, "storageId") === "string";
3969
+ .picker-cancel:hover { background: var(--rf-bg-hover); color: var(--rf-text); }
3970
+ .picker-note {
3971
+ position: fixed;
3972
+ z-index: ${REFLET_Z_INDEX.annotatorOverlay};
3973
+ display: flex;
3974
+ flex-direction: column;
3975
+ gap: 6px;
3976
+ width: 300px;
3977
+ max-width: calc(100vw - 24px);
3978
+ padding: 6px 6px 6px 8px;
3979
+ border-radius: 18px;
3980
+ animation: rf-in 180ms var(--rf-ease);
3674
3981
  }
3675
- function createFeedbackTransport(client) {
3676
- return {
3677
- create: (params) => client.create(params),
3678
- getScreenshotUploadUrl: () => client.getScreenshotUploadUrl(),
3679
- saveScreenshot: (params) => client.saveScreenshot(params),
3680
- uploadImage: async (uploadUrl, image) => {
3681
- const response = await fetch(uploadUrl, {
3682
- body: image.blob,
3683
- headers: { "Content-Type": image.mimeType },
3684
- method: "POST"
3685
- });
3686
- if (!response.ok) throw new Error(`Screenshot upload failed (${response.status})`);
3687
- const payload = await response.json();
3688
- if (!isStorageResponse(payload)) throw new Error("Screenshot upload returned no storage id");
3689
- return payload.storageId;
3690
- }
3691
- };
3982
+ .picker-note:focus-within { outline: 2px solid var(--rf-accent); outline-offset: 2px; }
3983
+ .picker-note-target {
3984
+ display: flex;
3985
+ align-items: center;
3986
+ gap: 6px;
3987
+ margin: 0;
3988
+ padding: 2px 4px 0;
3989
+ font-size: 11px;
3990
+ color: var(--rf-text-muted);
3991
+ white-space: nowrap;
3692
3992
  }
3693
- //#endregion
3694
- //#region src/feedback/ui/state/use-feedback-submission.ts
3695
- function useFeedbackSubmission(options) {
3696
- const [activeRequest, setActiveRequest] = useState(null);
3697
- const [pendingReport, setPendingReport] = useState(null);
3698
- const requestRef = useRef(null);
3699
- useEffect(() => () => {
3700
- requestRef.current = null;
3701
- }, []);
3702
- const resetSubmission = useCallback(() => {
3703
- requestRef.current = null;
3704
- setPendingReport(null);
3705
- setActiveRequest(null);
3706
- }, []);
3707
- const finishSubmission = (result) => {
3708
- if (result.pendingScreenshots.length > 0) {
3709
- setPendingReport(result);
3710
- options.onError(options.partialUploadMessage);
3711
- return;
3712
- }
3713
- setPendingReport(null);
3714
- options.onSubmitted(result.feedbackId);
3715
- };
3716
- const submit = async (draft) => {
3717
- if (requestRef.current) return;
3718
- if (!options.client) {
3719
- options.onError("Reflet is missing a publicKey. Pass one to RefletFeedback or RefletProvider.");
3720
- return;
3721
- }
3722
- const request = {};
3723
- requestRef.current = request;
3724
- setActiveRequest(request);
3725
- options.onError(null);
3726
- try {
3727
- const transport = createFeedbackTransport(options.client);
3728
- const result = pendingReport ? await attachPreparedScreenshots(transport, pendingReport) : await submitWidgetFeedback(transport, draft);
3729
- if (requestRef.current === request) finishSubmission(result);
3730
- } catch (error) {
3731
- if (requestRef.current === request) options.onError(error instanceof Error ? error.message : "Something went wrong. Please try again.");
3732
- } finally {
3733
- if (requestRef.current === request) {
3734
- requestRef.current = null;
3735
- setActiveRequest(null);
3736
- }
3737
- }
3738
- };
3739
- return {
3740
- hasPendingAttachments: pendingReport !== null,
3741
- isEditingDisabled: activeRequest !== null || pendingReport !== null,
3742
- isSubmitting: activeRequest !== null,
3743
- resetSubmission,
3744
- submit
3745
- };
3993
+ .picker-note-target strong { flex: none; font-weight: 600; color: var(--rf-accent); }
3994
+ .picker-note-target span { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
3995
+ .picker-note textarea {
3996
+ width: 100%;
3997
+ min-height: 46px;
3998
+ max-height: min(160px, 30dvh);
3999
+ margin: 0;
4000
+ padding: 7px 6px 0;
4001
+ border: 0;
4002
+ resize: none;
4003
+ background: transparent;
4004
+ color: var(--rf-text);
4005
+ font-size: 13px;
4006
+ line-height: 19px;
3746
4007
  }
3747
- //#endregion
3748
- //#region src/feedback/ui/state/use-screenshot-drafts.ts
3749
- function releaseDraft(draft) {
3750
- releaseCapture(draft?.image);
3751
- releaseCapture(draft?.closeUp);
4008
+ .picker-note textarea:focus { outline: none; }
4009
+ .picker-note textarea::placeholder { color: var(--rf-text-muted); }
4010
+ .picker-note-actions { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
4011
+ @media (pointer: coarse) {
4012
+ .picker-hint { padding: 6px 8px 6px 14px; }
4013
+ .picker-hint kbd { display: none; }
4014
+ .picker-cancel { height: 44px; padding: 0 18px; font-size: 14px; }
4015
+ .picker-note textarea { font-size: 16px; min-height: 56px; }
3752
4016
  }
3753
- function useScreenshotDrafts(captureFailed, onError) {
3754
- const [screenshots, setScreenshots] = useState([]);
3755
- const [activeId, setActiveId] = useState(null);
3756
- const [pendingCapture, setPendingCapture] = useState(null);
3757
- const ownedDrafts = useRef(/* @__PURE__ */ new Map());
3758
- const captureRequest = useRef(null);
3759
- useEffect(() => {
3760
- const drafts = ownedDrafts.current;
3761
- return () => {
3762
- captureRequest.current = null;
3763
- for (const draft of drafts.values()) releaseDraft(draft);
3764
- drafts.clear();
3765
- };
3766
- }, []);
3767
- const storeScreenshot = useCallback((screenshot) => {
3768
- releaseDraft(ownedDrafts.current.get(screenshot.id));
3769
- ownedDrafts.current.set(screenshot.id, screenshot);
3770
- setScreenshots((previous) => {
3771
- return previous.some(({ id }) => id === screenshot.id) ? previous.map((item) => item.id === screenshot.id ? screenshot : item) : [...previous, screenshot];
3772
- });
3773
- setActiveId(screenshot.id);
3774
- }, []);
3775
- const cancelPendingCapture = useCallback(() => {
3776
- captureRequest.current = null;
3777
- setPendingCapture(null);
3778
- }, []);
3779
- const takeScreenshot = useCallback(async (request) => {
3780
- captureRequest.current = request;
3781
- setPendingCapture(request);
3782
- onError(null);
3783
- const context = collectPageContext({ sdkVersion: SDK_VERSION });
3784
- const image = await captureViewport().catch(() => null);
3785
- if (captureRequest.current !== request) {
3786
- releaseCapture(image);
3787
- return;
3788
- }
3789
- cancelPendingCapture();
3790
- if (!image) {
3791
- onError(captureFailed);
3792
- return;
3793
- }
3794
- storeScreenshot({
3795
- ...request,
3796
- annotations: [],
3797
- context,
3798
- image
3799
- });
3800
- }, [
3801
- cancelPendingCapture,
3802
- captureFailed,
3803
- onError,
3804
- storeScreenshot
3805
- ]);
3806
- const takeCapture = useCallback(() => {
3807
- if (!captureRequest.current) takeScreenshot({
3808
- id: crypto.randomUUID(),
3809
- source: "manual"
3810
- });
3811
- }, [takeScreenshot]);
3812
- const refreshAutomatic = useCallback(() => {
3813
- takeScreenshot({
3814
- id: ownedDrafts.current.keys().next().value ?? captureRequest.current?.id ?? crypto.randomUUID(),
3815
- source: "automatic"
3816
- });
3817
- }, [takeScreenshot]);
3818
- const retakeCapture = useCallback((id) => {
3819
- if (!captureRequest.current && ownedDrafts.current.has(id)) takeScreenshot({
3820
- id,
3821
- source: "manual"
3822
- });
3823
- }, [takeScreenshot]);
3824
- const removeCapture = useCallback((id) => {
3825
- if (captureRequest.current?.id === id) cancelPendingCapture();
3826
- releaseDraft(ownedDrafts.current.get(id));
3827
- ownedDrafts.current.delete(id);
3828
- setScreenshots((previous) => previous.filter((item) => item.id !== id));
3829
- setActiveId((current) => current === id ? null : current);
3830
- }, [cancelPendingCapture]);
3831
- const resetScreenshots = useCallback(() => {
3832
- cancelPendingCapture();
3833
- for (const draft of ownedDrafts.current.values()) releaseDraft(draft);
3834
- ownedDrafts.current.clear();
3835
- setScreenshots([]);
3836
- setActiveId(null);
3837
- }, [cancelPendingCapture]);
3838
- const setAnnotations = useCallback((annotations) => {
3839
- setScreenshots((previous) => previous.map((item) => item.id === activeId ? {
3840
- ...item,
3841
- annotations
3842
- } : item));
3843
- }, [activeId]);
3844
- const setSelectionComment = useCallback((id, comment) => {
3845
- setScreenshots((previous) => previous.map((item) => item.id === id && item.selection ? {
3846
- ...item,
3847
- selection: {
3848
- ...item.selection,
3849
- comment
3850
- }
3851
- } : item));
3852
- }, []);
3853
- return {
3854
- activeScreenshot: screenshots.find(({ id }) => id === activeId) ?? screenshots.at(-1) ?? null,
3855
- cancelPendingCapture,
3856
- pendingCapture,
3857
- refreshAutomatic,
3858
- removeCapture,
3859
- resetScreenshots,
3860
- retakeCapture,
3861
- screenshots,
3862
- selectScreenshot: setActiveId,
3863
- setAnnotations,
3864
- setSelectionComment,
3865
- storeScreenshot,
3866
- takeCapture
3867
- };
4017
+
4018
+ `;
4019
+ //#endregion
4020
+ //#region src/feedback/ui/styles.ts
4021
+ const WIDGET_STYLES = `
4022
+ :host {
4023
+ --rf-offset: 20px;
4024
+ --rf-bg: #ffffff;
4025
+ --rf-bg-subtle: rgb(55 53 47 / 4%);
4026
+ --rf-bg-hover: rgb(55 53 47 / 7%);
4027
+ --rf-border: rgb(55 53 47 / 9%);
4028
+ --rf-hairline: rgb(15 15 15 / 10%);
4029
+ --rf-text: #37352f;
4030
+ --rf-text-muted: rgb(55 53 47 / 68%);
4031
+ --rf-primary: #37352f;
4032
+ --rf-primary-text: #ffffff;
4033
+ --rf-accent: #2383e2;
4034
+ --rf-accent-text: #ffffff;
4035
+ --rf-danger: #d44c47;
4036
+ --rf-success: #448361;
4037
+ --rf-swatch-base: #ffffff;
4038
+ --rf-spectrum: conic-gradient(#ff5757, #ffde59, #6bdb78, #4ac9fa, #8b5cf6, #ef73dc, #ff5757);
4039
+ --rf-radius: 20px;
4040
+ --rf-pill: 999px;
4041
+ --rf-attachment-height: 78px;
4042
+ --rf-width: 360px;
4043
+ --rf-glass: rgb(255 255 255 / 72%);
4044
+ --rf-glass-highlight: rgb(255 255 255 / 90%);
4045
+ --rf-glass-stroke: rgb(255 255 255 / 70%);
4046
+ --rf-radius-sm: 8px;
4047
+ --rf-shadow-inset: inset 0 0 0 1px var(--rf-border);
4048
+ --rf-ring: 0 0 0 1px rgb(15 15 15 / 4%);
4049
+ --rf-shadow-low: 0 1px 2px rgb(15 15 15 / 6%), 0 4px 10px -4px rgb(15 15 15 / 10%);
4050
+ --rf-shadow: 0 2px 5px -1px rgb(15 15 15 / 7%), 0 12px 32px -8px rgb(15 15 15 / 16%);
4051
+ --rf-shadow-float: 0 4px 12px -3px rgb(15 15 15 / 35%);
4052
+ --rf-ease: cubic-bezier(0.16, 1, 0.3, 1);
4053
+ color-scheme: light;
4054
+ all: initial;
4055
+ font-family: var(--rf-font, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif);
3868
4056
  }
3869
- //#endregion
3870
- //#region src/feedback/ui/state/use-widget-config.ts
3871
- function dismissalDuration(days) {
3872
- return days !== void 0 && Number.isFinite(days) && days > 0 ? days : null;
4057
+
4058
+ :host([data-theme="dark"]) {
4059
+ --rf-bg: #202020;
4060
+ --rf-bg-subtle: rgb(255 255 255 / 5%);
4061
+ --rf-bg-hover: rgb(255 255 255 / 9%);
4062
+ --rf-border: rgb(255 255 255 / 9%);
4063
+ --rf-hairline: rgb(255 255 255 / 12%);
4064
+ --rf-text: #ebeae8;
4065
+ --rf-text-muted: rgb(255 255 255 / 68%);
4066
+ --rf-glass: rgb(32 32 32 / 78%);
4067
+ --rf-glass-highlight: rgb(255 255 255 / 14%);
4068
+ --rf-glass-stroke: rgb(255 255 255 / 12%);
4069
+ --rf-primary: #f1efec;
4070
+ --rf-primary-text: #1c1c1b;
4071
+ --rf-accent: #529cca;
4072
+ --rf-danger: #eb5757;
4073
+ --rf-success: #4dab74;
4074
+ --rf-ring: 0 0 0 1px rgb(255 255 255 / 7%);
4075
+ --rf-shadow-low: 0 1px 2px rgb(0 0 0 / 30%), 0 4px 10px -4px rgb(0 0 0 / 40%);
4076
+ --rf-shadow: 0 2px 5px -1px rgb(0 0 0 / 35%), 0 14px 36px -10px rgb(0 0 0 / 55%);
4077
+ --rf-shadow-float: 0 4px 14px -3px rgb(0 0 0 / 55%);
4078
+ color-scheme: dark;
3873
4079
  }
3874
- function useWidgetConfig(props) {
3875
- const context = useContext(RefletContext);
3876
- const publicKey = props.publicKey ?? context?.publicKey;
3877
- const baseUrl = props.baseUrl ?? context?.baseUrl;
3878
- const user = props.user ?? context?.user;
3879
- const userToken = props.userToken ?? context?.userToken;
3880
- return {
3881
- client: useMemo(() => publicKey ? new Reflet({
3882
- baseUrl,
3883
- publicKey,
3884
- user,
3885
- userToken
3886
- }) : null, [
3887
- baseUrl,
3888
- publicKey,
3889
- user,
3890
- userToken
3891
- ]),
3892
- dismissalKey: `reflet-feedback-dismissed:${publicKey ?? "default"}:${user?.id ?? "anonymous"}`,
3893
- dismissForDays: dismissalDuration(props.dismissForDays),
3894
- isAnonymous: !(user || userToken)
3895
- };
4080
+
4081
+ *, *::before, *::after { box-sizing: border-box; }
4082
+
4083
+
4084
+ .root {
4085
+ position: fixed;
4086
+ z-index: ${REFLET_Z_INDEX.widgetRoot};
4087
+ display: flex;
4088
+ align-items: flex-end;
4089
+ gap: 8px;
4090
+ font: 13px/1.45 var(--rf-font, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif);
4091
+ color: var(--rf-text);
3896
4092
  }
3897
- //#endregion
3898
- //#region src/feedback/ui/use-capture-sync.ts
3899
- const RESIZE_DEBOUNCE = 400;
3900
- /**
3901
- * Calls `refresh` whenever the page the reporter is looking at changes shape:
3902
- * SPA routes (which never fire popstate on push/replace — history methods must
3903
- * be patched) and window resizes, debounced. Keeps the frozen screenshot and
3904
- * page context describing what the reporter currently sees.
3905
- */
3906
- function useCaptureSync(enabled, refresh) {
3907
- useEffect(() => {
3908
- if (!enabled) return;
3909
- const history = window.history;
3910
- const originalPush = history.pushState;
3911
- const originalReplace = history.replaceState;
3912
- const hadOwnPush = Object.hasOwn(history, "pushState");
3913
- const hadOwnReplace = Object.hasOwn(history, "replaceState");
3914
- history.pushState = function pushState(data, unused, url) {
3915
- const result = originalPush.call(history, data, unused, url);
3916
- refresh();
3917
- return result;
3918
- };
3919
- history.replaceState = function replaceState(data, unused, url) {
3920
- const result = originalReplace.call(history, data, unused, url);
3921
- refresh();
3922
- return result;
3923
- };
3924
- window.addEventListener("popstate", refresh);
3925
- let resizeTimer;
3926
- const onResize = () => {
3927
- window.clearTimeout(resizeTimer);
3928
- resizeTimer = window.setTimeout(refresh, RESIZE_DEBOUNCE);
3929
- };
3930
- window.addEventListener("resize", onResize);
3931
- return () => {
3932
- if (hadOwnPush) history.pushState = originalPush;
3933
- else Reflect.deleteProperty(history, "pushState");
3934
- if (hadOwnReplace) history.replaceState = originalReplace;
3935
- else Reflect.deleteProperty(history, "replaceState");
3936
- window.removeEventListener("popstate", refresh);
3937
- window.clearTimeout(resizeTimer);
3938
- window.removeEventListener("resize", onResize);
3939
- };
3940
- }, [enabled, refresh]);
4093
+ .root:has(.panel) { width: min(var(--rf-width), calc(var(--rf-viewport-width, 100vw) - 24px)); }
4094
+ .root[data-position$="right"] { right: calc(var(--rf-viewport-right, 0px) + max(12px, min(var(--rf-offset), calc((var(--rf-viewport-width, 100vw) - var(--rf-width)) / 2)))); }
4095
+ .root[data-position$="left"] { left: calc(var(--rf-viewport-left, 0px) + max(12px, min(var(--rf-offset), calc((var(--rf-viewport-width, 100vw) - var(--rf-width)) / 2)))); }
4096
+ .root[data-position^="bottom"] { bottom: calc(var(--rf-viewport-bottom, 0px) + max(var(--rf-offset), env(safe-area-inset-bottom))); }
4097
+ .root[data-position^="top"] { top: calc(var(--rf-viewport-top, 0px) + max(var(--rf-offset), env(safe-area-inset-top))); align-items: flex-start; }
4098
+ .root[data-position="bottom-right"] .panel { transform-origin: bottom right; }
4099
+ .root[data-position="bottom-left"] .panel { transform-origin: bottom left; }
4100
+ .root[data-position^="top"] .panel { transform-origin: top center; }
4101
+ .root[data-editing="true"] { visibility: hidden; pointer-events: none; }
4102
+ button, select, input, textarea { font: inherit; color: inherit; }
4103
+ button { margin: 0; cursor: pointer; border: 0; background: none; transition: scale 120ms var(--rf-ease); }
4104
+ button:disabled { cursor: not-allowed; opacity: .4; }
4105
+ button:active:not(:disabled) { scale: .97; }
4106
+ svg { flex-shrink: 0; }
4107
+ :focus-visible { outline: 2px solid var(--rf-accent); outline-offset: 3px; }
4108
+ .icon-btn, .tool {
4109
+ display: inline-flex; align-items: center; justify-content: center;
4110
+ width: 40px; height: 40px; flex: none; border-radius: var(--rf-pill); color: var(--rf-text-muted);
4111
+ transition: background 160ms var(--rf-ease), color 160ms var(--rf-ease), scale 120ms var(--rf-ease);
3941
4112
  }
3942
- //#endregion
3943
- //#region src/feedback/ui/use-widget-state.ts
3944
- const SUCCESS_CLOSE_DELAY = 2400;
3945
- const IS_APPLE = /Mac|iPhone|iPad/;
3946
- const MILLISECONDS_PER_DAY = 864e5;
3947
- function readDismissedUntil(key) {
3948
- if (typeof window === "undefined") return 0;
3949
- try {
3950
- const value = Number.parseInt(window.localStorage.getItem(key) ?? "", 10);
3951
- if (Number.isFinite(value) && value > Date.now()) return value;
3952
- window.localStorage.removeItem(key);
3953
- } catch {
3954
- return 0;
3955
- }
3956
- return 0;
4113
+ .icon-btn:hover, .tool:hover { background: var(--rf-bg-hover); color: var(--rf-text); }
4114
+ .launcher {
4115
+ display: inline-flex; align-items: center; gap: 7px; height: 40px; padding: 0 15px;
4116
+ border-radius: var(--rf-pill); color: var(--rf-text);
4117
+ font-weight: 500;
4118
+ transition: transform 200ms var(--rf-ease), box-shadow 200ms var(--rf-ease), scale 120ms var(--rf-ease);
3957
4119
  }
3958
- function writeDismissedUntil(key, until) {
3959
- try {
3960
- window.localStorage.setItem(key, String(until));
3961
- } catch {}
4120
+ .launcher:hover { transform: translateY(-2px); box-shadow: var(--rf-shadow); }
4121
+ .launcher-icon { display: flex; width: 15px; height: 15px; }
4122
+ .launcher-icon .on-close { display: none; }
4123
+ .floating-controls {
4124
+ display: flex; align-items: center; flex: none;
4125
+ margin-right: auto; padding-left: 3px;
4126
+ animation: rf-in 220ms var(--rf-ease);
3962
4127
  }
3963
- function matchesHotkey(event, hotkey, platform = typeof navigator === "undefined" ? "" : navigator.platform) {
3964
- const parts = hotkey.toLowerCase().split("+").map((part) => part.trim());
3965
- const key = parts.at(-1);
3966
- if (!key || event.key.toLowerCase() !== key) return false;
3967
- const isApple = IS_APPLE.test(platform);
3968
- const wantsMod = parts.includes("mod");
3969
- const expected = {
3970
- alt: parts.includes("alt") || parts.includes("option"),
3971
- ctrl: parts.includes("ctrl") || wantsMod && !isApple,
3972
- meta: parts.includes("meta") || parts.includes("cmd") || wantsMod && isApple,
3973
- shift: parts.includes("shift")
3974
- };
3975
- return event.altKey === expected.alt && event.ctrlKey === expected.ctrl && event.metaKey === expected.meta && event.shiftKey === expected.shift;
4128
+ .drag-handle { touch-action: none; cursor: grab; }
4129
+ .drag-handle:active { cursor: grabbing; }
4130
+ .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
4131
+ .ghost-btn { display: flex; align-items: center; gap: 8px; min-height: 40px; padding: 0 8px; border-radius: var(--rf-radius-sm); }
4132
+ .ghost-btn:hover { background: var(--rf-bg-hover); }
4133
+ .error { margin: 0; padding: 10px 14px; border-radius: var(--rf-radius); font-size: 12px; color: var(--rf-danger); }
4134
+ .done { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 24px; text-align: center; }
4135
+ .done svg { color: var(--rf-success); }
4136
+ .done h2 { margin: 0; font-size: 14px; font-weight: 600; }
4137
+ .done p { margin: 0; font-size: 12px; color: var(--rf-text-muted); }
4138
+ .spinner { width: 14px; height: 14px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: rf-spin 700ms linear infinite; }
4139
+ .hp { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
4140
+ @keyframes rf-spin { to { transform: rotate(360deg); } }
4141
+ @keyframes rf-in { from { opacity: 0; transform: translateY(5px) scale(.98); } }
4142
+ @keyframes rf-fade { from { opacity: 0; } }
4143
+ @media (pointer: coarse) {
4144
+ .icon-btn, .tool { width: 44px; height: 44px; }
4145
+ .launcher, .ghost-btn { min-height: 44px; }
4146
+ }
4147
+ .glass {
4148
+ position: relative;
4149
+ isolation: isolate;
4150
+ border: 1px solid var(--rf-glass-stroke);
4151
+ box-shadow: inset 0 1px 0 var(--rf-glass-highlight), var(--rf-shadow);
4152
+ }
4153
+ .glass::before {
4154
+ content: "";
4155
+ position: absolute;
4156
+ inset: 0;
4157
+ z-index: -1;
4158
+ border-radius: inherit;
4159
+ background: var(--rf-bg);
4160
+ pointer-events: none;
4161
+ }
4162
+ @supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) {
4163
+ .glass::before {
4164
+ background: var(--rf-glass);
4165
+ -webkit-backdrop-filter: blur(20px) saturate(1.6);
4166
+ backdrop-filter: blur(20px) saturate(1.6);
4167
+ }
4168
+ }
4169
+ @media (prefers-reduced-transparency: reduce), (prefers-contrast: more) {
4170
+ .glass::before { background: var(--rf-bg); backdrop-filter: none; -webkit-backdrop-filter: none; }
4171
+ }
4172
+ .capture-halo { position: fixed; inset: 0; z-index: ${REFLET_Z_INDEX.captureHalo}; pointer-events: none; opacity: 0; transition: opacity 500ms var(--rf-ease); }
4173
+ .capture-halo[data-active="true"] { opacity: 1; transition-duration: 140ms; }
4174
+ .capture-halo::before { content: ""; position: absolute; inset: 0; border-radius: 20px; box-shadow: inset 14px 0 36px -16px var(--rf-accent), inset -14px 0 36px -16px var(--rf-accent), inset 0 0 4px 1px color-mix(in srgb, var(--rf-accent) 35%, transparent); }
4175
+ .capture-halo::after { content: ""; position: absolute; inset: 0; background: linear-gradient(90deg, color-mix(in srgb, var(--rf-accent) 20%, transparent), transparent 5%, transparent 95%, color-mix(in srgb, var(--rf-accent) 20%, transparent)); }
4176
+ ${COMPOSER_STYLES}
4177
+ ${ANNOTATOR_STYLES}
4178
+ ${PICKER_STYLES}
4179
+ @media (prefers-reduced-motion: reduce) {
4180
+ *, *::before, *::after { animation: none !important; transition: none !important; }
4181
+ button:active:not(:disabled) { scale: 1; }
4182
+ }
4183
+ `;
4184
+ //#endregion
4185
+ //#region src/feedback/ui/shadow-portal.tsx
4186
+ const DARK_QUERY = "(prefers-color-scheme: dark)";
4187
+ function useResolvedTheme(theme) {
4188
+ const [systemDark, setSystemDark] = useState(false);
4189
+ useEffect(() => {
4190
+ if (theme !== "auto" || typeof window.matchMedia !== "function") return;
4191
+ const query = window.matchMedia(DARK_QUERY);
4192
+ setSystemDark(query.matches);
4193
+ const onChange = (event) => setSystemDark(event.matches);
4194
+ query.addEventListener("change", onChange);
4195
+ return () => query.removeEventListener("change", onChange);
4196
+ }, [theme]);
4197
+ if (theme === "auto") return systemDark ? "dark" : "light";
4198
+ return theme;
3976
4199
  }
3977
- function useWidgetState(props) {
3978
- const { client, dismissalKey, dismissForDays, isAnonymous } = useWidgetConfig(props);
3979
- const [isOpen, setIsOpen] = useState(false);
3980
- const [annotationTrigger, setAnnotationTrigger] = useState(null);
3981
- const [step, setStep] = useState("compose");
3982
- const [message, setMessage] = useState("");
3983
- const [email, setEmail] = useState("");
3984
- const [honeypot, setHoneypot] = useState("");
3985
- const [isElementCapturing, setIsElementCapturing] = useState(false);
3986
- const successTimer = useRef(null);
3987
- const [error, setError] = useState(null);
3988
- const [dismissedUntil, setDismissedUntil] = useState(() => readDismissedUntil(dismissalKey));
3989
- const pickRef = useRef(0);
3990
- useEffect(() => () => {
3991
- pickRef.current++;
3992
- if (successTimer.current) clearTimeout(successTimer.current);
3993
- }, []);
3994
- const consoleRef = useRef(null);
4200
+ /**
4201
+ * Hosts the widget in a shadow root: the app's CSS cannot reach the panel and
4202
+ * the panel's CSS cannot reach the app. Direct DOM work is unavoidable here —
4203
+ * React has no declarative way to create a shadow root.
4204
+ */
4205
+ function ShadowPortal({ children, offset, primaryColor, theme }) {
4206
+ const [shadowRoot, setShadowRoot] = useState(null);
4207
+ const resolvedTheme = useResolvedTheme(theme);
3995
4208
  useEffect(() => {
3996
- setDismissedUntil(readDismissedUntil(dismissalKey));
3997
- }, [dismissalKey]);
4209
+ const host = document.createElement("div");
4210
+ host.setAttribute(WIDGET_MARKER, "");
4211
+ const root = host.attachShadow({ mode: "open" });
4212
+ const style = document.createElement("style");
4213
+ style.textContent = WIDGET_STYLES;
4214
+ root.append(style);
4215
+ document.body.append(host);
4216
+ setShadowRoot(root);
4217
+ return () => host.remove();
4218
+ }, []);
3998
4219
  useEffect(() => {
3999
- if (props.captureConsole === false) return;
4000
- const recorder = startConsoleRecorder();
4001
- consoleRef.current = recorder;
4002
- return () => {
4003
- consoleRef.current = null;
4004
- recorder.stop();
4005
- };
4006
- }, [props.captureConsole]);
4007
- const captureFailed = props.labels?.captureFailed ?? DEFAULT_WIDGET_LABELS.captureFailed;
4008
- const gallery = useScreenshotDrafts(captureFailed, setError);
4009
- const { activeScreenshot, cancelPendingCapture, refreshAutomatic, resetScreenshots, storeScreenshot } = gallery;
4010
- const capture = activeScreenshot?.image ?? null;
4011
- const annotations = activeScreenshot?.annotations ?? [];
4012
- const isCapturing = gallery.pendingCapture !== null || isElementCapturing;
4013
- const submission = useFeedbackSubmission({
4014
- client,
4015
- onError: setError,
4016
- onSubmitted: (feedbackId) => {
4017
- setStep("success");
4018
- props.onSubmit?.({ feedbackId });
4019
- successTimer.current = setTimeout(close, SUCCESS_CLOSE_DELAY);
4020
- },
4021
- partialUploadMessage: props.labels?.attachmentUploadFailed ?? DEFAULT_WIDGET_LABELS.attachmentUploadFailed
4022
- });
4023
- const { resetSubmission } = submission;
4024
- const firstCapture = gallery.pendingCapture ?? gallery.screenshots[0];
4025
- const canRefreshAutomatically = gallery.screenshots.length <= 1 && firstCapture?.source === "automatic";
4026
- useCaptureSync(isOpen && !submission.isEditingDisabled && step === "compose" && canRefreshAutomatically && annotations.length === 0 && !gallery.screenshots.some((draft) => draft.selection) && props.captureOnOpen !== false, refreshAutomatic);
4027
- const reset = useCallback(() => {
4028
- if (successTimer.current) clearTimeout(successTimer.current);
4029
- setAnnotationTrigger(null);
4030
- resetSubmission();
4031
- pickRef.current++;
4032
- setIsElementCapturing(false);
4033
- resetScreenshots();
4034
- setMessage("");
4035
- setEmail("");
4036
- setHoneypot("");
4037
- setError(null);
4038
- setStep("compose");
4039
- }, [resetSubmission, resetScreenshots]);
4040
- const close = useCallback(() => {
4041
- setIsOpen(false);
4042
- reset();
4043
- props.onClose?.();
4044
- }, [reset, props.onClose]);
4045
- const dismiss = useCallback(() => {
4046
- if (dismissForDays === null) return;
4047
- const until = Date.now() + dismissForDays * MILLISECONDS_PER_DAY;
4048
- writeDismissedUntil(dismissalKey, until);
4049
- setIsOpen(false);
4050
- reset();
4051
- setDismissedUntil(until);
4052
- props.onDismiss?.({ until });
4053
- }, [
4054
- dismissForDays,
4055
- dismissalKey,
4056
- props.onDismiss,
4057
- reset
4058
- ]);
4059
- const open = useCallback(() => {
4060
- setIsOpen(true);
4061
- setStep("compose");
4062
- props.onOpen?.();
4063
- if (props.captureOnOpen !== false) refreshAutomatic();
4064
- }, [
4065
- props.captureOnOpen,
4066
- props.onOpen,
4067
- refreshAutomatic
4068
- ]);
4069
- const selectElement = useCallback(async (element, note = "") => {
4070
- cancelPendingCapture();
4071
- setIsElementCapturing(true);
4072
- const picked = note ? {
4073
- ...buildElementSelection(element),
4074
- comment: note.slice(0, MAX_SELECTION_COMMENT_LENGTH)
4075
- } : buildElementSelection(element);
4076
- setStep("compose");
4077
- const pick = ++pickRef.current;
4078
- const context = collectPageContext({ sdkVersion: SDK_VERSION });
4079
- const [fresh, closeUp] = await Promise.all([captureViewport().catch(() => null), captureElementCloseUp(element).catch(() => null)]);
4080
- if (pickRef.current !== pick) {
4081
- releaseCapture(fresh);
4082
- releaseCapture(closeUp);
4083
- return;
4084
- }
4085
- setIsElementCapturing(false);
4086
- if (!fresh) {
4087
- releaseCapture(closeUp);
4088
- setError(captureFailed);
4089
- return;
4090
- }
4091
- storeScreenshot({
4092
- annotations: [highlightFor(picked, fresh)],
4093
- closeUp,
4094
- context,
4095
- id: crypto.randomUUID(),
4096
- image: fresh,
4097
- selectedNode: element,
4098
- selection: picked,
4099
- source: "manual"
4100
- });
4220
+ const host = shadowRoot?.host;
4221
+ if (!(host instanceof HTMLElement)) return;
4222
+ host.setAttribute("data-theme", resolvedTheme);
4223
+ host.style.setProperty("--rf-offset", `${offset}px`);
4224
+ if (primaryColor) host.style.setProperty("--rf-primary", primaryColor);
4101
4225
  }, [
4102
- cancelPendingCapture,
4103
- captureFailed,
4104
- storeScreenshot
4226
+ shadowRoot,
4227
+ resolvedTheme,
4228
+ primaryColor,
4229
+ offset
4105
4230
  ]);
4106
- const selections = gallery.screenshots.flatMap(({ selection }) => selection ? [selection] : []);
4107
- const firstElementNote = selections.find(({ comment }) => comment)?.comment;
4108
- const reportedMessage = message.trim() || firstElementNote || "";
4109
- const submit = () => {
4110
- if (honeypot) {
4111
- setStep("success");
4112
- return;
4113
- }
4114
- setStep("compose");
4115
- return submission.submit({
4116
- context: {
4117
- ...activeScreenshot?.context ?? collectPageContext({ sdkVersion: "0.4.2" }),
4118
- consoleEvents: consoleRef.current?.events(),
4119
- metadata: props.metadata,
4120
- selections
4121
- },
4122
- email,
4123
- isAnonymous,
4124
- message: reportedMessage,
4125
- screenshots: gallery.screenshots
4126
- });
4127
- };
4128
- const requestSubmit = () => {
4129
- if (isAnonymous && email.length === 0 && !submission.hasPendingAttachments && !honeypot) {
4130
- setStep("email");
4131
- return;
4132
- }
4133
- return submit();
4134
- };
4135
- return {
4136
- activeScreenshot,
4137
- annotateScreenshot: (id, trigger) => {
4138
- setAnnotationTrigger(trigger);
4139
- gallery.selectScreenshot(id);
4140
- setStep("annotate");
4141
- },
4142
- annotations,
4143
- annotationTrigger,
4144
- canDismiss: dismissForDays !== null,
4145
- canSubmit: reportedMessage.length > 0,
4146
- capture,
4147
- close,
4148
- dismiss,
4149
- dismissForDays,
4150
- email,
4151
- error,
4152
- hasPendingAttachments: submission.hasPendingAttachments,
4153
- honeypot,
4154
- isAnonymous,
4155
- isCapturing,
4156
- isDismissed: dismissedUntil > Date.now(),
4157
- isEditingDisabled: submission.isEditingDisabled,
4158
- isOpen,
4159
- isSubmitting: submission.isSubmitting,
4160
- message,
4161
- open,
4162
- pendingCapture: gallery.pendingCapture,
4163
- removeCapture: gallery.removeCapture,
4164
- requestSubmit,
4165
- retakeCapture: gallery.retakeCapture,
4166
- screenshots: gallery.screenshots,
4167
- selectElement,
4168
- selections,
4169
- setAnnotations: gallery.setAnnotations,
4170
- setEmail,
4171
- setHoneypot,
4172
- setMessage,
4173
- setSelectionComment: gallery.setSelectionComment,
4174
- setStep,
4175
- step,
4176
- submit,
4177
- takeCapture: gallery.takeCapture
4178
- };
4231
+ if (!shadowRoot) return null;
4232
+ return createPortal(children, shadowRoot);
4179
4233
  }
4180
4234
  //#endregion
4181
4235
  //#region src/feedback/widget.tsx
@@ -4186,25 +4240,7 @@ function RefletFeedback(props) {
4186
4240
  ...props.labels
4187
4241
  };
4188
4242
  const state = useWidgetState(props);
4189
- const { close, isOpen, open } = state;
4190
- useEffect(() => {
4191
- if (!(hotkey && enabled)) return;
4192
- const onKeyDown = (event) => {
4193
- if (matchesHotkey(event, hotkey)) {
4194
- event.preventDefault();
4195
- if (isOpen) close();
4196
- else open();
4197
- }
4198
- };
4199
- document.addEventListener("keydown", onKeyDown, true);
4200
- return () => document.removeEventListener("keydown", onKeyDown, true);
4201
- }, [
4202
- close,
4203
- enabled,
4204
- hotkey,
4205
- isOpen,
4206
- open
4207
- ]);
4243
+ const { isOpen } = state;
4208
4244
  if (!enabled || state.isDismissed) return null;
4209
4245
  return /* @__PURE__ */ jsxs(ShadowPortal, {
4210
4246
  offset,
@@ -4217,6 +4253,7 @@ function RefletFeedback(props) {
4217
4253
  "data-active": state.isCapturing
4218
4254
  }),
4219
4255
  /* @__PURE__ */ jsx(FloatingWidget, {
4256
+ hotkey,
4220
4257
  labels,
4221
4258
  position,
4222
4259
  state