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