reflet-sdk 0.3.2 → 0.3.4

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,14 +1,165 @@
1
1
  "use client";
2
2
  import { t as Reflet } from "./client-BYuSKJw5.js";
3
3
  import { t as RefletContext } from "./react-context-DZw_HHjj.js";
4
- import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
5
- import { jsx, jsxs } from "react/jsx-runtime";
4
+ import { useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
5
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
6
+ import { Slider } from "@base-ui/react/slider";
7
+ import { ScrollArea } from "@base-ui/react/scroll-area";
6
8
  import { createPortal } from "react-dom";
7
9
  //#region src/feedback/core/capture.ts
8
10
  /** Marks every node the widget owns, so captures never include the panel. */
9
11
  const WIDGET_MARKER = "data-reflet-widget";
10
12
  const MAX_PIXEL_RATIO = 2;
11
13
  const DEFAULT_MAX_WIDTH = 2400;
14
+ const MAX_INLINE_BYTES = 3e6;
15
+ const TRANSPARENT_PIXEL = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
16
+ const EXTERNAL_URL = /^(https?:)?\/\//i;
17
+ const CSS_URL = /url\((['"]?)([^)'"]+)\1\)/g;
18
+ function isExternalImageUrl(url) {
19
+ if (!url || url.startsWith("data:") || url.startsWith("blob:")) return false;
20
+ if (!EXTERNAL_URL.test(url)) return false;
21
+ try {
22
+ return new URL(url, window.location.href).origin !== window.location.origin;
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+ function toDataUrl(url) {
28
+ return new Promise((resolve) => {
29
+ fetch(url, { mode: "cors" }).then(async (response) => {
30
+ if (!response.ok) {
31
+ resolve(null);
32
+ return;
33
+ }
34
+ const blob = await response.blob();
35
+ if (blob.size === 0 || blob.size > MAX_INLINE_BYTES) {
36
+ resolve(null);
37
+ return;
38
+ }
39
+ const reader = new FileReader();
40
+ reader.onload = () => resolve(typeof reader.result === "string" ? reader.result : null);
41
+ reader.onerror = () => resolve(null);
42
+ reader.readAsDataURL(blob);
43
+ }).catch(() => resolve(null));
44
+ });
45
+ }
46
+ const PNG_NORMALIZE_TIMEOUT = 750;
47
+ const CAPTURE_TIMEOUT = 1e4;
48
+ const ANIMATION_SETTLE_TIMEOUT = 1e3;
49
+ /**
50
+ * Tiles and sections routinely animate in or reflow (grid drags, entrance
51
+ * transitions); a capture taken mid-flight freezes them displaced. Waits for
52
+ * animations touching the page to finish, capped so an infinite spinner can
53
+ * never stall the report.
54
+ */
55
+ async function waitForPageAnimations() {
56
+ if (typeof document.getAnimations !== "function") return;
57
+ const start = performance.now();
58
+ while (performance.now() - start < ANIMATION_SETTLE_TIMEOUT) {
59
+ const running = document.getAnimations().filter((animation) => animation.playState === "running");
60
+ if (running.length === 0) return;
61
+ await Promise.race([Promise.allSettled(running.map((animation) => animation.finished)), new Promise((resolve) => setTimeout(resolve, 150))]);
62
+ }
63
+ }
64
+ /**
65
+ * snapdom awaits `decode()` on images created with `decoding="sync"` — a
66
+ * promise Chrome 151 sometimes never settles (observed for cross-origin
67
+ * webp, cached or inlined). Re-encoding through a canvas yields a PNG data
68
+ * url, which decodes reliably; the load itself goes through `onload`, never
69
+ * `decode()`.
70
+ */
71
+ function normalizeToPng(dataUrl) {
72
+ return new Promise((resolve) => {
73
+ const image = new Image();
74
+ const timeout = setTimeout(() => resolve(dataUrl), PNG_NORMALIZE_TIMEOUT);
75
+ image.onload = () => {
76
+ clearTimeout(timeout);
77
+ try {
78
+ if (image.naturalWidth === 0 || image.naturalHeight === 0) {
79
+ resolve(null);
80
+ return;
81
+ }
82
+ const canvas = document.createElement("canvas");
83
+ canvas.width = image.naturalWidth;
84
+ canvas.height = image.naturalHeight;
85
+ const context = canvas.getContext("2d");
86
+ if (!context) {
87
+ resolve(dataUrl);
88
+ return;
89
+ }
90
+ context.drawImage(image, 0, 0);
91
+ resolve(canvas.toDataURL("image/png"));
92
+ } catch {
93
+ resolve(null);
94
+ }
95
+ };
96
+ image.onerror = () => {
97
+ clearTimeout(timeout);
98
+ resolve(null);
99
+ };
100
+ image.src = dataUrl;
101
+ });
102
+ }
103
+ async function toInlineUrl(url) {
104
+ const dataUrl = await toDataUrl(url);
105
+ if (!dataUrl) return null;
106
+ return normalizeToPng(dataUrl);
107
+ }
108
+ function remember(swaps, element, name) {
109
+ swaps.push({
110
+ element,
111
+ name,
112
+ value: element.getAttribute(name)
113
+ });
114
+ }
115
+ async function inlineImgSource(img, swaps) {
116
+ const src = img.getAttribute("src");
117
+ if (!(src && isExternalImageUrl(src))) return;
118
+ const dataUrl = await toInlineUrl(src);
119
+ if (dataUrl) {
120
+ if (img.hasAttribute("srcset")) {
121
+ remember(swaps, img, "srcset");
122
+ img.removeAttribute("srcset");
123
+ }
124
+ if (img.hasAttribute("sizes")) {
125
+ remember(swaps, img, "sizes");
126
+ img.removeAttribute("sizes");
127
+ }
128
+ }
129
+ remember(swaps, img, "src");
130
+ img.setAttribute("src", dataUrl ?? TRANSPARENT_PIXEL);
131
+ }
132
+ async function inlineBackgroundImages(element, swaps) {
133
+ const style = element.getAttribute("style");
134
+ if (!style) return;
135
+ const urls = [...style.matchAll(CSS_URL)].map((match) => match[2] ?? "");
136
+ if (!urls.some(isExternalImageUrl)) return;
137
+ let next = style;
138
+ for (const url of urls) {
139
+ if (!isExternalImageUrl(url)) continue;
140
+ const dataUrl = await toInlineUrl(url);
141
+ next = next.split(url).join(dataUrl ?? TRANSPARENT_PIXEL);
142
+ }
143
+ if (next !== style) {
144
+ remember(swaps, element, "style");
145
+ element.setAttribute("style", next);
146
+ }
147
+ }
148
+ /**
149
+ * Swaps every cross-origin image under `root` for a data URL so the capture
150
+ * cannot hit snapdom's decode-failure hang. Returns the undo list — always
151
+ * run it, even after a failed capture.
152
+ */
153
+ async function inlineExternalImages(root) {
154
+ const swaps = [];
155
+ for (const img of [...root.querySelectorAll("img")]) await inlineImgSource(img, swaps);
156
+ for (const el of [...root.querySelectorAll("[style*='url(']")]) await inlineBackgroundImages(el, swaps);
157
+ return swaps;
158
+ }
159
+ function restoreInlinedImages(swaps) {
160
+ for (const swap of swaps) if (swap.value === null) swap.element.removeAttribute(swap.name);
161
+ else swap.element.setAttribute(swap.name, swap.value);
162
+ }
12
163
  function buildSnapdomOptions(options) {
13
164
  const ratio = options.devicePixelRatio ?? 1;
14
165
  return {
@@ -18,6 +169,7 @@ function buildSnapdomOptions(options) {
18
169
  embedFonts: true,
19
170
  exclude: [`[${WIDGET_MARKER}]`, ...options.excludeSelectors ?? []],
20
171
  fast: true,
172
+ filter: options.filter,
21
173
  reconcile: true
22
174
  };
23
175
  }
@@ -34,7 +186,7 @@ function fitWithin(size, maxWidth) {
34
186
  width: maxWidth
35
187
  };
36
188
  }
37
- function toBlob$1(canvas) {
189
+ function toBlob(canvas) {
38
190
  return new Promise((resolve) => {
39
191
  canvas.toBlob(resolve, "image/png");
40
192
  });
@@ -56,7 +208,7 @@ async function capturedFromCanvas(source, maxWidth) {
56
208
  width: source.width
57
209
  }, maxWidth);
58
210
  const output = fitted.scale === 1 ? source : downscale(source, fitted);
59
- const blob = await toBlob$1(output);
211
+ const blob = await toBlob(output);
60
212
  if (!blob) return null;
61
213
  return {
62
214
  blob,
@@ -78,11 +230,27 @@ async function captureViewport(options = {}) {
78
230
  try {
79
231
  const { snapdom } = await import("@zumer/snapdom");
80
232
  const target = options.element ?? document.documentElement;
81
- return await capturedFromCanvas(await snapdom.toCanvas(target, buildSnapdomOptions({
82
- devicePixelRatio: window.devicePixelRatio,
83
- element: options.element,
84
- excludeSelectors: options.excludeSelectors
85
- })), options.maxWidth ?? DEFAULT_MAX_WIDTH);
233
+ await waitForPageAnimations();
234
+ const swaps = await inlineExternalImages(target);
235
+ const shoot = (withoutImages = false) => {
236
+ const attempt = snapdom.toCanvas(target, buildSnapdomOptions({
237
+ devicePixelRatio: window.devicePixelRatio,
238
+ element: options.element,
239
+ excludeSelectors: options.excludeSelectors,
240
+ filter: withoutImages ? (element) => element.tagName !== "IMG" : void 0
241
+ }));
242
+ attempt.catch(() => void 0);
243
+ return Promise.race([attempt, new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error("capture timed out")), options.timeout ?? CAPTURE_TIMEOUT))]);
244
+ };
245
+ let canvas;
246
+ try {
247
+ canvas = await shoot();
248
+ } catch {
249
+ canvas = await shoot(true);
250
+ } finally {
251
+ restoreInlinedImages(swaps);
252
+ }
253
+ return await capturedFromCanvas(canvas, options.maxWidth ?? DEFAULT_MAX_WIDTH);
86
254
  } catch {
87
255
  return null;
88
256
  }
@@ -641,29 +809,40 @@ function collectPageContext(options) {
641
809
  }
642
810
  //#endregion
643
811
  //#region src/feedback/types.ts
644
- /** Reported with every submission. */
645
- const SDK_VERSION = "0.3.2";
812
+ const SDK_VERSION = "0.3.4";
646
813
  const DEFAULT_WIDGET_LABELS = {
647
814
  annotateHint: "Draw on the screenshot to point at the problem.",
815
+ attachmentUploadFailed: "Your feedback was saved, but some screenshots are still pending. Retry to attach them.",
648
816
  attachScreenshot: "Attach a screenshot",
649
817
  back: "Back",
650
818
  cancel: "Cancel",
819
+ captureFailed: "Screenshot unavailable. Try again or send without it.",
820
+ capturing: "Taking screenshot…",
651
821
  categoryBug: "Bug",
652
822
  categoryIdea: "Idea",
823
+ categoryLabel: "Feedback type",
653
824
  categoryQuestion: "Question",
654
825
  clearAnnotations: "Clear drawing",
655
- descriptionPlaceholder: "What happened? What did you expect?",
826
+ clearSelection: "Remove selected element",
827
+ descriptionPlaceholder: "What would you like to share?",
656
828
  dismissForDays: "Hide for {days} days",
657
829
  done: "Done",
830
+ emailInvalid: "Enter a valid email address.",
658
831
  emailLabel: "Email",
659
832
  emailPlaceholder: "you@company.com",
660
833
  errorGeneric: "Something went wrong. Please try again.",
661
834
  errorTitleRequired: "Tell us a bit about it first.",
835
+ minimize: "Minimize feedback",
836
+ moreOptions: "More options",
837
+ moveFeedback: "Move feedback",
662
838
  pickElement: "Point at an element",
663
839
  pickElementHint: "Click the element you are talking about — cancel with",
664
840
  recapture: "Retake",
665
841
  removeScreenshot: "Remove",
842
+ resume: "Resume feedback",
666
843
  retakeHint: "Screenshot of this page",
844
+ retryAttachments: "Retry attachments",
845
+ screenshot: "Screenshot",
667
846
  submit: "Send feedback",
668
847
  successMessage: "We read every report. Thanks for taking the time.",
669
848
  successTitle: "Feedback sent",
@@ -674,15 +853,10 @@ const DEFAULT_WIDGET_LABELS = {
674
853
  };
675
854
  //#endregion
676
855
  //#region src/feedback/core/annotations.ts
677
- const ANNOTATION_COLORS = [
678
- "#ef4444",
679
- "#f59e0b",
680
- "#22c55e",
681
- "#3b82f6",
682
- "#111827"
683
- ];
684
856
  const BOX_TOOLS = [
857
+ "text",
685
858
  "rectangle",
859
+ "spotlight",
686
860
  "highlight",
687
861
  "blur"
688
862
  ];
@@ -723,6 +897,7 @@ function simplifyPath(points, tolerance = DEFAULT_PATH_TOLERANCE) {
723
897
  return kept;
724
898
  }
725
899
  function isDegenerate(annotation) {
900
+ if (annotation.tool === "text") return !annotation.text?.trim();
726
901
  if (annotation.tool === "pen") return (annotation.points?.length ?? 0) < 2;
727
902
  const { start, end } = annotation;
728
903
  return Math.hypot(end.x - start.x, end.y - start.y) < MIN_GESTURE_LENGTH;
@@ -741,6 +916,7 @@ function toWire(annotation) {
741
916
  return {
742
917
  color,
743
918
  height: rect.height,
919
+ ...tool === "text" ? { text: annotation.text } : {},
744
920
  type: tool,
745
921
  width: rect.width,
746
922
  x: rect.x,
@@ -761,144 +937,20 @@ function toWireAnnotations(annotations) {
761
937
  return annotations.filter((item) => !isDegenerate(item)).map(toWire);
762
938
  }
763
939
  //#endregion
764
- //#region src/feedback/core/annotation-renderer.ts
765
- const STROKE_WIDTH = 3;
766
- const ARROW_HEAD_SIZE = 16;
767
- const HIGHLIGHT_ALPHA = .3;
768
- const REDACTION_FILL = "#111827";
769
- const PIXEL_BLOCK = 10;
770
- function scalePoint(point, scale) {
771
- return {
772
- x: point.x * scale,
773
- y: point.y * scale
774
- };
775
- }
776
- function scaleRect(rect, scale) {
777
- return {
778
- height: rect.height * scale,
779
- width: rect.width * scale,
780
- x: rect.x * scale,
781
- y: rect.y * scale
782
- };
783
- }
784
- function isCanvasElement(value) {
785
- return typeof HTMLCanvasElement !== "undefined" && value instanceof HTMLCanvasElement;
786
- }
787
- /**
788
- * Downsamples the region and paints it back enlarged, so redacted pixels are
789
- * unrecoverable. Falls back to an opaque block wherever 2d canvases are absent.
790
- */
791
- function redact(context, rect) {
792
- const source = context.canvas;
793
- const width = Math.max(1, Math.round(rect.width / PIXEL_BLOCK));
794
- const height = Math.max(1, Math.round(rect.height / PIXEL_BLOCK));
795
- if (isCanvasElement(source)) {
796
- const offscreen = document.createElement("canvas");
797
- offscreen.width = width;
798
- offscreen.height = height;
799
- const offscreenContext = offscreen.getContext("2d");
800
- if (offscreenContext) {
801
- offscreenContext.imageSmoothingEnabled = true;
802
- offscreenContext.drawImage(source, rect.x, rect.y, rect.width, rect.height, 0, 0, width, height);
803
- context.drawImage(offscreen, rect.x, rect.y, rect.width, rect.height);
804
- return;
805
- }
806
- }
807
- context.fillStyle = REDACTION_FILL;
808
- context.fillRect(rect.x, rect.y, rect.width, rect.height);
809
- }
810
- function drawArrow(context, start, end, scale) {
811
- context.beginPath();
812
- context.moveTo(start.x, start.y);
813
- context.lineTo(end.x, end.y);
814
- context.stroke();
815
- const [left, right] = arrowHead(start, end, ARROW_HEAD_SIZE * scale);
816
- context.beginPath();
817
- context.moveTo(end.x, end.y);
818
- context.lineTo(left.x, left.y);
819
- context.lineTo(right.x, right.y);
820
- context.closePath();
821
- context.fill();
822
- }
823
- function drawPen(context, points, scale) {
824
- const [first, ...rest] = points;
825
- if (!first) return;
826
- const head = scalePoint(first, scale);
827
- context.beginPath();
828
- context.moveTo(head.x, head.y);
829
- for (const point of rest) {
830
- const scaled = scalePoint(point, scale);
831
- context.lineTo(scaled.x, scaled.y);
832
- }
833
- context.stroke();
834
- }
835
- function drawAnnotation(context, annotation, scale) {
836
- const { color, tool } = annotation;
837
- const rect = scaleRect(normalizeRect(annotation.start, annotation.end), scale);
838
- context.save();
839
- context.lineWidth = STROKE_WIDTH * scale;
840
- context.lineCap = "round";
841
- context.lineJoin = "round";
842
- context.strokeStyle = color;
843
- context.fillStyle = color;
844
- switch (tool) {
845
- case "pen":
846
- drawPen(context, annotation.points ?? [], scale);
847
- break;
848
- case "arrow":
849
- drawArrow(context, scalePoint(annotation.start, scale), scalePoint(annotation.end, scale), scale);
850
- break;
851
- case "rectangle":
852
- context.strokeRect(rect.x, rect.y, rect.width, rect.height);
853
- break;
854
- case "highlight":
855
- context.globalAlpha = HIGHLIGHT_ALPHA;
856
- context.fillRect(rect.x, rect.y, rect.width, rect.height);
857
- break;
858
- default: redact(context, rect);
859
- }
860
- context.restore();
861
- }
862
- function drawAnnotations(context, annotations, options) {
863
- const scale = options?.scale ?? 1;
864
- for (const annotation of annotations) if (!isDegenerate(annotation)) drawAnnotation(context, annotation, scale);
865
- }
866
- function loadImage(url) {
867
- return new Promise((resolve, reject) => {
868
- const image = new Image();
869
- image.onload = () => resolve(image);
870
- image.onerror = () => reject(/* @__PURE__ */ new Error("Screenshot could not be decoded"));
871
- image.src = url;
872
- });
873
- }
874
- function toBlob(canvas) {
875
- return new Promise((resolve) => {
876
- canvas.toBlob(resolve, "image/png");
877
- });
878
- }
879
- /**
880
- * Burns the annotations into a copy of the capture so reviewers and agents see
881
- * the drawing without needing to replay the vector data.
882
- */
883
- async function renderAnnotatedImage(source, annotations) {
884
- if (annotations.length === 0) return null;
885
- const canvas = document.createElement("canvas");
886
- canvas.width = source.width;
887
- canvas.height = source.height;
888
- const context = canvas.getContext("2d");
889
- if (!context) return null;
890
- const image = await loadImage(source.objectUrl);
891
- context.drawImage(image, 0, 0, source.width, source.height);
892
- drawAnnotations(context, annotations);
893
- const blob = await toBlob(canvas);
894
- if (!blob) return null;
895
- return {
896
- blob,
897
- height: source.height,
898
- mimeType: "image/png",
899
- objectUrl: URL.createObjectURL(blob),
900
- width: source.width
901
- };
940
+ //#region src/feedback/ui/annotation/color-value.ts
941
+ const DEFAULT_ANNOTATION_COLOR = {
942
+ baseColor: "#ef4444",
943
+ strengthPercent: 100
944
+ };
945
+ function annotationColorHex({ baseColor, strengthPercent }) {
946
+ return `#${[
947
+ 1,
948
+ 3,
949
+ 5
950
+ ].map((offset) => {
951
+ const channel = Number.parseInt(baseColor.slice(offset, offset + 2), 16);
952
+ return Math.round(255 + (channel - 255) * strengthPercent / 100).toString(16).padStart(2, "0");
953
+ }).join("")}`;
902
954
  }
903
955
  //#endregion
904
956
  //#region src/feedback/ui/icons.tsx
@@ -930,28 +982,6 @@ function CloseIcon({ className }) {
930
982
  children: /* @__PURE__ */ jsx("path", { d: "M18 6 6 18M6 6l12 12" })
931
983
  });
932
984
  }
933
- function BugIcon() {
934
- return /* @__PURE__ */ jsx(Svg, {
935
- size: 14,
936
- children: /* @__PURE__ */ jsx("path", { d: "M8 6a4 4 0 0 1 8 0M6 10h12v4a6 6 0 0 1-12 0zM3 12h3M18 12h3M4.5 6.5 7 8M19.5 6.5 17 8M4.5 17.5 7 16M19.5 17.5 17 16" })
937
- });
938
- }
939
- function IdeaIcon() {
940
- return /* @__PURE__ */ jsx(Svg, {
941
- size: 14,
942
- children: /* @__PURE__ */ jsx("path", { d: "M9 18h6M10 21h4M12 3a6 6 0 0 0-3.6 10.8c.5.4.8.9.9 1.5l.1.7h5.2l.1-.7c.1-.6.4-1.1.9-1.5A6 6 0 0 0 12 3" })
943
- });
944
- }
945
- function QuestionIcon() {
946
- return /* @__PURE__ */ jsxs(Svg, {
947
- size: 14,
948
- children: [/* @__PURE__ */ jsx("circle", {
949
- cx: "12",
950
- cy: "12",
951
- r: "9"
952
- }), /* @__PURE__ */ jsx("path", { d: "M9.6 9.4a2.5 2.5 0 1 1 3.3 2.4c-.6.2-.9.8-.9 1.4v.3M12 17h.01" })]
953
- });
954
- }
955
985
  function CameraIcon() {
956
986
  return /* @__PURE__ */ jsxs(Svg, { children: [/* @__PURE__ */ jsx("path", { d: "M3 8.5A1.5 1.5 0 0 1 4.5 7h2.2l1.2-2h8.2l1.2 2h2.2A1.5 1.5 0 0 1 21 8.5v9A1.5 1.5 0 0 1 19.5 19h-15A1.5 1.5 0 0 1 3 17.5z" }), /* @__PURE__ */ jsx("circle", {
957
987
  cx: "12",
@@ -975,21 +1005,6 @@ function TrashIcon() {
975
1005
  function ArrowIcon() {
976
1006
  return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M5 19 19 5M11 5h8v8" }) });
977
1007
  }
978
- function SquareIcon() {
979
- return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("rect", {
980
- height: "13",
981
- rx: "1.5",
982
- width: "16",
983
- x: "4",
984
- y: "5.5"
985
- }) });
986
- }
987
- function HighlightIcon() {
988
- return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M4 15h16M6 5h12v7H6z" }) });
989
- }
990
- function BlurIcon() {
991
- return /* @__PURE__ */ jsxs(Svg, { children: [/* @__PURE__ */ jsx("path", { d: "M12 3c3.5 4 5.5 6.6 5.5 9.2A5.5 5.5 0 0 1 6.5 12.2C6.5 9.6 8.5 7 12 3" }), /* @__PURE__ */ jsx("path", { d: "M9 13h.01M12 15h.01M15 12h.01" })] });
992
- }
993
1008
  function UndoIcon() {
994
1009
  return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M4 9h11a5 5 0 0 1 0 10h-5M4 9l4-4M4 9l4 4" }) });
995
1010
  }
@@ -1007,83 +1022,742 @@ function CheckIcon({ size = 28 }) {
1007
1022
  children: /* @__PURE__ */ jsx("path", { d: "m5 13 4 4L19 7" })
1008
1023
  });
1009
1024
  }
1010
- //#endregion
1011
- //#region src/feedback/ui/annotator.tsx
1012
- const TOOLS = [
1013
- {
1014
- icon: PencilIcon,
1015
- id: "pen",
1016
- label: "Draw"
1017
- },
1018
- {
1019
- icon: ArrowIcon,
1020
- id: "arrow",
1021
- label: "Arrow"
1022
- },
1023
- {
1024
- icon: SquareIcon,
1025
- id: "rectangle",
1026
- label: "Box"
1027
- },
1028
- {
1029
- icon: HighlightIcon,
1030
- id: "highlight",
1031
- label: "Highlight"
1032
- },
1033
- {
1034
- icon: BlurIcon,
1035
- id: "blur",
1036
- label: "Hide"
1037
- }
1038
- ];
1039
- function toImagePoint(canvas, clientX, clientY) {
1040
- const bounds = canvas.getBoundingClientRect();
1041
- return {
1042
- x: (clientX - bounds.left) / bounds.width * canvas.width,
1043
- y: (clientY - bounds.top) / bounds.height * canvas.height
1044
- };
1025
+ function GripIcon() {
1026
+ return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M9 5h.01M15 5h.01M9 12h.01M15 12h.01M9 19h.01M15 19h.01" }) });
1045
1027
  }
1046
- function Annotator({ annotations, capture, labels, onChange, onDone }) {
1047
- const canvasRef = useRef(null);
1048
- const nextId = useRef(0);
1049
- const [tool, setTool] = useState("pen");
1050
- const [color, setColor] = useState(ANNOTATION_COLORS[0]);
1051
- const [draft, setDraft] = useState(null);
1052
- const [base, setBase] = useState(null);
1053
- useEffect(() => {
1054
- let active = true;
1055
- const image = new Image();
1056
- image.onload = () => {
1057
- if (active) setBase(image);
1058
- };
1059
- image.src = capture.objectUrl;
1060
- return () => {
1061
- active = false;
1062
- setBase(null);
1063
- };
1064
- }, [capture.objectUrl]);
1028
+ function MinusIcon() {
1029
+ return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M5 12h14" }) });
1030
+ }
1031
+ function MoreIcon() {
1032
+ return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M5 12h.01M12 12h.01M19 12h.01" }) });
1033
+ }
1034
+ function ChevronDownIcon() {
1035
+ return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "m7 10 5 5 5-5" }) });
1036
+ }
1037
+ function SquareIcon() {
1038
+ return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("rect", {
1039
+ height: "14",
1040
+ rx: "2",
1041
+ width: "14",
1042
+ x: "5",
1043
+ y: "5"
1044
+ }) });
1045
+ }
1046
+ function TextIcon() {
1047
+ return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M5 6V4h14v2M12 4v16M8 20h8" }) });
1048
+ }
1049
+ function SpotlightIcon() {
1050
+ return /* @__PURE__ */ jsxs(Svg, { children: [/* @__PURE__ */ jsx("path", { d: "M8 4H4v4M16 4h4v4M4 16v4h4M20 16v4h-4" }), /* @__PURE__ */ jsx("rect", {
1051
+ height: "8",
1052
+ rx: "1",
1053
+ width: "8",
1054
+ x: "8",
1055
+ y: "8"
1056
+ })] });
1057
+ }
1058
+ function HighlightIcon() {
1059
+ return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M4 19h16M8 15l8-11 4 3-8 11-4-3ZM7 15l-2 3" }) });
1060
+ }
1061
+ function BlurIcon() {
1062
+ return /* @__PURE__ */ jsxs(Svg, { children: [/* @__PURE__ */ jsx("rect", {
1063
+ height: "14",
1064
+ rx: "2",
1065
+ width: "14",
1066
+ x: "5",
1067
+ y: "5"
1068
+ }), /* @__PURE__ */ jsx("path", { d: "M8 8h8v8H8z" })] });
1069
+ }
1070
+ //#endregion
1071
+ //#region src/feedback/ui/annotation/text/text-editor.tsx
1072
+ function TextAnnotationEditor({ draft, onCommit, onCancel }) {
1073
+ const [text, setText] = useState(draft.annotation.text ?? "");
1074
+ const inputRef = useRef(null);
1075
+ const id = useId().replaceAll(":", "");
1076
+ useEffect(() => {
1077
+ inputRef.current?.focus();
1078
+ }, []);
1079
+ return /* @__PURE__ */ jsxs("form", {
1080
+ className: "glass text-annotation-editor",
1081
+ id,
1082
+ onSubmit: (event) => {
1083
+ event.preventDefault();
1084
+ onCommit(text);
1085
+ },
1086
+ children: [
1087
+ /* @__PURE__ */ jsx("style", { children: `[id="${id}"] { left: clamp(12px, ${draft.position.x}px, calc(100vw - 252px)); top: clamp(12px, ${draft.position.y}px, calc(100dvh - 148px)); }` }),
1088
+ /* @__PURE__ */ jsx("textarea", {
1089
+ "aria-label": "Text annotation",
1090
+ maxLength: 280,
1091
+ onBlur: (event) => {
1092
+ if (!event.currentTarget.form?.contains(event.relatedTarget)) onCommit(text);
1093
+ },
1094
+ onChange: (event) => setText(event.target.value),
1095
+ onKeyDown: (event) => {
1096
+ if (event.key === "Escape") {
1097
+ event.preventDefault();
1098
+ event.stopPropagation();
1099
+ onCancel();
1100
+ }
1101
+ if (event.key === "Enter" && !event.shiftKey) {
1102
+ event.preventDefault();
1103
+ onCommit(text);
1104
+ }
1105
+ },
1106
+ placeholder: "Type a note…",
1107
+ ref: inputRef,
1108
+ rows: 2,
1109
+ value: text
1110
+ }),
1111
+ /* @__PURE__ */ jsxs("div", {
1112
+ className: "text-annotation-actions",
1113
+ children: [/* @__PURE__ */ jsx("button", {
1114
+ "aria-label": "Cancel text",
1115
+ className: "tool",
1116
+ onClick: onCancel,
1117
+ type: "button",
1118
+ children: /* @__PURE__ */ jsx(CloseIcon, {})
1119
+ }), /* @__PURE__ */ jsx("button", {
1120
+ "aria-label": "Apply text",
1121
+ className: "done-btn",
1122
+ type: "submit",
1123
+ children: /* @__PURE__ */ jsx(CheckIcon, { size: 16 })
1124
+ })]
1125
+ })
1126
+ ]
1127
+ });
1128
+ }
1129
+ //#endregion
1130
+ //#region src/feedback/ui/annotation/text/use-text-annotation.ts
1131
+ function containsPoint(annotation, point) {
1132
+ const bounds = normalizeRect(annotation.start, annotation.end);
1133
+ return point.x >= bounds.x && point.x <= bounds.x + bounds.width && point.y >= bounds.y && point.y <= bounds.y + bounds.height;
1134
+ }
1135
+ function useTextAnnotation(options) {
1136
+ const [textDraft, setTextDraft] = useState(null);
1137
+ const beginText = (canvas, point) => {
1138
+ const bounds = canvas.getBoundingClientRect();
1139
+ const existing = [...options.annotations].reverse().find((item) => item.tool === "text" && containsPoint(item, point));
1140
+ const annotation = existing ?? {
1141
+ color: options.color,
1142
+ end: point,
1143
+ id: crypto.randomUUID(),
1144
+ start: point,
1145
+ tool: "text"
1146
+ };
1147
+ const fontSize = existing ? (existing.end.y - existing.start.y) / (existing.text?.split("\n").length ?? 1) / 1.4 : 18 * canvas.width / bounds.width;
1148
+ setTextDraft({
1149
+ annotation,
1150
+ fontSize,
1151
+ position: {
1152
+ x: bounds.x + annotation.start.x / canvas.width * bounds.width,
1153
+ y: bounds.y + annotation.start.y / canvas.height * bounds.height
1154
+ }
1155
+ });
1156
+ };
1157
+ const commitText = (canvas, text) => {
1158
+ const context = canvas.getContext("2d");
1159
+ if (!(textDraft && context)) return;
1160
+ const value = text.trim();
1161
+ const remaining = options.annotations.filter(({ id }) => id !== textDraft.annotation.id);
1162
+ if (value) {
1163
+ context.font = `${textDraft.fontSize}px ui-sans-serif, system-ui, sans-serif`;
1164
+ const lines = value.split("\n");
1165
+ remaining.push({
1166
+ ...textDraft.annotation,
1167
+ end: {
1168
+ x: textDraft.annotation.start.x + Math.max(...lines.map((line) => context.measureText(line).width)),
1169
+ y: textDraft.annotation.start.y + lines.length * textDraft.fontSize * 1.4
1170
+ },
1171
+ text: value
1172
+ });
1173
+ }
1174
+ options.onChange(remaining);
1175
+ setTextDraft(null);
1176
+ };
1177
+ return {
1178
+ beginText,
1179
+ cancelText: () => setTextDraft(null),
1180
+ commitText,
1181
+ textDraft
1182
+ };
1183
+ }
1184
+ //#endregion
1185
+ //#region src/feedback/ui/floating/use-details-popover.ts
1186
+ function useDetailsPopover() {
1187
+ const [open, setOpen] = useState(false);
1188
+ const [placement, setPlacement] = useState("above");
1189
+ const [anchorLeft, setAnchorLeft] = useState(0);
1190
+ const detailsRef = useRef(null);
1191
+ const summaryRef = useRef(null);
1192
+ useEffect(() => {
1193
+ const reposition = () => {
1194
+ const bounds = summaryRef.current?.getBoundingClientRect();
1195
+ if (bounds && detailsRef.current?.open) {
1196
+ setAnchorLeft(bounds.left);
1197
+ setPlacement(bounds.bottom < window.innerHeight / 2 ? "below" : "above");
1198
+ }
1199
+ };
1200
+ const dismissOutside = (event) => {
1201
+ const details = detailsRef.current;
1202
+ if (details?.open && !event.composedPath().includes(details)) setOpen(false);
1203
+ };
1204
+ const dismissWithEscape = (event) => {
1205
+ if (event.key !== "Escape" || !detailsRef.current?.open) return;
1206
+ event.preventDefault();
1207
+ event.stopPropagation();
1208
+ setOpen(false);
1209
+ summaryRef.current?.focus();
1210
+ };
1211
+ document.addEventListener("pointerdown", dismissOutside);
1212
+ document.addEventListener("keydown", dismissWithEscape, true);
1213
+ window.addEventListener("resize", reposition);
1214
+ return () => {
1215
+ document.removeEventListener("pointerdown", dismissOutside);
1216
+ document.removeEventListener("keydown", dismissWithEscape, true);
1217
+ window.removeEventListener("resize", reposition);
1218
+ };
1219
+ }, []);
1220
+ const show = () => {
1221
+ const bounds = summaryRef.current?.getBoundingClientRect();
1222
+ if (bounds) {
1223
+ setAnchorLeft(bounds.left);
1224
+ setPlacement(bounds.bottom < window.innerHeight / 2 ? "below" : "above");
1225
+ }
1226
+ setOpen(true);
1227
+ };
1228
+ return {
1229
+ anchorLeft,
1230
+ detailsProps: {
1231
+ "data-placement": placement,
1232
+ open,
1233
+ ref: detailsRef
1234
+ },
1235
+ hide: () => {
1236
+ setOpen(false);
1237
+ summaryRef.current?.focus();
1238
+ },
1239
+ show,
1240
+ summaryProps: {
1241
+ onClick: (event) => {
1242
+ event.preventDefault();
1243
+ if (open) setOpen(false);
1244
+ else show();
1245
+ },
1246
+ ref: summaryRef
1247
+ }
1248
+ };
1249
+ }
1250
+ //#endregion
1251
+ //#region src/feedback/ui/annotation/color-picker.tsx
1252
+ function ColorSpectrum({ selection, onChange }) {
1253
+ const inputRef = useRef(null);
1254
+ return /* @__PURE__ */ jsxs("div", {
1255
+ className: "color-spectrum",
1256
+ children: [/* @__PURE__ */ jsx("button", {
1257
+ "aria-label": "Custom color",
1258
+ onClick: () => inputRef.current?.click(),
1259
+ title: "Custom color",
1260
+ type: "button"
1261
+ }), /* @__PURE__ */ jsx("input", {
1262
+ "aria-hidden": "true",
1263
+ onChange: (event) => onChange({
1264
+ baseColor: event.target.value,
1265
+ strengthPercent: 100
1266
+ }),
1267
+ ref: inputRef,
1268
+ tabIndex: -1,
1269
+ type: "color",
1270
+ value: selection.baseColor
1271
+ })]
1272
+ });
1273
+ }
1274
+ function ColorControls({ selection, onChange }) {
1275
+ return /* @__PURE__ */ jsxs("div", {
1276
+ className: "color-popover glass",
1277
+ children: [/* @__PURE__ */ jsx(Slider.Root, {
1278
+ className: "color-slider",
1279
+ onValueChange: (strengthPercent) => onChange({
1280
+ ...selection,
1281
+ strengthPercent
1282
+ }),
1283
+ value: selection.strengthPercent,
1284
+ children: /* @__PURE__ */ jsx(Slider.Control, {
1285
+ className: "color-slider-control",
1286
+ children: /* @__PURE__ */ jsx(Slider.Track, {
1287
+ className: "color-slider-track",
1288
+ children: /* @__PURE__ */ jsx(Slider.Thumb, {
1289
+ "aria-label": "Color strength",
1290
+ className: "color-slider-thumb",
1291
+ getAriaValueText: (_, value) => `${value}% color`
1292
+ })
1293
+ })
1294
+ })
1295
+ }), /* @__PURE__ */ jsx(ColorSpectrum, {
1296
+ onChange,
1297
+ selection
1298
+ })]
1299
+ });
1300
+ }
1301
+ function ColorPicker({ selection, onChange }) {
1302
+ const popover = useDetailsPopover();
1303
+ const id = useId().replaceAll(":", "");
1304
+ return /* @__PURE__ */ jsxs("details", {
1305
+ className: "color-picker",
1306
+ id,
1307
+ ...popover.detailsProps,
1308
+ children: [
1309
+ /* @__PURE__ */ jsx("style", { children: `[id="${id}"] { --rf-color-anchor: ${popover.anchorLeft}px; --annotation-color: ${annotationColorHex(selection)}; --annotation-base-color: ${selection.baseColor}; }` }),
1310
+ /* @__PURE__ */ jsx("summary", {
1311
+ "aria-label": "Annotation color",
1312
+ className: "tool",
1313
+ ...popover.summaryProps,
1314
+ title: "Annotation color",
1315
+ children: /* @__PURE__ */ jsx("span", { className: "color-preview" })
1316
+ }),
1317
+ /* @__PURE__ */ jsx(ColorControls, {
1318
+ onChange,
1319
+ selection
1320
+ })
1321
+ ]
1322
+ });
1323
+ }
1324
+ //#endregion
1325
+ //#region src/feedback/ui/annotation/tool-menu.tsx
1326
+ const EXTRA_TOOLS = [
1327
+ {
1328
+ Icon: SpotlightIcon,
1329
+ id: "spotlight",
1330
+ label: "Spotlight"
1331
+ },
1332
+ {
1333
+ Icon: PencilIcon,
1334
+ id: "pen",
1335
+ label: "Draw"
1336
+ },
1337
+ {
1338
+ Icon: HighlightIcon,
1339
+ id: "highlight",
1340
+ label: "Highlight"
1341
+ },
1342
+ {
1343
+ Icon: BlurIcon,
1344
+ id: "blur",
1345
+ label: "Hide"
1346
+ }
1347
+ ];
1348
+ function DrawingToolMenu({ drawing, editor, labels }) {
1349
+ const popover = useDetailsPopover();
1350
+ return /* @__PURE__ */ jsxs("details", {
1351
+ className: "drawing-menu",
1352
+ ...popover.detailsProps,
1353
+ children: [/* @__PURE__ */ jsx("summary", {
1354
+ "aria-label": "More drawing tools",
1355
+ className: "tool",
1356
+ "data-selected": EXTRA_TOOLS.some(({ id }) => id === drawing.tool),
1357
+ title: "More drawing tools",
1358
+ ...popover.summaryProps,
1359
+ children: /* @__PURE__ */ jsx(ChevronDownIcon, {})
1360
+ }), /* @__PURE__ */ jsxs("div", {
1361
+ className: "drawing-popover glass",
1362
+ children: [
1363
+ EXTRA_TOOLS.map(({ id, label, Icon }) => /* @__PURE__ */ jsxs("button", {
1364
+ "aria-pressed": drawing.tool === id,
1365
+ className: "drawing-option",
1366
+ onClick: () => {
1367
+ drawing.setTool(id);
1368
+ popover.hide();
1369
+ },
1370
+ type: "button",
1371
+ children: [/* @__PURE__ */ jsx(Icon, {}), label]
1372
+ }, id)),
1373
+ /* @__PURE__ */ jsx("div", { className: "drawing-divider" }),
1374
+ /* @__PURE__ */ jsxs("button", {
1375
+ className: "drawing-option",
1376
+ disabled: editor.annotations.length === 0,
1377
+ onClick: () => {
1378
+ editor.onChange([]);
1379
+ popover.hide();
1380
+ },
1381
+ type: "button",
1382
+ children: [/* @__PURE__ */ jsx(TrashIcon, {}), labels.clearAnnotations]
1383
+ }),
1384
+ editor.onRetake && /* @__PURE__ */ jsxs("button", {
1385
+ className: "drawing-option",
1386
+ onClick: editor.onRetake,
1387
+ type: "button",
1388
+ children: [/* @__PURE__ */ jsx(CameraIcon, {}), labels.recapture]
1389
+ })
1390
+ ]
1391
+ })]
1392
+ });
1393
+ }
1394
+ //#endregion
1395
+ //#region src/feedback/ui/annotation/toolbar.tsx
1396
+ const QUICK_TOOLS = [
1397
+ {
1398
+ Icon: SquareIcon,
1399
+ id: "rectangle",
1400
+ label: "Rectangle"
1401
+ },
1402
+ {
1403
+ Icon: ArrowIcon,
1404
+ id: "arrow",
1405
+ label: "Arrow"
1406
+ },
1407
+ {
1408
+ Icon: TextIcon,
1409
+ id: "text",
1410
+ label: "Text"
1411
+ }
1412
+ ];
1413
+ function AnnotationToolbar({ editor, labels, drawing }) {
1414
+ return /* @__PURE__ */ jsxs("div", {
1415
+ className: "toolbar",
1416
+ children: [/* @__PURE__ */ jsxs("div", {
1417
+ className: "group glass",
1418
+ children: [QUICK_TOOLS.map(({ id, label, Icon }) => /* @__PURE__ */ jsx("button", {
1419
+ "aria-label": label,
1420
+ "aria-pressed": drawing.tool === id,
1421
+ className: "tool",
1422
+ onClick: () => drawing.setTool(id),
1423
+ title: label,
1424
+ type: "button",
1425
+ children: /* @__PURE__ */ jsx(Icon, {})
1426
+ }, id)), /* @__PURE__ */ jsx(DrawingToolMenu, {
1427
+ drawing,
1428
+ editor,
1429
+ labels
1430
+ })]
1431
+ }), /* @__PURE__ */ jsxs("div", {
1432
+ className: "group glass",
1433
+ children: [
1434
+ /* @__PURE__ */ jsx(ColorPicker, {
1435
+ onChange: drawing.setColor,
1436
+ selection: drawing.color
1437
+ }),
1438
+ /* @__PURE__ */ jsx("button", {
1439
+ "aria-label": labels.undo,
1440
+ className: "tool",
1441
+ disabled: editor.annotations.length === 0,
1442
+ onClick: () => editor.onChange(editor.annotations.slice(0, -1)),
1443
+ title: labels.undo,
1444
+ type: "button",
1445
+ children: /* @__PURE__ */ jsx(UndoIcon, {})
1446
+ }),
1447
+ /* @__PURE__ */ jsx("button", {
1448
+ "aria-label": labels.done,
1449
+ className: "done-btn",
1450
+ onClick: editor.onDone,
1451
+ title: labels.done,
1452
+ type: "button",
1453
+ children: /* @__PURE__ */ jsx(CheckIcon, { size: 18 })
1454
+ })
1455
+ ]
1456
+ })]
1457
+ });
1458
+ }
1459
+ //#endregion
1460
+ //#region src/feedback/core/annotation-renderer.ts
1461
+ const STROKE_WIDTH = 3;
1462
+ const ARROW_HEAD_SIZE = 16;
1463
+ const HIGHLIGHT_ALPHA = .3;
1464
+ const REDACTION_FILL = "#111827";
1465
+ const PIXEL_BLOCK = 10;
1466
+ function scalePoint(point, scale) {
1467
+ return {
1468
+ x: point.x * scale,
1469
+ y: point.y * scale
1470
+ };
1471
+ }
1472
+ function scaleRect(rect, scale) {
1473
+ return {
1474
+ height: rect.height * scale,
1475
+ width: rect.width * scale,
1476
+ x: rect.x * scale,
1477
+ y: rect.y * scale
1478
+ };
1479
+ }
1480
+ function isCanvasElement(value) {
1481
+ return typeof HTMLCanvasElement !== "undefined" && value instanceof HTMLCanvasElement;
1482
+ }
1483
+ /**
1484
+ * Downsamples the region and paints it back enlarged, so redacted pixels are
1485
+ * unrecoverable. Falls back to an opaque block wherever 2d canvases are absent.
1486
+ */
1487
+ function redact(context, rect) {
1488
+ const source = context.canvas;
1489
+ const width = Math.max(1, Math.round(rect.width / PIXEL_BLOCK));
1490
+ const height = Math.max(1, Math.round(rect.height / PIXEL_BLOCK));
1491
+ if (isCanvasElement(source)) {
1492
+ const offscreen = document.createElement("canvas");
1493
+ offscreen.width = width;
1494
+ offscreen.height = height;
1495
+ const offscreenContext = offscreen.getContext("2d");
1496
+ if (offscreenContext) {
1497
+ offscreenContext.imageSmoothingEnabled = true;
1498
+ offscreenContext.drawImage(source, rect.x, rect.y, rect.width, rect.height, 0, 0, width, height);
1499
+ context.drawImage(offscreen, rect.x, rect.y, rect.width, rect.height);
1500
+ return;
1501
+ }
1502
+ }
1503
+ context.fillStyle = REDACTION_FILL;
1504
+ context.fillRect(rect.x, rect.y, rect.width, rect.height);
1505
+ }
1506
+ function drawArrow(context, start, end, scale) {
1507
+ context.beginPath();
1508
+ context.moveTo(start.x, start.y);
1509
+ context.lineTo(end.x, end.y);
1510
+ context.stroke();
1511
+ const [left, right] = arrowHead(start, end, ARROW_HEAD_SIZE * scale);
1512
+ context.beginPath();
1513
+ context.moveTo(end.x, end.y);
1514
+ context.lineTo(left.x, left.y);
1515
+ context.lineTo(right.x, right.y);
1516
+ context.closePath();
1517
+ context.fill();
1518
+ }
1519
+ function drawPen(context, points, scale) {
1520
+ const [first, ...rest] = points;
1521
+ if (!first) return;
1522
+ const head = scalePoint(first, scale);
1523
+ context.beginPath();
1524
+ context.moveTo(head.x, head.y);
1525
+ for (const point of rest) {
1526
+ const scaled = scalePoint(point, scale);
1527
+ context.lineTo(scaled.x, scaled.y);
1528
+ }
1529
+ context.stroke();
1530
+ }
1531
+ function drawSpotlight(context, rect) {
1532
+ const { width, height } = context.canvas;
1533
+ context.save();
1534
+ context.fillStyle = REDACTION_FILL;
1535
+ context.globalAlpha = .55;
1536
+ context.fillRect(0, 0, width, rect.y);
1537
+ context.fillRect(0, rect.y + rect.height, width, height - rect.y - rect.height);
1538
+ context.fillRect(0, rect.y, rect.x, rect.height);
1539
+ context.fillRect(rect.x + rect.width, rect.y, width - rect.x - rect.width, rect.height);
1540
+ context.restore();
1541
+ context.strokeRect(rect.x, rect.y, rect.width, rect.height);
1542
+ }
1543
+ function drawAnnotation(context, annotation, scale) {
1544
+ const { color, tool } = annotation;
1545
+ const rect = scaleRect(normalizeRect(annotation.start, annotation.end), scale);
1546
+ context.save();
1547
+ context.lineWidth = STROKE_WIDTH * scale;
1548
+ context.lineCap = "round";
1549
+ context.lineJoin = "round";
1550
+ context.strokeStyle = color;
1551
+ context.fillStyle = color;
1552
+ switch (tool) {
1553
+ case "text": {
1554
+ const lines = (annotation.text ?? "").split("\n");
1555
+ const lineHeight = rect.height / lines.length;
1556
+ context.font = `${lineHeight / 1.4}px ui-sans-serif, system-ui, sans-serif`;
1557
+ context.textBaseline = "top";
1558
+ for (const [index, line] of lines.entries()) context.fillText(line, rect.x, rect.y + index * lineHeight);
1559
+ break;
1560
+ }
1561
+ case "pen":
1562
+ drawPen(context, annotation.points ?? [], scale);
1563
+ break;
1564
+ case "arrow":
1565
+ drawArrow(context, scalePoint(annotation.start, scale), scalePoint(annotation.end, scale), scale);
1566
+ break;
1567
+ case "spotlight":
1568
+ drawSpotlight(context, rect);
1569
+ break;
1570
+ case "rectangle":
1571
+ context.strokeRect(rect.x, rect.y, rect.width, rect.height);
1572
+ break;
1573
+ case "highlight":
1574
+ context.globalAlpha = HIGHLIGHT_ALPHA;
1575
+ context.fillRect(rect.x, rect.y, rect.width, rect.height);
1576
+ break;
1577
+ default: redact(context, rect);
1578
+ }
1579
+ context.restore();
1580
+ }
1581
+ function drawAnnotations(context, annotations, options) {
1582
+ const scale = options?.scale ?? 1;
1583
+ for (const annotation of annotations) if (!isDegenerate(annotation)) drawAnnotation(context, annotation, scale);
1584
+ }
1585
+ function loadImage(url) {
1586
+ return new Promise((resolve, reject) => {
1587
+ const image = new Image();
1588
+ image.onload = () => resolve(image);
1589
+ image.onerror = () => reject(/* @__PURE__ */ new Error("Screenshot could not be decoded"));
1590
+ image.src = url;
1591
+ });
1592
+ }
1593
+ /**
1594
+ * Burns the annotations into a copy of the capture so reviewers and agents see
1595
+ * the drawing without needing to replay the vector data.
1596
+ */
1597
+ async function renderAnnotatedImage(source, annotations) {
1598
+ if (annotations.length === 0) return null;
1599
+ const canvas = document.createElement("canvas");
1600
+ canvas.width = source.width;
1601
+ canvas.height = source.height;
1602
+ const context = canvas.getContext("2d");
1603
+ if (!context) return null;
1604
+ const image = await loadImage(source.objectUrl);
1605
+ context.drawImage(image, 0, 0, source.width, source.height);
1606
+ drawAnnotations(context, annotations);
1607
+ return await capturedFromCanvas(canvas, source.width);
1608
+ }
1609
+ //#endregion
1610
+ //#region src/feedback/ui/annotation/use-annotation-canvas.ts
1611
+ function useAnnotationCanvas(capture, annotations) {
1612
+ const canvasRef = useRef(null);
1613
+ const [bitmap, setBitmap] = useState(null);
1614
+ const [failedUrl, setFailedUrl] = useState(null);
1615
+ useEffect(() => {
1616
+ const image = new Image();
1617
+ image.onload = () => setBitmap(image);
1618
+ image.onerror = () => setFailedUrl(capture.objectUrl);
1619
+ image.src = capture.objectUrl;
1620
+ return () => {
1621
+ image.onload = null;
1622
+ image.onerror = null;
1623
+ };
1624
+ }, [capture.objectUrl]);
1625
+ const isDecoded = bitmap?.src === capture.objectUrl;
1065
1626
  useEffect(() => {
1066
1627
  const canvas = canvasRef.current;
1067
1628
  const context = canvas?.getContext("2d");
1068
- if (!(canvas && context)) return;
1629
+ if (!(canvas && context && bitmap && isDecoded)) return;
1069
1630
  context.clearRect(0, 0, canvas.width, canvas.height);
1070
- if (base) context.drawImage(base, 0, 0, canvas.width, canvas.height);
1071
- drawAnnotations(context, draft ? [...annotations, draft] : annotations);
1631
+ context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
1632
+ drawAnnotations(context, annotations);
1072
1633
  }, [
1073
1634
  annotations,
1074
- base,
1075
- draft
1635
+ bitmap,
1636
+ isDecoded
1076
1637
  ]);
1638
+ return {
1639
+ canvasRef,
1640
+ hasError: failedUrl === capture.objectUrl,
1641
+ isDecoded
1642
+ };
1643
+ }
1644
+ //#endregion
1645
+ //#region src/feedback/ui/annotation/use-image-morph.ts
1646
+ function prefersReducedMotion() {
1647
+ return window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
1648
+ }
1649
+ function thumbnailFrame(frame, trigger) {
1650
+ const destination = frame.getBoundingClientRect();
1651
+ const thumbnail = trigger.getBoundingClientRect();
1652
+ const scale = Math.max(thumbnail.width / destination.width, thumbnail.height / destination.height);
1653
+ const insetX = (destination.width - thumbnail.width / scale) / 2;
1654
+ return {
1655
+ clipPath: `inset(0px ${insetX}px ${destination.height - thumbnail.height / scale}px ${insetX}px round ${Number.parseFloat(getComputedStyle(trigger).borderRadius) / scale}px)`,
1656
+ transform: `translate(${thumbnail.x - destination.x - insetX * scale}px, ${thumbnail.y - destination.y}px) scale(${scale})`
1657
+ };
1658
+ }
1659
+ function expandedFrame(visual) {
1660
+ return {
1661
+ clipPath: `inset(0px 0px 0px 0px round ${getComputedStyle(visual).borderRadius})`,
1662
+ transform: "none"
1663
+ };
1664
+ }
1665
+ const TIMING = {
1666
+ duration: 340,
1667
+ easing: "cubic-bezier(0.22, 1, 0.36, 1)"
1668
+ };
1669
+ function useImageMorph(trigger, onDone) {
1670
+ const frameRef = useRef(null);
1671
+ const visualRef = useRef(null);
1672
+ const entrance = useRef(null);
1673
+ const exit = useRef(null);
1674
+ const [isOpening, setIsOpening] = useState(false);
1675
+ const [isClosing, setIsClosing] = useState(false);
1676
+ useLayoutEffect(() => {
1677
+ const frame = frameRef.current;
1678
+ const visual = visualRef.current;
1679
+ if (frame && visual?.animate && trigger && !prefersReducedMotion()) {
1680
+ setIsOpening(true);
1681
+ entrance.current = visual.animate([thumbnailFrame(frame, trigger), expandedFrame(visual)], TIMING);
1682
+ entrance.current.finished.then(() => setIsOpening(false), () => void 0);
1683
+ }
1684
+ return () => {
1685
+ entrance.current?.cancel();
1686
+ exit.current?.cancel();
1687
+ };
1688
+ }, [trigger]);
1689
+ const close = (action = onDone) => {
1690
+ if (exit.current) return;
1691
+ const frame = frameRef.current;
1692
+ const visual = visualRef.current;
1693
+ if (!(frame && visual?.animate && trigger) || prefersReducedMotion()) {
1694
+ action();
1695
+ return;
1696
+ }
1697
+ const current = entrance.current?.playState === "running" ? {
1698
+ clipPath: getComputedStyle(visual).clipPath,
1699
+ transform: getComputedStyle(visual).transform
1700
+ } : expandedFrame(visual);
1701
+ entrance.current?.cancel();
1702
+ setIsClosing(true);
1703
+ const animation = visual.animate([current, thumbnailFrame(frame, trigger)], {
1704
+ ...TIMING,
1705
+ duration: 260,
1706
+ fill: "forwards"
1707
+ });
1708
+ exit.current = animation;
1709
+ animation.finished.then(action, () => void 0);
1710
+ };
1711
+ return {
1712
+ close,
1713
+ frameRef,
1714
+ isClosing,
1715
+ isOpening,
1716
+ visualRef
1717
+ };
1718
+ }
1719
+ //#endregion
1720
+ //#region src/feedback/ui/annotator.tsx
1721
+ function toImagePoint(canvas, clientX, clientY) {
1722
+ const bounds = canvas.getBoundingClientRect();
1723
+ return {
1724
+ x: Math.max(0, Math.min(canvas.width, (clientX - bounds.left) / bounds.width * canvas.width)),
1725
+ y: Math.max(0, Math.min(canvas.height, (clientY - bounds.top) / bounds.height * canvas.height))
1726
+ };
1727
+ }
1728
+ function Annotator({ capture, labels, editor }) {
1729
+ const { annotations, onChange, onDone, onRetake } = editor;
1730
+ const morph = useImageMorph(editor.trigger, onDone);
1731
+ const dialogRef = useRef(null);
1732
+ const [tool, setTool] = useState("rectangle");
1733
+ const [colorSelection, setColorSelection] = useState(DEFAULT_ANNOTATION_COLOR);
1734
+ const color = annotationColorHex(colorSelection);
1735
+ const [draft, setDraft] = useState(null);
1736
+ const { canvasRef, hasError, isDecoded } = useAnnotationCanvas(capture, draft ? [...annotations, draft] : annotations);
1737
+ const text = useTextAnnotation({
1738
+ annotations,
1739
+ color,
1740
+ onChange
1741
+ });
1742
+ useEffect(() => {
1743
+ const dialog = dialogRef.current;
1744
+ dialog?.showModal();
1745
+ return () => dialog?.close();
1746
+ }, []);
1077
1747
  const onPointerDown = (event) => {
1078
1748
  const canvas = canvasRef.current;
1079
- if (!canvas) return;
1080
- canvas.setPointerCapture(event.pointerId);
1749
+ if (!(canvas && isDecoded) || event.button !== 0) return;
1081
1750
  const point = toImagePoint(canvas, event.clientX, event.clientY);
1082
- nextId.current += 1;
1751
+ if (tool === "text") {
1752
+ event.preventDefault();
1753
+ text.beginText(canvas, point);
1754
+ return;
1755
+ }
1756
+ canvas.setPointerCapture(event.pointerId);
1083
1757
  setDraft({
1084
1758
  color,
1085
1759
  end: point,
1086
- id: `a${nextId.current}`,
1760
+ id: crypto.randomUUID(),
1087
1761
  points: tool === "pen" ? [point] : void 0,
1088
1762
  start: point,
1089
1763
  tool
@@ -1107,100 +1781,83 @@ function Annotator({ annotations, capture, labels, onChange, onDone }) {
1107
1781
  if (!isDegenerate(draft)) onChange([...annotations, draft]);
1108
1782
  setDraft(null);
1109
1783
  };
1110
- useEffect(() => {
1111
- const onKeyDown = (event) => {
1112
- if (event.key === "Escape") {
1113
- event.preventDefault();
1114
- onDone();
1115
- }
1116
- };
1117
- document.addEventListener("keydown", onKeyDown);
1118
- return () => document.removeEventListener("keydown", onKeyDown);
1119
- }, [onDone]);
1120
- return /* @__PURE__ */ jsxs("div", {
1784
+ return /* @__PURE__ */ jsxs("dialog", {
1785
+ "aria-label": labels.annotateHint,
1121
1786
  className: "overlay",
1122
- children: [/* @__PURE__ */ jsx("div", {
1123
- className: "editor",
1124
- children: /* @__PURE__ */ jsx("canvas", {
1125
- height: capture.height,
1126
- onPointerCancel: onPointerUp,
1127
- onPointerDown,
1128
- onPointerMove,
1129
- onPointerUp,
1130
- ref: canvasRef,
1131
- width: capture.width
1132
- })
1133
- }), /* @__PURE__ */ jsxs("div", {
1134
- className: "toolbar",
1135
- children: [
1136
- /* @__PURE__ */ jsx("div", {
1137
- className: "group",
1138
- children: TOOLS.map(({ icon: Icon, id, label }) => /* @__PURE__ */ jsx("button", {
1139
- "aria-label": label,
1140
- "aria-pressed": tool === id,
1141
- className: "tool",
1142
- onClick: () => setTool(id),
1143
- title: label,
1144
- type: "button",
1145
- children: /* @__PURE__ */ jsx(Icon, {})
1146
- }, id))
1147
- }),
1148
- /* @__PURE__ */ jsx("div", {
1149
- className: "group",
1150
- children: ANNOTATION_COLORS.map((value) => /* @__PURE__ */ jsx("button", {
1151
- "aria-label": `Color ${value}`,
1152
- "aria-pressed": color === value,
1153
- className: "swatch",
1154
- onClick: () => setColor(value),
1155
- style: { background: value },
1156
- type: "button"
1157
- }, value))
1158
- }),
1159
- /* @__PURE__ */ jsxs("div", {
1160
- className: "group",
1161
- children: [
1162
- /* @__PURE__ */ jsx("button", {
1163
- "aria-label": labels.undo,
1164
- className: "tool",
1165
- disabled: annotations.length === 0,
1166
- onClick: () => onChange(annotations.slice(0, -1)),
1167
- title: labels.undo,
1168
- type: "button",
1169
- children: /* @__PURE__ */ jsx(UndoIcon, {})
1170
- }),
1171
- /* @__PURE__ */ jsx("button", {
1172
- "aria-label": labels.clearAnnotations,
1173
- className: "tool",
1174
- disabled: annotations.length === 0,
1175
- onClick: () => onChange([]),
1176
- title: labels.clearAnnotations,
1177
- type: "button",
1178
- children: /* @__PURE__ */ jsx(TrashIcon, {})
1179
- }),
1180
- /* @__PURE__ */ jsx("button", {
1181
- className: "done-btn",
1182
- onClick: onDone,
1183
- type: "button",
1184
- children: labels.done
1185
- })
1186
- ]
1787
+ "data-closing": morph.isClosing,
1788
+ "data-opening": morph.isOpening,
1789
+ onCancel: (event) => {
1790
+ event.preventDefault();
1791
+ morph.close();
1792
+ },
1793
+ ref: dialogRef,
1794
+ children: [
1795
+ hasError && /* @__PURE__ */ jsx("p", {
1796
+ className: "error",
1797
+ role: "alert",
1798
+ children: labels.captureFailed
1799
+ }),
1800
+ /* @__PURE__ */ jsx("style", { children: `.editor-image { --rf-capture-ratio: ${capture.width / capture.height}; }` }),
1801
+ /* @__PURE__ */ jsx("div", {
1802
+ className: "editor",
1803
+ children: /* @__PURE__ */ jsx("div", {
1804
+ className: "editor-image",
1805
+ ref: morph.frameRef,
1806
+ children: /* @__PURE__ */ jsxs("div", {
1807
+ className: "editor-visual",
1808
+ ref: morph.visualRef,
1809
+ children: [/* @__PURE__ */ jsx("img", {
1810
+ alt: "",
1811
+ height: capture.height,
1812
+ src: capture.objectUrl,
1813
+ width: capture.width
1814
+ }), /* @__PURE__ */ jsx("canvas", {
1815
+ "data-tool": tool,
1816
+ height: capture.height,
1817
+ onPointerCancel: () => setDraft(null),
1818
+ onPointerDown,
1819
+ onPointerMove,
1820
+ onPointerUp,
1821
+ ref: canvasRef,
1822
+ width: capture.width
1823
+ })]
1824
+ })
1187
1825
  })
1188
- ]
1189
- })]
1826
+ }),
1827
+ text.textDraft && /* @__PURE__ */ jsx(TextAnnotationEditor, {
1828
+ draft: text.textDraft,
1829
+ onCancel: text.cancelText,
1830
+ onCommit: (value) => {
1831
+ if (canvasRef.current) text.commitText(canvasRef.current, value);
1832
+ }
1833
+ }, text.textDraft.annotation.id),
1834
+ /* @__PURE__ */ jsx(AnnotationToolbar, {
1835
+ drawing: {
1836
+ color: colorSelection,
1837
+ setColor: setColorSelection,
1838
+ setTool,
1839
+ tool
1840
+ },
1841
+ editor: {
1842
+ annotations,
1843
+ onChange,
1844
+ onDone: () => morph.close(),
1845
+ onRetake: onRetake ? () => morph.close(onRetake) : void 0
1846
+ },
1847
+ labels
1848
+ })
1849
+ ]
1190
1850
  });
1191
1851
  }
1192
1852
  //#endregion
1193
1853
  //#region src/feedback/ui/launcher.tsx
1194
- /**
1195
- * Never changes size between states — the panel opening must not move the
1196
- * button out from under the pointer.
1197
- */
1198
- function Launcher({ isOpen, label, onClick }) {
1854
+ function Launcher({ isOpen, label, onClick, buttonRef }) {
1199
1855
  return /* @__PURE__ */ jsxs("button", {
1200
1856
  "aria-expanded": isOpen,
1201
- className: "launcher",
1857
+ className: "launcher glass",
1202
1858
  "data-open": isOpen,
1203
1859
  onClick,
1860
+ ref: buttonRef,
1204
1861
  type: "button",
1205
1862
  children: [/* @__PURE__ */ jsxs("span", {
1206
1863
  className: "launcher-icon",
@@ -1209,271 +1866,571 @@ function Launcher({ isOpen, label, onClick }) {
1209
1866
  });
1210
1867
  }
1211
1868
  //#endregion
1212
- //#region src/feedback/ui/panel.tsx
1213
- const CATEGORY_META = {
1214
- bug: {
1215
- icon: BugIcon,
1216
- labelKey: "categoryBug"
1217
- },
1218
- idea: {
1219
- icon: IdeaIcon,
1220
- labelKey: "categoryIdea"
1221
- },
1222
- question: {
1223
- icon: QuestionIcon,
1224
- labelKey: "categoryQuestion"
1225
- }
1226
- };
1227
- function Attachment({ labels, state }) {
1228
- if (state.isCapturing) return /* @__PURE__ */ jsxs("div", {
1229
- className: "card",
1230
- children: [/* @__PURE__ */ jsx("div", { className: "thumb" }), /* @__PURE__ */ jsxs("div", {
1231
- className: "meta",
1232
- children: [/* @__PURE__ */ jsx("strong", { children: labels.retakeHint }), /* @__PURE__ */ jsx("span", { children: "…" })]
1233
- })]
1234
- });
1235
- if (!state.capture) return /* @__PURE__ */ jsxs("button", {
1236
- className: "ghost-btn",
1237
- onClick: () => state.takeCapture(),
1238
- type: "button",
1239
- children: [/* @__PURE__ */ jsx(CameraIcon, {}), labels.attachScreenshot]
1240
- });
1241
- const annotationCount = state.annotations.length;
1242
- return /* @__PURE__ */ jsxs("div", {
1243
- className: "card",
1244
- children: [
1245
- /* @__PURE__ */ jsx("div", {
1246
- className: "thumb",
1247
- children: /* @__PURE__ */ jsx("img", {
1248
- alt: "",
1249
- height: state.capture.height,
1250
- src: state.capture.objectUrl,
1251
- width: state.capture.width
1252
- })
1253
- }),
1254
- /* @__PURE__ */ jsxs("div", {
1255
- className: "meta",
1256
- children: [/* @__PURE__ */ jsx("strong", {
1257
- className: "truncate",
1258
- children: labels.retakeHint
1259
- }), /* @__PURE__ */ jsxs("span", {
1260
- className: "truncate",
1261
- children: [
1262
- state.capture.width,
1263
- "×",
1264
- state.capture.height,
1265
- annotationCount > 0 ? ` · ${annotationCount} marks` : ""
1266
- ]
1267
- })]
1268
- }),
1269
- /* @__PURE__ */ jsxs("div", {
1270
- className: "actions",
1869
+ //#region src/feedback/ui/annotation/screenshot-preview.tsx
1870
+ function ScreenshotPreview({ annotations, capture }) {
1871
+ const { canvasRef } = useAnnotationCanvas(capture, annotations);
1872
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("img", {
1873
+ alt: "",
1874
+ draggable: false,
1875
+ height: capture.height,
1876
+ src: capture.objectUrl,
1877
+ width: capture.width
1878
+ }), annotations.length > 0 && /* @__PURE__ */ jsx("canvas", {
1879
+ height: capture.height,
1880
+ ref: canvasRef,
1881
+ width: capture.width
1882
+ })] });
1883
+ }
1884
+ //#endregion
1885
+ //#region src/feedback/ui/composer/screenshot-card.tsx
1886
+ function ScreenshotCard({ item, labels, state }) {
1887
+ const { draft, number, multiple } = item;
1888
+ const isCapturing = state.pendingCapture?.id === draft.id;
1889
+ const disabled = state.isEditingDisabled || state.isCapturing;
1890
+ return /* @__PURE__ */ jsxs("fieldset", {
1891
+ "aria-label": `${labels.screenshot} ${number}`,
1892
+ className: "screenshot-attachment glass",
1893
+ children: [/* @__PURE__ */ jsxs("button", {
1894
+ "aria-label": labels.annotateHint,
1895
+ className: "screenshot-preview",
1896
+ disabled,
1897
+ onClick: (event) => state.annotateScreenshot(draft.id, event.currentTarget),
1898
+ type: "button",
1899
+ children: [/* @__PURE__ */ jsx(ScreenshotPreview, {
1900
+ annotations: draft.annotations,
1901
+ capture: draft.image
1902
+ }), /* @__PURE__ */ jsxs("span", {
1903
+ className: "attachment-caption glass",
1271
1904
  children: [
1272
- /* @__PURE__ */ jsx("button", {
1273
- "aria-label": labels.annotateHint,
1274
- className: "icon-btn",
1275
- onClick: () => state.setStep("annotate"),
1276
- title: labels.annotateHint,
1277
- type: "button",
1278
- children: /* @__PURE__ */ jsx(PencilIcon, {})
1905
+ /* @__PURE__ */ jsx(PencilIcon, {}),
1906
+ /* @__PURE__ */ jsx("span", {
1907
+ className: "attachment-caption-label",
1908
+ children: labels.screenshot
1279
1909
  }),
1280
- /* @__PURE__ */ jsx("button", {
1281
- "aria-label": labels.recapture,
1282
- className: "icon-btn",
1283
- onClick: () => state.takeCapture(),
1284
- title: labels.recapture,
1285
- type: "button",
1286
- children: /* @__PURE__ */ jsx(CameraIcon, {})
1287
- }),
1288
- /* @__PURE__ */ jsx("button", {
1289
- "aria-label": labels.removeScreenshot,
1290
- className: "icon-btn",
1291
- onClick: state.removeCapture,
1292
- title: labels.removeScreenshot,
1293
- type: "button",
1294
- children: /* @__PURE__ */ jsx(TrashIcon, {})
1910
+ multiple && /* @__PURE__ */ jsx("span", { children: number }),
1911
+ draft.annotations.length > 0 && /* @__PURE__ */ jsx("span", {
1912
+ className: "annotation-count",
1913
+ children: draft.annotations.length
1295
1914
  })
1296
1915
  ]
1297
- })
1298
- ]
1916
+ })]
1917
+ }), isCapturing ? /* @__PURE__ */ jsxs("div", {
1918
+ className: "capture-progress glass",
1919
+ role: "status",
1920
+ children: [/* @__PURE__ */ jsx("span", { className: "spinner" }), labels.capturing]
1921
+ }) : /* @__PURE__ */ jsxs("div", {
1922
+ className: "attachment-actions glass",
1923
+ children: [/* @__PURE__ */ jsx("button", {
1924
+ "aria-label": labels.recapture,
1925
+ className: "icon-btn",
1926
+ disabled,
1927
+ onClick: () => state.retakeCapture(draft.id),
1928
+ title: labels.recapture,
1929
+ type: "button",
1930
+ children: /* @__PURE__ */ jsx(CameraIcon, {})
1931
+ }), /* @__PURE__ */ jsx("button", {
1932
+ "aria-label": labels.removeScreenshot,
1933
+ className: "icon-btn",
1934
+ disabled,
1935
+ onClick: () => state.removeCapture(draft.id),
1936
+ title: labels.removeScreenshot,
1937
+ type: "button",
1938
+ children: /* @__PURE__ */ jsx(TrashIcon, {})
1939
+ })]
1940
+ })]
1941
+ });
1942
+ }
1943
+ //#endregion
1944
+ //#region src/feedback/ui/composer/attachments.tsx
1945
+ function SelectedElement({ labels, state }) {
1946
+ if (!state.selection) return null;
1947
+ const component = state.selection.componentStack[0];
1948
+ return /* @__PURE__ */ jsxs("div", {
1949
+ className: "selection-chip glass",
1950
+ children: [/* @__PURE__ */ jsxs("button", {
1951
+ "aria-label": labels.pickElement,
1952
+ className: "selection-label",
1953
+ disabled: state.isEditingDisabled || state.isCapturing,
1954
+ onClick: () => state.setStep("picking"),
1955
+ title: state.selection.sourceLocation ?? state.selection.selector,
1956
+ type: "button",
1957
+ children: [state.elementCapture ? /* @__PURE__ */ jsx("img", {
1958
+ alt: "",
1959
+ height: 32,
1960
+ src: state.elementCapture.objectUrl,
1961
+ width: 32
1962
+ }) : /* @__PURE__ */ jsx(TargetIcon, {}), /* @__PURE__ */ jsx("span", {
1963
+ className: "truncate",
1964
+ children: component ? `<${component}>` : state.selection.label
1965
+ })]
1966
+ }), /* @__PURE__ */ jsx("button", {
1967
+ "aria-label": labels.clearSelection,
1968
+ className: "icon-btn",
1969
+ disabled: state.isEditingDisabled || state.isCapturing,
1970
+ onClick: state.clearSelection,
1971
+ type: "button",
1972
+ children: /* @__PURE__ */ jsx(CloseIcon, {})
1973
+ })]
1299
1974
  });
1300
1975
  }
1301
- /** Shows the reporter exactly what will be sent about the element they picked. */
1302
- function Selection({ labels, state }) {
1303
- const { elementCapture, selection } = state;
1304
- if (!selection) return /* @__PURE__ */ jsxs("button", {
1305
- className: "ghost-btn",
1306
- onClick: () => state.setStep("picking"),
1307
- type: "button",
1308
- children: [/* @__PURE__ */ jsx(TargetIcon, {}), labels.pickElement]
1309
- });
1310
- const component = selection.componentStack[0];
1976
+ function Attachments({ labels, state }) {
1977
+ const stripRef = useRef(null);
1978
+ const count = state.screenshots.length;
1979
+ useEffect(() => {
1980
+ const strip = stripRef.current;
1981
+ if (strip && count > 0) strip.scrollTo?.({ left: strip.scrollWidth });
1982
+ }, [count]);
1983
+ const isAdding = state.pendingCapture && !state.screenshots.some(({ id }) => id === state.pendingCapture?.id);
1311
1984
  return /* @__PURE__ */ jsxs("div", {
1312
- className: "card",
1313
- children: [
1314
- /* @__PURE__ */ jsx("div", {
1315
- className: "thumb contain",
1316
- children: elementCapture && /* @__PURE__ */ jsx("img", {
1317
- alt: "",
1318
- height: elementCapture.height,
1319
- src: elementCapture.objectUrl,
1320
- width: elementCapture.width
1985
+ className: "attachments",
1986
+ children: [/* @__PURE__ */ jsx(SelectedElement, {
1987
+ labels,
1988
+ state
1989
+ }), /* @__PURE__ */ jsxs(ScrollArea.Root, {
1990
+ className: "screenshot-scroll-area",
1991
+ children: [/* @__PURE__ */ jsx(ScrollArea.Viewport, {
1992
+ "aria-label": labels.screenshot,
1993
+ className: "screenshot-strip",
1994
+ ref: stripRef,
1995
+ role: "region",
1996
+ children: /* @__PURE__ */ jsxs(ScrollArea.Content, {
1997
+ className: "screenshot-strip-content",
1998
+ children: [state.screenshots.map((draft, index) => /* @__PURE__ */ jsx(ScreenshotCard, {
1999
+ item: {
2000
+ draft,
2001
+ multiple: count > 1,
2002
+ number: index + 1
2003
+ },
2004
+ labels,
2005
+ state
2006
+ }, draft.id)), isAdding && /* @__PURE__ */ jsxs("div", {
2007
+ "aria-label": labels.capturing,
2008
+ className: "attachment-skeleton glass",
2009
+ role: "status",
2010
+ children: [/* @__PURE__ */ jsx(CameraIcon, {}), /* @__PURE__ */ jsx("span", { children: labels.capturing })]
2011
+ })]
1321
2012
  })
1322
- }),
1323
- /* @__PURE__ */ jsxs("div", {
1324
- className: "meta",
1325
- children: [/* @__PURE__ */ jsx("strong", {
1326
- className: "truncate",
1327
- children: component ? `<${component}>` : selection.label
1328
- }), /* @__PURE__ */ jsx("span", {
1329
- className: "truncate",
1330
- children: selection.region ?? selection.sourceLocation ?? selection.selector
1331
- })]
1332
- }),
1333
- /* @__PURE__ */ jsxs("div", {
1334
- className: "actions",
1335
- children: [/* @__PURE__ */ jsx("button", {
2013
+ }), /* @__PURE__ */ jsx(ScrollArea.Scrollbar, {
2014
+ className: "screenshot-scrollbar",
2015
+ orientation: "horizontal",
2016
+ children: /* @__PURE__ */ jsx(ScrollArea.Thumb, { className: "screenshot-scroll-thumb" })
2017
+ })]
2018
+ })]
2019
+ });
2020
+ }
2021
+ //#endregion
2022
+ //#region src/feedback/ui/composer/options.tsx
2023
+ function ComposerOptions({ labels, state }) {
2024
+ const popover = useDetailsPopover();
2025
+ const [emailInvalid, setEmailInvalid] = useState(false);
2026
+ return /* @__PURE__ */ jsxs("details", {
2027
+ className: "composer-options",
2028
+ ...popover.detailsProps,
2029
+ children: [/* @__PURE__ */ jsx("summary", {
2030
+ "aria-label": labels.moreOptions,
2031
+ className: "icon-btn",
2032
+ ...popover.summaryProps,
2033
+ title: labels.moreOptions,
2034
+ children: /* @__PURE__ */ jsx(MoreIcon, {})
2035
+ }), /* @__PURE__ */ jsxs("div", {
2036
+ className: "options-popover glass",
2037
+ children: [
2038
+ state.isAnonymous && /* @__PURE__ */ jsxs("label", {
2039
+ className: "email-field",
2040
+ children: [
2041
+ labels.emailLabel,
2042
+ /* @__PURE__ */ jsx("input", {
2043
+ "aria-invalid": emailInvalid,
2044
+ "aria-label": labels.emailLabel,
2045
+ autoComplete: "email",
2046
+ disabled: state.isEditingDisabled,
2047
+ onChange: (event) => {
2048
+ setEmailInvalid(false);
2049
+ state.setEmail(event.target.value);
2050
+ },
2051
+ onInvalid: (event) => {
2052
+ event.preventDefault();
2053
+ setEmailInvalid(true);
2054
+ popover.show();
2055
+ const input = event.currentTarget;
2056
+ requestAnimationFrame(() => input.focus());
2057
+ },
2058
+ placeholder: labels.emailPlaceholder,
2059
+ type: "email",
2060
+ value: state.email
2061
+ }),
2062
+ emailInvalid && /* @__PURE__ */ jsx("span", {
2063
+ role: "alert",
2064
+ children: labels.emailInvalid
2065
+ })
2066
+ ]
2067
+ }),
2068
+ /* @__PURE__ */ jsxs("button", {
2069
+ className: "ghost-btn",
2070
+ disabled: state.isSubmitting,
2071
+ onClick: state.close,
2072
+ type: "button",
2073
+ children: [/* @__PURE__ */ jsx(CloseIcon, {}), labels.cancel]
2074
+ }),
2075
+ state.canDismiss && /* @__PURE__ */ jsx("button", {
2076
+ className: "dismiss-btn",
2077
+ disabled: state.isSubmitting,
2078
+ onClick: state.dismiss,
2079
+ type: "button",
2080
+ children: labels.dismissForDays.replace("{days}", String(state.dismissForDays))
2081
+ }),
2082
+ /* @__PURE__ */ jsxs("a", {
2083
+ className: "version-info",
2084
+ href: "https://reflet.app",
2085
+ rel: "noopener noreferrer",
2086
+ target: "_blank",
2087
+ children: ["Reflet SDK ", SDK_VERSION]
2088
+ })
2089
+ ]
2090
+ })]
2091
+ });
2092
+ }
2093
+ //#endregion
2094
+ //#region src/feedback/ui/composer/controls.tsx
2095
+ const CATEGORY_LABELS = {
2096
+ bug: "categoryBug",
2097
+ idea: "categoryIdea",
2098
+ question: "categoryQuestion"
2099
+ };
2100
+ function ComposerControls({ options, labels, state }) {
2101
+ return /* @__PURE__ */ jsxs("div", {
2102
+ className: "composer-toolbar",
2103
+ children: [options.floatingControls, /* @__PURE__ */ jsxs("div", {
2104
+ className: "composer-controls glass",
2105
+ children: [
2106
+ options.categories.length > 1 && /* @__PURE__ */ jsxs("span", {
2107
+ className: "select-control",
2108
+ children: [/* @__PURE__ */ jsx("select", {
2109
+ "aria-label": labels.categoryLabel,
2110
+ className: "category-select",
2111
+ disabled: state.isEditingDisabled,
2112
+ onChange: (event) => {
2113
+ const category = options.categories.find((value) => value === event.target.value);
2114
+ if (category) state.setCategory(category);
2115
+ },
2116
+ value: state.category,
2117
+ children: options.categories.map((value) => /* @__PURE__ */ jsx("option", {
2118
+ value,
2119
+ children: labels[CATEGORY_LABELS[value]]
2120
+ }, value))
2121
+ }), /* @__PURE__ */ jsx(ChevronDownIcon, {})]
2122
+ }),
2123
+ /* @__PURE__ */ jsx("button", {
2124
+ "aria-label": labels.attachScreenshot,
2125
+ className: "icon-btn",
2126
+ disabled: state.isCapturing || state.isEditingDisabled,
2127
+ onClick: state.takeCapture,
2128
+ title: labels.attachScreenshot,
2129
+ type: "button",
2130
+ children: /* @__PURE__ */ jsx(CameraIcon, {})
2131
+ }),
2132
+ /* @__PURE__ */ jsx("button", {
1336
2133
  "aria-label": labels.pickElement,
1337
2134
  className: "icon-btn",
2135
+ disabled: state.isEditingDisabled || state.isCapturing,
1338
2136
  onClick: () => state.setStep("picking"),
1339
2137
  title: labels.pickElement,
1340
2138
  type: "button",
1341
2139
  children: /* @__PURE__ */ jsx(TargetIcon, {})
1342
- }), /* @__PURE__ */ jsx("button", {
1343
- "aria-label": labels.cancel,
1344
- className: "icon-btn",
1345
- onClick: state.clearSelection,
1346
- title: labels.cancel,
1347
- type: "button",
1348
- children: /* @__PURE__ */ jsx(CloseIcon, {})
1349
- })]
1350
- })
1351
- ]
2140
+ }),
2141
+ /* @__PURE__ */ jsx(ComposerOptions, {
2142
+ labels,
2143
+ state
2144
+ })
2145
+ ]
2146
+ })]
2147
+ });
2148
+ }
2149
+ //#endregion
2150
+ //#region src/feedback/ui/composer/message-input.tsx
2151
+ function MessageInput({ labels, state }) {
2152
+ const [expanded, setExpanded] = useState(false);
2153
+ const submitLabel = state.hasPendingAttachments ? labels.retryAttachments : labels.submit;
2154
+ return /* @__PURE__ */ jsxs("div", {
2155
+ className: "message-row glass",
2156
+ "data-expanded": expanded,
2157
+ children: [/* @__PURE__ */ jsx("textarea", {
2158
+ "aria-label": labels.descriptionPlaceholder,
2159
+ disabled: state.isEditingDisabled,
2160
+ maxLength: 4e3,
2161
+ onChange: (event) => state.setMessage(event.target.value),
2162
+ onFocus: () => setExpanded(true),
2163
+ placeholder: labels.descriptionPlaceholder,
2164
+ rows: expanded ? 3 : 1,
2165
+ value: state.message
2166
+ }), /* @__PURE__ */ jsx("button", {
2167
+ "aria-busy": state.isSubmitting,
2168
+ "aria-label": submitLabel,
2169
+ className: "submit",
2170
+ disabled: state.isSubmitting || state.isCapturing || !state.message.trim(),
2171
+ title: submitLabel,
2172
+ type: "submit",
2173
+ children: state.isSubmitting ? /* @__PURE__ */ jsx("span", { className: "spinner" }) : /* @__PURE__ */ jsx(ArrowIcon, {})
2174
+ })]
1352
2175
  });
1353
2176
  }
1354
- function FeedbackPanel({ categories, labels, state }) {
1355
- const messageRef = useRef(null);
2177
+ //#endregion
2178
+ //#region src/feedback/ui/panel.tsx
2179
+ function FeedbackPanel({ options, labels, state }) {
2180
+ const panelRef = useRef(null);
1356
2181
  useEffect(() => {
1357
- messageRef.current?.focus();
2182
+ panelRef.current?.focus();
1358
2183
  }, []);
1359
- if (state.step === "success") return /* @__PURE__ */ jsx("section", {
2184
+ if (state.step === "success") return /* @__PURE__ */ jsxs("section", {
1360
2185
  "aria-live": "polite",
1361
2186
  className: "panel",
1362
- children: /* @__PURE__ */ jsxs("div", {
1363
- className: "done",
2187
+ children: [/* @__PURE__ */ jsxs("div", {
2188
+ className: "done success-panel glass",
1364
2189
  children: [
1365
2190
  /* @__PURE__ */ jsx(CheckIcon, {}),
1366
2191
  /* @__PURE__ */ jsx("h2", { children: labels.successTitle }),
1367
2192
  /* @__PURE__ */ jsx("p", { children: labels.successMessage })
1368
2193
  ]
1369
- })
2194
+ }), /* @__PURE__ */ jsx("div", {
2195
+ className: "composer-toolbar",
2196
+ children: options.floatingControls
2197
+ })]
1370
2198
  });
1371
2199
  return /* @__PURE__ */ jsxs("section", {
1372
2200
  "aria-label": labels.title,
1373
2201
  className: "panel",
1374
- children: [
1375
- /* @__PURE__ */ jsxs("header", {
1376
- className: "head",
1377
- children: [
1378
- /* @__PURE__ */ jsx("h2", { children: labels.title }),
1379
- /* @__PURE__ */ jsx("span", { className: "spacer" }),
1380
- /* @__PURE__ */ jsx("button", {
1381
- "aria-label": labels.cancel,
2202
+ ref: panelRef,
2203
+ tabIndex: -1,
2204
+ children: [/* @__PURE__ */ jsx(Attachments, {
2205
+ labels,
2206
+ state
2207
+ }), /* @__PURE__ */ jsxs("form", {
2208
+ className: "composer",
2209
+ onSubmit: (event) => {
2210
+ event.preventDefault();
2211
+ state.submit();
2212
+ },
2213
+ children: [
2214
+ /* @__PURE__ */ jsx(MessageInput, {
2215
+ labels,
2216
+ state
2217
+ }),
2218
+ /* @__PURE__ */ jsx(ComposerControls, {
2219
+ labels,
2220
+ options,
2221
+ state
2222
+ }),
2223
+ /* @__PURE__ */ jsx("div", {
2224
+ "aria-hidden": "true",
2225
+ className: "hp",
2226
+ children: /* @__PURE__ */ jsx("input", {
2227
+ autoComplete: "off",
2228
+ name: "company-website",
2229
+ onChange: (event) => state.setHoneypot(event.target.value),
2230
+ tabIndex: -1,
2231
+ value: state.honeypot
2232
+ })
2233
+ }),
2234
+ state.error && /* @__PURE__ */ jsx("p", {
2235
+ className: "error glass",
2236
+ role: "alert",
2237
+ children: state.error
2238
+ })
2239
+ ]
2240
+ })]
2241
+ });
2242
+ }
2243
+ //#endregion
2244
+ //#region src/feedback/ui/floating/use-floating-position.ts
2245
+ const EDGE_GAP = 12;
2246
+ const KEYBOARD_STEP = 24;
2247
+ const KEY_DIRECTIONS = {
2248
+ ArrowDown: {
2249
+ x: 0,
2250
+ y: KEYBOARD_STEP
2251
+ },
2252
+ ArrowLeft: {
2253
+ x: -24,
2254
+ y: 0
2255
+ },
2256
+ ArrowRight: {
2257
+ x: KEYBOARD_STEP,
2258
+ y: 0
2259
+ },
2260
+ ArrowUp: {
2261
+ x: 0,
2262
+ y: -24
2263
+ }
2264
+ };
2265
+ function visibleViewport() {
2266
+ const viewport = window.visualViewport;
2267
+ const left = viewport?.offsetLeft ?? 0;
2268
+ const top = viewport?.offsetTop ?? 0;
2269
+ const width = viewport?.width ?? window.innerWidth;
2270
+ const height = viewport?.height ?? window.innerHeight;
2271
+ return {
2272
+ bottom: window.innerHeight - top - height,
2273
+ height,
2274
+ left,
2275
+ right: window.innerWidth - left - width,
2276
+ top,
2277
+ width
2278
+ };
2279
+ }
2280
+ function clampPosition(point, bounds, viewport = visibleViewport()) {
2281
+ return {
2282
+ x: Math.max(viewport.left + EDGE_GAP, Math.min(point.x, viewport.left + viewport.width - bounds.width - EDGE_GAP)),
2283
+ y: Math.max(viewport.top + EDGE_GAP, Math.min(point.y, viewport.top + viewport.height - bounds.height - EDGE_GAP))
2284
+ };
2285
+ }
2286
+ function useFloatingPosition(corner = "bottom-right") {
2287
+ const rootRef = useRef(null);
2288
+ const gesture = useRef(null);
2289
+ const [position, setPosition] = useState(null);
2290
+ const [viewport, setViewport] = useState(visibleViewport);
2291
+ const horizontalEdge = corner.endsWith("right") ? "right" : "left";
2292
+ const verticalEdge = corner.startsWith("bottom") ? "bottom" : "top";
2293
+ const moveTo = useCallback((point, bounds) => {
2294
+ const fitted = clampPosition(point, bounds);
2295
+ const visible = visibleViewport();
2296
+ const anchor = {
2297
+ x: horizontalEdge === "right" ? visible.left + visible.width - fitted.x - bounds.width : fitted.x - visible.left,
2298
+ y: verticalEdge === "bottom" ? visible.top + visible.height - fitted.y - bounds.height : fitted.y - visible.top
2299
+ };
2300
+ setPosition((current) => current?.x === anchor.x && current.y === anchor.y ? current : anchor);
2301
+ }, [horizontalEdge, verticalEdge]);
2302
+ const keepInViewport = useCallback(() => {
2303
+ const bounds = rootRef.current?.getBoundingClientRect();
2304
+ if (!bounds) return;
2305
+ const fitted = clampPosition(bounds, bounds, viewport);
2306
+ if (Math.abs(fitted.x - bounds.x) > .5 || Math.abs(fitted.y - bounds.y) > .5) moveTo(fitted, bounds);
2307
+ }, [moveTo, viewport]);
2308
+ useLayoutEffect(() => {
2309
+ keepInViewport();
2310
+ }, [keepInViewport]);
2311
+ useEffect(() => {
2312
+ const resizeViewport = () => setViewport(visibleViewport());
2313
+ const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(keepInViewport);
2314
+ if (rootRef.current) observer?.observe(rootRef.current);
2315
+ window.addEventListener("resize", resizeViewport);
2316
+ window.visualViewport?.addEventListener("resize", resizeViewport);
2317
+ window.visualViewport?.addEventListener("scroll", resizeViewport);
2318
+ return () => {
2319
+ observer?.disconnect();
2320
+ window.removeEventListener("resize", resizeViewport);
2321
+ window.visualViewport?.removeEventListener("resize", resizeViewport);
2322
+ window.visualViewport?.removeEventListener("scroll", resizeViewport);
2323
+ };
2324
+ }, [keepInViewport]);
2325
+ const onPointerDown = (event) => {
2326
+ const bounds = rootRef.current?.getBoundingClientRect();
2327
+ if (!bounds || event.button !== 0) return;
2328
+ event.currentTarget.setPointerCapture(event.pointerId);
2329
+ gesture.current = {
2330
+ x: event.clientX - bounds.x,
2331
+ y: event.clientY - bounds.y
2332
+ };
2333
+ };
2334
+ const onPointerMove = (event) => {
2335
+ const bounds = rootRef.current?.getBoundingClientRect();
2336
+ if (bounds && gesture.current) moveTo({
2337
+ x: event.clientX - gesture.current.x,
2338
+ y: event.clientY - gesture.current.y
2339
+ }, bounds);
2340
+ };
2341
+ const onKeyDown = (event) => {
2342
+ const direction = KEY_DIRECTIONS[event.key];
2343
+ const bounds = rootRef.current?.getBoundingClientRect();
2344
+ if (!(bounds && direction)) return;
2345
+ event.preventDefault();
2346
+ moveTo({
2347
+ x: bounds.x + direction.x,
2348
+ y: bounds.y + direction.y
2349
+ }, bounds);
2350
+ };
2351
+ 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; }`;
2352
+ 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; }` : "";
2353
+ return {
2354
+ handleProps: {
2355
+ onKeyDown,
2356
+ onPointerCancel: () => {
2357
+ gesture.current = null;
2358
+ },
2359
+ onPointerDown,
2360
+ onPointerMove,
2361
+ onPointerUp: () => {
2362
+ gesture.current = null;
2363
+ }
2364
+ },
2365
+ position,
2366
+ rootRef,
2367
+ styles: viewportStyles + anchorStyles
2368
+ };
2369
+ }
2370
+ //#endregion
2371
+ //#region src/feedback/ui/floating/floating-widget.tsx
2372
+ function FloatingWidget({ labels, options, state }) {
2373
+ const [minimized, setMinimized] = useState(false);
2374
+ const launcherRef = useRef(null);
2375
+ const floating = useFloatingPosition(options.position);
2376
+ const showPanel = state.isOpen && !minimized;
2377
+ const minimize = () => {
2378
+ setMinimized(true);
2379
+ requestAnimationFrame(() => launcherRef.current?.focus());
2380
+ };
2381
+ useEffect(() => {
2382
+ if (state.step === "compose") state.annotationTrigger?.focus({ preventScroll: true });
2383
+ }, [state.step, state.annotationTrigger]);
2384
+ useEffect(() => {
2385
+ if (!showPanel || state.step !== "compose") return;
2386
+ const onKeyDown = (event) => {
2387
+ if (event.key === "Escape" && !event.defaultPrevented) {
2388
+ setMinimized(true);
2389
+ requestAnimationFrame(() => launcherRef.current?.focus());
2390
+ }
2391
+ };
2392
+ document.addEventListener("keydown", onKeyDown);
2393
+ return () => document.removeEventListener("keydown", onKeyDown);
2394
+ }, [showPanel, state.step]);
2395
+ return /* @__PURE__ */ jsxs("div", {
2396
+ className: "root",
2397
+ "data-editing": state.isOpen && (state.step === "picking" || state.step === "annotate"),
2398
+ "data-moved": Boolean(floating.position),
2399
+ "data-position": options.position,
2400
+ ref: floating.rootRef,
2401
+ children: [/* @__PURE__ */ jsx("style", { children: floating.styles }), showPanel ? /* @__PURE__ */ jsx(FeedbackPanel, {
2402
+ labels,
2403
+ options: {
2404
+ categories: options.categories,
2405
+ floatingControls: /* @__PURE__ */ jsxs("div", {
2406
+ className: "floating-controls glass",
2407
+ children: [/* @__PURE__ */ jsx("button", {
2408
+ "aria-label": labels.moveFeedback,
2409
+ className: "icon-btn drag-handle",
2410
+ title: labels.moveFeedback,
2411
+ type: "button",
2412
+ ...floating.handleProps,
2413
+ children: /* @__PURE__ */ jsx(GripIcon, {})
2414
+ }), /* @__PURE__ */ jsx("button", {
2415
+ "aria-label": labels.minimize,
1382
2416
  className: "icon-btn",
1383
- onClick: state.close,
2417
+ onClick: minimize,
2418
+ title: labels.minimize,
1384
2419
  type: "button",
1385
- children: /* @__PURE__ */ jsx(CloseIcon, {})
1386
- })
1387
- ]
1388
- }),
1389
- /* @__PURE__ */ jsxs("form", {
1390
- className: "body",
1391
- onSubmit: (event) => {
1392
- event.preventDefault();
1393
- state.submit();
1394
- },
1395
- children: [
1396
- categories.length > 1 && /* @__PURE__ */ jsx("fieldset", {
1397
- className: "segmented",
1398
- children: categories.map((value) => {
1399
- const { icon: Icon, labelKey } = CATEGORY_META[value];
1400
- return /* @__PURE__ */ jsxs("button", {
1401
- "aria-pressed": state.category === value,
1402
- onClick: () => state.setCategory(value),
1403
- type: "button",
1404
- children: [/* @__PURE__ */ jsx(Icon, {}), labels[labelKey]]
1405
- }, value);
1406
- })
1407
- }),
1408
- /* @__PURE__ */ jsx("textarea", {
1409
- "aria-label": labels.descriptionPlaceholder,
1410
- maxLength: 4e3,
1411
- onChange: (event) => state.setMessage(event.target.value),
1412
- placeholder: labels.descriptionPlaceholder,
1413
- ref: messageRef,
1414
- value: state.message
1415
- }),
1416
- /* @__PURE__ */ jsxs("div", {
1417
- className: "attachments",
1418
- children: [/* @__PURE__ */ jsx(Attachment, {
1419
- labels,
1420
- state
1421
- }), /* @__PURE__ */ jsx(Selection, {
1422
- labels,
1423
- state
1424
- })]
1425
- }),
1426
- state.isAnonymous && /* @__PURE__ */ jsx("input", {
1427
- "aria-label": labels.emailLabel,
1428
- autoComplete: "email",
1429
- onChange: (event) => state.setEmail(event.target.value),
1430
- placeholder: labels.emailPlaceholder,
1431
- type: "email",
1432
- value: state.email
1433
- }),
1434
- /* @__PURE__ */ jsx("div", {
1435
- "aria-hidden": "true",
1436
- className: "hp",
1437
- children: /* @__PURE__ */ jsx("input", {
1438
- autoComplete: "off",
1439
- name: "company-website",
1440
- onChange: (event) => state.setHoneypot(event.target.value),
1441
- tabIndex: -1,
1442
- value: state.honeypot
1443
- })
1444
- }),
1445
- state.error && /* @__PURE__ */ jsx("p", {
1446
- className: "error",
1447
- children: state.error
1448
- }),
1449
- /* @__PURE__ */ jsx("button", {
1450
- className: "submit",
1451
- disabled: state.isSubmitting || !state.message.trim(),
1452
- type: "submit",
1453
- children: state.isSubmitting ? /* @__PURE__ */ jsx("span", { className: "spinner" }) : labels.submit
1454
- })
1455
- ]
1456
- }),
1457
- /* @__PURE__ */ jsxs("footer", {
1458
- className: "footer",
1459
- "data-dismissible": state.canDismiss,
1460
- children: [state.canDismiss && /* @__PURE__ */ jsx("button", {
1461
- className: "dismiss-btn",
1462
- onClick: state.dismiss,
1463
- type: "button",
1464
- children: labels.dismissForDays.replace("{days}", String(state.dismissForDays))
1465
- }), /* @__PURE__ */ jsxs("span", { children: [
1466
- "Powered by",
1467
- " ",
1468
- /* @__PURE__ */ jsx("a", {
1469
- href: "https://reflet.app",
1470
- rel: "noopener noreferrer",
1471
- target: "_blank",
1472
- children: "Reflet"
1473
- })
1474
- ] })]
1475
- })
1476
- ]
2420
+ children: /* @__PURE__ */ jsx(MinusIcon, {})
2421
+ })]
2422
+ })
2423
+ },
2424
+ state
2425
+ }) : /* @__PURE__ */ jsx(Launcher, {
2426
+ buttonRef: launcherRef,
2427
+ isOpen: false,
2428
+ label: state.isOpen ? labels.resume : labels.trigger,
2429
+ onClick: () => {
2430
+ setMinimized(false);
2431
+ if (!state.isOpen) state.open();
2432
+ }
2433
+ })]
1477
2434
  });
1478
2435
  }
1479
2436
  //#endregion
@@ -1580,29 +2537,30 @@ function ElementPicker({ hint, onCancel, onPick }) {
1580
2537
  }
1581
2538
  //#endregion
1582
2539
  //#region src/feedback/ui/styles.ts
1583
- /**
1584
- * Styles for the feedback widget. Injected into a shadow root, so nothing here
1585
- * leaks into the host app and nothing from the host app leaks in.
1586
- */
1587
2540
  const WIDGET_STYLES = `
1588
2541
  :host {
2542
+ --rf-offset: 20px;
1589
2543
  --rf-bg: #ffffff;
1590
2544
  --rf-bg-subtle: rgb(55 53 47 / 4%);
1591
2545
  --rf-bg-hover: rgb(55 53 47 / 7%);
1592
2546
  --rf-border: rgb(55 53 47 / 9%);
1593
2547
  --rf-text: #37352f;
1594
- --rf-text-muted: rgb(55 53 47 / 55%);
2548
+ --rf-text-muted: rgb(55 53 47 / 68%);
1595
2549
  --rf-primary: #37352f;
1596
2550
  --rf-primary-text: #ffffff;
1597
2551
  --rf-accent: #2383e2;
1598
2552
  --rf-danger: #d44c47;
1599
2553
  --rf-success: #448361;
1600
- --rf-radius: 12px;
2554
+ --rf-radius: 20px;
2555
+ --rf-pill: 999px;
2556
+ --rf-width: 360px;
2557
+ --rf-glass: rgb(255 255 255 / 72%);
2558
+ --rf-glass-highlight: rgb(255 255 255 / 90%);
2559
+ --rf-glass-stroke: rgb(255 255 255 / 70%);
1601
2560
  --rf-radius-sm: 8px;
1602
2561
  --rf-shadow-inset: inset 0 0 0 1px var(--rf-border);
1603
2562
  --rf-shadow-low: 0 0 0 1px rgb(15 15 15 / 4%), 0 1px 2px rgb(15 15 15 / 6%), 0 4px 10px -4px rgb(15 15 15 / 10%);
1604
2563
  --rf-shadow: 0 0 0 1px rgb(15 15 15 / 4%), 0 2px 5px -1px rgb(15 15 15 / 7%), 0 12px 32px -8px rgb(15 15 15 / 16%);
1605
- --rf-ring: 0 0 0 3px color-mix(in srgb, var(--rf-accent) 20%, transparent);
1606
2564
  --rf-ease: cubic-bezier(0.16, 1, 0.3, 1);
1607
2565
  color-scheme: light;
1608
2566
  all: initial;
@@ -1611,347 +2569,258 @@ const WIDGET_STYLES = `
1611
2569
 
1612
2570
  :host([data-theme="dark"]) {
1613
2571
  --rf-bg: #202020;
1614
- --rf-bg-subtle: rgb(255 255 255 / 5%);
1615
- --rf-bg-hover: rgb(255 255 255 / 9%);
1616
- --rf-border: rgb(255 255 255 / 9%);
1617
- --rf-text: #ebeae8;
1618
- --rf-text-muted: rgb(255 255 255 / 46%);
1619
- --rf-primary: #f1efec;
1620
- --rf-primary-text: #1c1c1b;
1621
- --rf-accent: #529cca;
1622
- --rf-danger: #eb5757;
1623
- --rf-success: #4dab74;
1624
- --rf-shadow-low: 0 0 0 1px rgb(255 255 255 / 6%), 0 1px 2px rgb(0 0 0 / 30%), 0 4px 10px -4px rgb(0 0 0 / 40%);
1625
- --rf-shadow: 0 0 0 1px rgb(255 255 255 / 7%), 0 2px 5px -1px rgb(0 0 0 / 35%), 0 14px 36px -10px rgb(0 0 0 / 55%);
1626
- color-scheme: dark;
1627
- }
1628
-
1629
- *, *::before, *::after { box-sizing: border-box; }
1630
-
1631
- .root {
1632
- position: fixed;
1633
- z-index: 2147483000;
1634
- display: flex;
1635
- flex-direction: column;
1636
- gap: 10px;
1637
- font-family: inherit;
1638
- font-size: 13.5px;
1639
- line-height: 1.45;
1640
- color: var(--rf-text);
1641
- }
1642
-
1643
- .root[data-position$="right"] { right: var(--rf-offset); align-items: flex-end; }
1644
- .root[data-position$="left"] { left: var(--rf-offset); align-items: flex-start; }
1645
- .root[data-position^="bottom"] { bottom: var(--rf-offset); }
1646
- .root[data-position^="top"] { top: var(--rf-offset); flex-direction: column-reverse; }
1647
-
1648
- button {
1649
- font: inherit;
1650
- color: inherit;
1651
- margin: 0;
1652
- cursor: pointer;
1653
- border: 0;
1654
- background: none;
1655
- }
1656
-
1657
- :focus-visible { outline: none; box-shadow: var(--rf-ring); }
1658
-
1659
- .launcher {
1660
- display: inline-flex;
1661
- align-items: center;
1662
- gap: 6px;
1663
- height: 32px;
1664
- padding: 0 11px;
1665
- border-radius: var(--rf-radius-sm);
1666
- background: var(--rf-primary);
1667
- color: var(--rf-primary-text);
1668
- font-size: 13px;
1669
- font-weight: 500;
1670
- letter-spacing: -0.005em;
1671
- box-shadow: var(--rf-shadow-low);
1672
- transition: transform 240ms var(--rf-ease), box-shadow 240ms var(--rf-ease), opacity 160ms var(--rf-ease);
1673
- }
1674
- .launcher:hover { transform: translateY(-1px); box-shadow: var(--rf-shadow); }
1675
- .launcher:active { transform: translateY(0) scale(0.98); }
1676
-
1677
- .launcher-icon { position: relative; width: 15px; height: 15px; flex: none; }
1678
- .launcher-icon svg { position: absolute; inset: 0; width: 100%; height: 100%; transition: opacity 200ms var(--rf-ease), transform 320ms var(--rf-ease); }
1679
- .launcher-icon .on-close { opacity: 0; transform: rotate(-90deg) scale(0.6); }
1680
- .launcher[data-open="true"] .on-open { opacity: 0; transform: rotate(90deg) scale(0.6); }
1681
- .launcher[data-open="true"] .on-close { opacity: 1; transform: none; }
1682
-
1683
- .panel {
1684
- width: min(352px, calc(100vw - 24px));
1685
- max-height: min(600px, calc(100vh - 96px));
1686
- display: flex;
1687
- flex-direction: column;
1688
- overflow: hidden;
1689
- background: var(--rf-bg);
1690
- border-radius: var(--rf-radius);
1691
- box-shadow: var(--rf-shadow);
1692
- animation: rf-in 220ms var(--rf-ease);
1693
- }
1694
- .root[data-position^="bottom"] .panel { transform-origin: bottom center; }
1695
- .root[data-position^="top"] .panel { transform-origin: top center; animation-name: rf-in-top; }
1696
-
1697
- @keyframes rf-in {
1698
- from { opacity: 0; transform: translateY(6px) scale(0.985); }
1699
- }
1700
- @keyframes rf-in-top {
1701
- from { opacity: 0; transform: translateY(-6px) scale(0.985); }
1702
- }
1703
- @keyframes rf-fade { from { opacity: 0; } }
1704
-
1705
- @media (prefers-reduced-motion: reduce) {
1706
- *, *::before, *::after { animation: none !important; transition: none !important; }
1707
- }
1708
-
1709
- .head {
1710
- display: flex;
1711
- align-items: center;
1712
- gap: 8px;
1713
- padding: 12px 10px 4px 14px;
1714
- }
1715
- .head h2 { margin: 0; font-size: 13.5px; font-weight: 600; letter-spacing: -0.01em; }
1716
- .head .spacer { flex: 1; }
1717
-
1718
- .icon-btn {
1719
- display: inline-flex;
1720
- align-items: center;
1721
- justify-content: center;
1722
- width: 26px;
1723
- height: 26px;
1724
- flex: none;
1725
- border-radius: 6px;
1726
- color: var(--rf-text-muted);
1727
- transition: background 120ms var(--rf-ease), color 120ms var(--rf-ease);
1728
- }
1729
- .icon-btn:hover { background: var(--rf-bg-hover); color: var(--rf-text); }
1730
-
1731
- .body { display: flex; flex-direction: column; gap: 8px; padding: 6px 14px 14px; overflow-y: auto; }
1732
-
1733
- .segmented {
1734
- border: 0;
1735
- margin: 0;
1736
- min-inline-size: 0;
1737
- display: grid;
1738
- grid-auto-flow: column;
1739
- grid-auto-columns: 1fr;
1740
- gap: 2px;
1741
- padding: 2px;
1742
- background: var(--rf-bg-subtle);
1743
- border-radius: var(--rf-radius-sm);
1744
- }
1745
- .segmented button {
1746
- display: flex;
1747
- align-items: center;
1748
- justify-content: center;
1749
- gap: 5px;
1750
- height: 26px;
1751
- border-radius: 6px;
1752
- font-size: 12.5px;
1753
- font-weight: 500;
1754
- color: var(--rf-text-muted);
1755
- transition: background 160ms var(--rf-ease), color 160ms var(--rf-ease);
1756
- }
1757
- .segmented button:hover { color: var(--rf-text); }
1758
- .segmented button[aria-pressed="true"] {
1759
- background: var(--rf-bg);
1760
- color: var(--rf-text);
1761
- box-shadow: var(--rf-shadow-low);
1762
- }
1763
-
1764
- textarea, input[type="email"] {
1765
- width: 100%;
1766
- padding: 8px 10px;
1767
- font: inherit;
1768
- color: var(--rf-text);
1769
- background: var(--rf-bg);
1770
- border: 1px solid var(--rf-border);
1771
- border-radius: var(--rf-radius-sm);
1772
- transition: border-color 160ms var(--rf-ease), box-shadow 160ms var(--rf-ease);
1773
- }
1774
- textarea { min-height: 84px; max-height: 220px; resize: none; }
1775
- textarea::placeholder, input::placeholder { color: var(--rf-text-muted); }
1776
- textarea:focus, input:focus { outline: none; border-color: color-mix(in srgb, var(--rf-accent) 55%, transparent); box-shadow: var(--rf-ring); }
1777
-
1778
- .attachments { display: flex; flex-wrap: wrap; gap: 6px; }
1779
-
1780
- .card {
1781
- flex: 1 0 100%;
1782
- display: flex;
1783
- align-items: center;
1784
- gap: 10px;
1785
- min-width: 0;
1786
- padding: 6px;
1787
- border-radius: 10px;
1788
- background: var(--rf-bg-subtle);
1789
- animation: rf-fade 200ms var(--rf-ease);
1790
- }
1791
- .thumb {
1792
- position: relative;
1793
- flex: 0 0 60px;
1794
- height: 42px;
1795
- overflow: hidden;
1796
- border-radius: 6px;
1797
- background: var(--rf-bg);
1798
- box-shadow: var(--rf-shadow-inset);
1799
- }
1800
- .thumb img { width: 100%; height: 100%; object-fit: cover; object-position: top center; display: block; }
1801
- .thumb.contain img { object-fit: contain; object-position: center; }
1802
- .card .meta { display: flex; flex-direction: column; justify-content: center; gap: 1px; min-width: 0; }
1803
- .card .meta strong { font-size: 12.5px; font-weight: 550; }
1804
- .card .meta span { font-size: 11.5px; color: var(--rf-text-muted); }
1805
- .card .actions { display: flex; gap: 2px; margin-left: auto; align-items: center; }
1806
- .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1807
- .card code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; }
1808
-
1809
- .ghost-btn {
1810
- flex: 0 0 auto;
1811
- display: inline-flex;
1812
- align-items: center;
1813
- gap: 6px;
1814
- height: 30px;
1815
- padding: 0 10px;
1816
- font-size: 12.5px;
1817
- font-weight: 500;
1818
- color: var(--rf-text-muted);
1819
- border-radius: var(--rf-radius-sm);
1820
- box-shadow: var(--rf-shadow-inset);
1821
- transition: background 160ms var(--rf-ease), color 160ms var(--rf-ease);
2572
+ --rf-bg-subtle: rgb(255 255 255 / 5%);
2573
+ --rf-bg-hover: rgb(255 255 255 / 9%);
2574
+ --rf-border: rgb(255 255 255 / 9%);
2575
+ --rf-text: #ebeae8;
2576
+ --rf-text-muted: rgb(255 255 255 / 68%);
2577
+ --rf-glass: rgb(32 32 32 / 78%);
2578
+ --rf-glass-highlight: rgb(255 255 255 / 14%);
2579
+ --rf-glass-stroke: rgb(255 255 255 / 12%);
2580
+ --rf-primary: #f1efec;
2581
+ --rf-primary-text: #1c1c1b;
2582
+ --rf-accent: #529cca;
2583
+ --rf-danger: #eb5757;
2584
+ --rf-success: #4dab74;
2585
+ --rf-shadow-low: 0 0 0 1px rgb(255 255 255 / 6%), 0 1px 2px rgb(0 0 0 / 30%), 0 4px 10px -4px rgb(0 0 0 / 40%);
2586
+ --rf-shadow: 0 0 0 1px rgb(255 255 255 / 7%), 0 2px 5px -1px rgb(0 0 0 / 35%), 0 14px 36px -10px rgb(0 0 0 / 55%);
2587
+ color-scheme: dark;
1822
2588
  }
1823
- .ghost-btn:hover { background: var(--rf-bg-subtle); color: var(--rf-text); }
1824
2589
 
1825
- .submit {
1826
- display: inline-flex;
1827
- align-items: center;
1828
- justify-content: center;
1829
- gap: 8px;
1830
- width: 100%;
1831
- height: 32px;
1832
- margin-top: 2px;
1833
- font-size: 13px;
1834
- font-weight: 550;
1835
- color: var(--rf-primary-text);
1836
- background: var(--rf-primary);
1837
- border-radius: var(--rf-radius-sm);
1838
- transition: opacity 160ms var(--rf-ease), transform 160ms var(--rf-ease);
1839
- }
1840
- .submit:hover:not(:disabled) { opacity: 0.88; }
1841
- .submit:active:not(:disabled) { transform: scale(0.99); }
1842
- .submit:disabled { opacity: 0.35; cursor: not-allowed; }
2590
+ *, *::before, *::after { box-sizing: border-box; }
1843
2591
 
1844
- .error { margin: 0; font-size: 12px; color: var(--rf-danger); }
1845
2592
 
1846
- .footer {
1847
- min-height: 34px;
1848
- padding: 0 14px;
1849
- border-top: 1px solid var(--rf-border);
2593
+ .root {
2594
+ position: fixed;
2595
+ z-index: 2147483000;
1850
2596
  display: flex;
1851
- align-items: center;
1852
- justify-content: center;
1853
- gap: 12px;
1854
- font-size: 11px;
1855
- color: var(--rf-text-muted);
2597
+ align-items: flex-end;
2598
+ gap: 8px;
2599
+ font: 13px/1.45 var(--rf-font, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif);
2600
+ color: var(--rf-text);
1856
2601
  }
1857
- .footer[data-dismissible="true"] { justify-content: space-between; }
1858
- .footer a { color: inherit; text-decoration: none; font-weight: 550; transition: color 120ms var(--rf-ease); }
1859
- .footer a:hover { color: var(--rf-text); }
1860
- .dismiss-btn {
1861
- align-self: stretch;
1862
- color: inherit;
1863
- font-size: 11px;
1864
- transition: color 120ms var(--rf-ease);
2602
+ .root:has(.panel) { width: min(var(--rf-width), calc(var(--rf-viewport-width, 100vw) - 24px)); }
2603
+ .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)))); }
2604
+ .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)))); }
2605
+ .root[data-position^="bottom"] { bottom: calc(var(--rf-viewport-bottom, 0px) + max(var(--rf-offset), env(safe-area-inset-bottom))); }
2606
+ .root[data-position^="top"] { top: calc(var(--rf-viewport-top, 0px) + max(var(--rf-offset), env(safe-area-inset-top))); align-items: flex-start; }
2607
+ .root[data-position="bottom-right"] .panel { transform-origin: bottom right; }
2608
+ .root[data-position="bottom-left"] .panel { transform-origin: bottom left; }
2609
+ .root[data-position^="top"] .panel { transform-origin: top center; }
2610
+ .root[data-editing="true"] { visibility: hidden; pointer-events: none; }
2611
+ button, select, input, textarea { font: inherit; color: inherit; }
2612
+ button { margin: 0; cursor: pointer; border: 0; background: none; }
2613
+ button:disabled { cursor: not-allowed; opacity: .4; }
2614
+ svg { flex-shrink: 0; }
2615
+ :focus-visible { outline: 2px solid var(--rf-accent); outline-offset: 3px; }
2616
+ .icon-btn, .tool {
2617
+ display: inline-flex; align-items: center; justify-content: center;
2618
+ width: 32px; height: 32px; flex: none; border-radius: var(--rf-pill); color: var(--rf-text-muted);
2619
+ transition: background 160ms var(--rf-ease), color 160ms var(--rf-ease);
1865
2620
  }
1866
- .dismiss-btn:hover { color: var(--rf-text); }
1867
-
1868
- .done { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 30px 20px 34px; text-align: center; }
2621
+ .icon-btn:hover, .tool:hover { background: var(--rf-bg-hover); color: var(--rf-text); }
2622
+ .launcher {
2623
+ display: inline-flex; align-items: center; gap: 7px; height: 40px; padding: 0 15px;
2624
+ border-radius: var(--rf-pill); color: var(--rf-text);
2625
+ font-weight: 500;
2626
+ transition: transform 200ms var(--rf-ease), box-shadow 200ms var(--rf-ease);
2627
+ }
2628
+ .launcher:hover { transform: translateY(-2px); box-shadow: var(--rf-shadow); }
2629
+ .launcher-icon { display: flex; width: 15px; height: 15px; }
2630
+ .launcher-icon .on-close { display: none; }
2631
+ .floating-controls {
2632
+ display: flex; align-items: center; padding: 3px; flex: none;
2633
+ border-radius: var(--rf-pill);
2634
+ animation: rf-in 220ms var(--rf-ease);
2635
+ }
2636
+ .drag-handle { touch-action: none; cursor: grab; }
2637
+ .drag-handle:active { cursor: grabbing; }
2638
+ .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2639
+ .ghost-btn { display: flex; align-items: center; gap: 8px; min-height: 32px; padding: 0 8px; border-radius: var(--rf-radius-sm); }
2640
+ .ghost-btn:hover { background: var(--rf-bg-hover); }
2641
+ .error { margin: 0; padding: 10px 14px; border-radius: var(--rf-radius); font-size: 12px; color: var(--rf-danger); }
2642
+ .done { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 24px; text-align: center; }
1869
2643
  .done svg { color: var(--rf-success); }
1870
2644
  .done h2 { margin: 0; font-size: 14px; font-weight: 600; }
1871
- .done p { margin: 0; font-size: 12.5px; color: var(--rf-text-muted); }
1872
-
1873
- .spinner {
1874
- width: 14px;
1875
- height: 14px;
1876
- border: 2px solid currentColor;
1877
- border-right-color: transparent;
1878
- border-radius: 50%;
1879
- opacity: 0.6;
1880
- animation: rf-spin 700ms linear infinite;
1881
- }
1882
- @keyframes rf-spin { to { transform: rotate(360deg); } }
1883
-
2645
+ .done p { margin: 0; font-size: 12px; color: var(--rf-text-muted); }
2646
+ .spinner { width: 14px; height: 14px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: rf-spin 700ms linear infinite; }
1884
2647
  .hp { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
1885
-
1886
- .overlay {
1887
- position: fixed;
2648
+ @keyframes rf-spin { to { transform: rotate(360deg); } }
2649
+ @keyframes rf-in { from { opacity: 0; transform: translateY(5px) scale(.98); } }
2650
+ @keyframes rf-fade { from { opacity: 0; } }
2651
+ @media (pointer: coarse) {
2652
+ .icon-btn, .tool { width: 44px; height: 44px; }
2653
+ .launcher, .ghost-btn { min-height: 44px; }
2654
+ }
2655
+ .glass {
2656
+ position: relative;
2657
+ isolation: isolate;
2658
+ border: 1px solid var(--rf-glass-stroke);
2659
+ box-shadow: inset 0 1px 0 var(--rf-glass-highlight), var(--rf-shadow);
2660
+ }
2661
+ .glass::before {
2662
+ content: "";
2663
+ position: absolute;
1888
2664
  inset: 0;
1889
- z-index: 2147483001;
1890
- display: flex;
1891
- flex-direction: column;
1892
- background: rgb(15 15 15 / 76%);
1893
- backdrop-filter: blur(3px);
1894
- animation: rf-fade 180ms var(--rf-ease);
2665
+ z-index: -1;
2666
+ border-radius: inherit;
2667
+ background: var(--rf-bg);
2668
+ pointer-events: none;
1895
2669
  }
1896
-
1897
- .editor { flex: 1; display: flex; align-items: center; justify-content: center; padding: 24px 24px 0; min-height: 0; }
1898
- .editor canvas {
1899
- max-width: 100%;
1900
- max-height: 100%;
1901
- border-radius: 10px;
1902
- box-shadow: 0 20px 60px -16px rgb(0 0 0 / 70%);
1903
- cursor: crosshair;
1904
- touch-action: none;
2670
+ @supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) {
2671
+ .glass::before {
2672
+ background: var(--rf-glass);
2673
+ -webkit-backdrop-filter: blur(20px) saturate(1.6);
2674
+ backdrop-filter: blur(20px) saturate(1.6);
2675
+ }
1905
2676
  }
1906
-
1907
- .toolbar {
1908
- display: flex;
1909
- align-items: center;
1910
- justify-content: center;
1911
- gap: 6px;
1912
- flex-wrap: wrap;
1913
- padding: 14px 16px calc(14px + env(safe-area-inset-bottom));
2677
+ @media (prefers-reduced-transparency: reduce), (prefers-contrast: more) {
2678
+ .glass::before { background: var(--rf-bg); backdrop-filter: none; -webkit-backdrop-filter: none; }
1914
2679
  }
1915
- .toolbar .group {
1916
- display: flex;
1917
- align-items: center;
1918
- gap: 2px;
1919
- padding: 4px;
1920
- background: var(--rf-bg);
1921
- border-radius: 10px;
1922
- box-shadow: var(--rf-shadow);
2680
+ .capture-halo { position: fixed; inset: 0; z-index: 2147482999; pointer-events: none; opacity: 0; transition: opacity 500ms var(--rf-ease); }
2681
+ .capture-halo[data-active="true"] { opacity: 1; transition-duration: 140ms; }
2682
+ .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); }
2683
+ .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)); }
2684
+
2685
+ .panel { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 8px; outline: none; animation: rf-in 240ms var(--rf-ease); }
2686
+ .success-panel { border-radius: var(--rf-radius); }
2687
+ .composer { position: relative; display: flex; flex-direction: column; gap: 8px; }
2688
+ .message-row { display: flex; align-items: flex-end; gap: 4px; padding: 4px; border-radius: 26px; }
2689
+ .message-row textarea {
2690
+ flex: 1; width: 0; min-height: 36px; height: 36px; max-height: min(140px, 30dvh);
2691
+ margin: 0; padding: 8px 10px 8px 12px; border: 0; border-radius: 20px; resize: none;
2692
+ background: transparent; line-height: 20px; transition: height 220ms var(--rf-ease);
2693
+ }
2694
+ .panel:focus-within .message-row[data-expanded="true"] textarea { height: 76px; }
2695
+ .message-row textarea::placeholder { color: var(--rf-text-muted); }
2696
+ .message-row textarea:focus { outline: none; }
2697
+ .message-row:has(textarea:focus-visible) { border-color: color-mix(in srgb, var(--rf-accent) 40%, var(--rf-glass-stroke)); }
2698
+ .submit { display: flex; align-items: center; justify-content: center; width: 36px; height: 36px; 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); }
2699
+ .submit svg { transform: rotate(-45deg); }
2700
+ .submit:hover:enabled { transform: scale(1.05); }
2701
+ .submit:disabled { background: var(--rf-bg-hover); color: var(--rf-text-muted); opacity: .65; }
2702
+ .composer-toolbar { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
2703
+ .composer-controls { display: flex; align-items: center; gap: 2px; padding: 3px; border-radius: var(--rf-pill); }
2704
+ .select-control { position: relative; display: flex; align-items: center; min-width: 0; }
2705
+ .select-control > svg { position: absolute; right: 9px; width: 12px; height: 12px; pointer-events: none; color: var(--rf-text-muted); }
2706
+ .category-select { appearance: none; min-width: 0; max-width: 124px; height: 32px; padding: 0 26px 0 12px; border: 0; border-radius: var(--rf-pill); color: var(--rf-text); background: transparent; cursor: pointer; text-overflow: ellipsis; }
2707
+ .category-select:hover:hover { background: var(--rf-bg-hover); }
2708
+ option { background: var(--rf-bg); color: var(--rf-text); }
2709
+ .composer-options { position: relative; }
2710
+ summary { cursor: pointer; list-style: none; }
2711
+ summary::-webkit-details-marker { display: none; }
2712
+ .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); }
2713
+ .composer-options[data-placement="below"] .options-popover { top: calc(100% + 12px); bottom: auto; }
2714
+ .email-field { display: flex; flex-direction: column; gap: 6px; color: var(--rf-text-muted); font-size: 12px; }
2715
+ .email-field [role="alert"] { color: var(--rf-danger); }
2716
+ .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); }
2717
+ .dismiss-btn { min-height: 32px; text-align: left; padding: 0 8px; border-radius: var(--rf-radius-sm); color: var(--rf-text-muted); }
2718
+ .dismiss-btn:hover { background: var(--rf-bg-hover); }
2719
+ .version-info { font-size: 10px; color: var(--rf-text-muted); padding: 4px 8px 0; border-top: 1px solid var(--rf-border); text-decoration: none; }
2720
+ .version-info:hover { color: var(--rf-text); }
2721
+ .attachments { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; }
2722
+ .screenshot-scroll-area { position: relative; min-width: 0; max-width: calc(100% + 8px); margin: -4px -4px -8px; }
2723
+ .screenshot-strip { width: 100%; padding: 4px 4px 8px; 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; }
2724
+ .screenshot-strip[data-overflow-x-start] { --rf-fade-start: min(24px, var(--scroll-area-overflow-x-start)); }
2725
+ .screenshot-strip[data-overflow-x-end] { --rf-fade-end: min(24px, var(--scroll-area-overflow-x-end)); }
2726
+ .screenshot-strip-content { display: flex; align-items: flex-end; gap: 8px; }
2727
+ .screenshot-scroll-area:has(.screenshot-strip-content:empty) { display: none; }
2728
+ .attachments:has(.screenshot-strip-content:empty):not(:has(.selection-chip)) { display: none; }
2729
+ .screenshot-scrollbar { height: 4px; margin: 0 8px 2px; opacity: 0; transition: opacity 150ms var(--rf-ease); }
2730
+ .screenshot-scrollbar[data-hovering], .screenshot-scrollbar[data-scrolling] { opacity: 1; }
2731
+ .screenshot-scroll-thumb { border-radius: var(--rf-pill); background: var(--rf-border); }
2732
+ .capture-progress { position: absolute; inset: 3px; display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 6px; border-radius: 16px; color: var(--rf-text-muted); font-size: 10px; }
2733
+ .attachments:empty { display: none; }
2734
+ .screenshot-attachment, .attachment-skeleton { position: relative; flex: none; margin: 0; min-width: 0; width: 128px; height: 78px; padding: 3px; border-radius: var(--rf-radius); animation: rf-in 260ms var(--rf-ease); }
2735
+ .screenshot-preview { display: block; position: relative; width: 100%; height: 100%; padding: 0; overflow: hidden; border-radius: 16px; }
2736
+ .screenshot-preview img, .screenshot-preview canvas { width: 100%; height: 100%; display: block; object-fit: cover; object-position: top center; }
2737
+ .screenshot-preview canvas { position: absolute; inset: 0; pointer-events: none; }
2738
+ .attachment-caption { position: absolute; left: 5px; bottom: 5px; display: flex; align-items: center; gap: 4px; padding: 3px 6px; font-size: 10px; border-radius: var(--rf-pill); color: var(--rf-text); }
2739
+ .attachment-caption svg { width: 12px; height: 12px; }
2740
+ .annotation-count { color: var(--rf-text); font-weight: 600; }
2741
+ .attachment-actions { position: absolute; right: 4px; bottom: -4px; display: flex; gap: 1px; padding: 1px; border-radius: var(--rf-pill); opacity: 0; pointer-events: none; transform: translateY(3px); transition: opacity 150ms var(--rf-ease), transform 150ms var(--rf-ease); }
2742
+ .screenshot-attachment:hover .attachment-actions, .screenshot-attachment:focus-within .attachment-actions { opacity: 1; pointer-events: auto; transform: none; }
2743
+ .screenshot-attachment:hover .attachment-caption, .screenshot-attachment:focus-within .attachment-caption { opacity: 0; }
2744
+ .attachment-skeleton { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; color: var(--rf-text-muted); font-size: 11px; }
2745
+ .attachment-skeleton::after { content: ""; position: absolute; inset: 0; border-radius: inherit; background: var(--rf-bg-subtle); animation: rf-fade 800ms alternate infinite; }
2746
+ .selection-chip { display: flex; align-items: center; max-width: 100%; padding: 3px; border-radius: var(--rf-pill); }
2747
+ .selection-label { display: flex; align-items: center; min-width: 0; gap: 6px; padding: 0 6px; font-size: 11px; }
2748
+ .selection-label img { object-fit: contain; border-radius: var(--rf-pill); }
2749
+ @media (pointer: coarse) {
2750
+ .category-select { height: 44px; max-width: 96px; }
2751
+ .message-row { border-radius: 28px; }
2752
+ .message-row textarea { font-size: 16px; min-height: 44px; height: 44px; padding-top: 12px; }
2753
+ .submit { width: 44px; height: 44px; }
2754
+ .attachment-actions { opacity: 1; pointer-events: auto; transform: none; }
2755
+ .attachment-caption { top: 4px; bottom: auto; right: 4px; left: auto; }
2756
+ .attachment-caption-label { display: none; }
2757
+ .screenshot-attachment:hover .attachment-caption, .screenshot-attachment:focus-within .attachment-caption { opacity: 1; }
2758
+ .screenshot-attachment, .attachment-skeleton { height: 96px; }
2759
+ .email-field input { font-size: 16px; }
2760
+ .dismiss-btn, .selection-label { min-height: 44px; }
1923
2761
  }
1924
- .tool {
1925
- display: inline-flex;
1926
- align-items: center;
1927
- justify-content: center;
1928
- width: 30px;
1929
- height: 30px;
1930
- border-radius: 7px;
1931
- color: var(--rf-text-muted);
1932
- transition: background 160ms var(--rf-ease), color 160ms var(--rf-ease);
2762
+
2763
+
2764
+ .overlay { margin: 0; border: 0; padding: 0; width: 100vw; max-width: none; height: 100dvh; max-height: none; position: fixed; inset: 0; z-index: 2147483001; display: flex; flex-direction: column; background: color-mix(in srgb, var(--rf-bg) 94%, transparent); animation: rf-editor-in 260ms var(--rf-ease); transition: background 260ms var(--rf-ease); color: var(--rf-text); font: 13px/1.45 ui-sans-serif, system-ui, sans-serif; }
2765
+ .overlay::backdrop { background: transparent; }
2766
+ @keyframes rf-editor-in { from { background: transparent; } }
2767
+ .editor { flex: 1; position: relative; display: flex; align-items: center; justify-content: center; padding: 20px; min-height: 0; container-type: size; }
2768
+ .editor-image { width: min(100cqw, calc(100cqh * var(--rf-capture-ratio))); aspect-ratio: var(--rf-capture-ratio); flex: none; position: relative; }
2769
+ .editor-visual { position: absolute; inset: 0; border-radius: var(--rf-radius); box-shadow: var(--rf-shadow); transform-origin: top left; overflow: hidden; }
2770
+ .editor-visual img, .editor-visual canvas { position: absolute; inset: 0; width: 100%; height: 100%; border-radius: inherit; object-fit: cover; object-position: top center; }
2771
+ .editor-visual canvas { cursor: crosshair; touch-action: none; }
2772
+ .overlay[data-opening="true"] .editor { pointer-events: none; }
2773
+ .overlay[data-closing="true"] { pointer-events: none; background: transparent; }
2774
+ .overlay[data-closing="true"] .toolbar { opacity: 0; transform: translateY(8px); }
2775
+
2776
+ .toolbar { 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)); }
2777
+ .toolbar .group { display: flex; align-items: center; gap: 2px; padding: 4px; border-radius: var(--rf-pill); }
2778
+ .tool[aria-pressed="true"], .tool[data-selected="true"] { background: var(--rf-primary); color: var(--rf-primary-text); }
2779
+ .color-picker { position: relative; }
2780
+ .color-picker summary { display: flex; justify-content: center; align-items: center; }
2781
+ .color-preview { width: 18px; height: 18px; border-radius: 50%; background: var(--annotation-color); box-shadow: inset 0 0 0 1px var(--rf-border); }
2782
+ .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); }
2783
+ .color-picker[data-placement="below"] .color-popover { top: calc(100% + 14px); bottom: auto; }
2784
+ .color-slider { flex: 1; min-width: 0; }
2785
+ .color-slider-control { display: flex; align-items: center; height: 32px; width: 100%; touch-action: none; user-select: none; cursor: pointer; }
2786
+ .color-slider-track { height: 12px; width: 100%; border-radius: var(--rf-pill); background: linear-gradient(to right, white, var(--annotation-base-color)); box-shadow: inset 0 0 0 1px var(--rf-border); }
2787
+ .color-slider-thumb { width: 16px; height: 16px; border: 2px solid white; border-radius: 50%; background: var(--annotation-color); box-shadow: var(--rf-shadow-low); transition: scale 160ms var(--rf-ease); }
2788
+ .color-slider-thumb[data-dragging] { scale: 1.12; }
2789
+ .color-slider-thumb:has(:focus-visible), .color-spectrum:has(:focus-visible) { outline: 2px solid var(--rf-accent); outline-offset: 3px; }
2790
+ .color-spectrum { position: relative; display: flex; align-items: center; justify-content: center; flex: none; width: 32px; height: 32px; border-radius: 50%; cursor: pointer; }
2791
+ .color-spectrum::before { content: ""; width: 16px; height: 16px; border-radius: 50%; background: conic-gradient(#ff5757, #ffde59, #6bdb78, #4ac9fa, #8b5cf6, #ef73dc, #ff5757); box-shadow: inset 0 0 0 1px var(--rf-glass-highlight); }
2792
+ .color-spectrum:hover { background: var(--rf-bg-hover); }
2793
+ .color-spectrum button { position: absolute; inset: 0; width: 100%; height: 100%; border-radius: inherit; }
2794
+ .color-spectrum button:focus-visible { outline: none; }
2795
+ .color-spectrum input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; pointer-events: none; }
2796
+ .done-btn { display: flex; align-items: center; justify-content: center; width: 34px; height: 34px; padding: 0; border-radius: var(--rf-pill); color: var(--rf-primary-text); background: var(--rf-primary); }
2797
+ .drawing-menu { position: relative; }
2798
+ .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); }
2799
+ .drawing-menu[data-placement="below"] .drawing-popover { top: calc(100% + 12px); bottom: auto; }
2800
+ .drawing-option { display: flex; align-items: center; width: 100%; min-height: 36px; gap: 10px; padding: 0 10px; border-radius: 12px; text-align: left; }
2801
+ .drawing-option:hover, .drawing-option[aria-pressed="true"] { background: var(--rf-bg-hover); }
2802
+ .drawing-divider { height: 1px; margin: 5px; background: var(--rf-border); }
2803
+ .editor canvas[data-tool="text"] { cursor: text; }
2804
+ .text-annotation-editor { position: fixed; z-index: 3; width: 240px; padding: 8px; border-radius: var(--rf-radius); }
2805
+ .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; }
2806
+ .text-annotation-editor textarea:focus { outline: none; }
2807
+ .text-annotation-actions { display: flex; align-items: center; justify-content: flex-end; gap: 4px; }
2808
+ @media (pointer: coarse) {
2809
+ .drawing-option { min-height: 44px; }
2810
+ .done-btn { width: 44px; height: 44px; }
2811
+ .color-popover { --rf-color-width: 232px; padding-left: 16px; }
2812
+ .color-slider-control, .color-spectrum { height: 44px; }
2813
+ .color-spectrum { width: 44px; }
2814
+ .color-slider-thumb, .color-spectrum::before { width: 18px; height: 18px; }
2815
+ .editor { padding: 8px; }
2816
+ .toolbar { gap: 6px; }
2817
+ .toolbar .group { padding: 3px; gap: 0; }
2818
+ }
2819
+ @media (pointer: coarse) and (max-width: 360px) {
2820
+ .toolbar { gap: 4px; padding-inline: 2px; }
2821
+ .toolbar .group { padding: 0; }
1933
2822
  }
1934
- .tool:hover { background: var(--rf-bg-hover); color: var(--rf-text); }
1935
- .tool[aria-pressed="true"] { background: var(--rf-bg-hover); color: var(--rf-text); }
1936
- .swatch {
1937
- width: 20px;
1938
- height: 20px;
1939
- border-radius: 50%;
1940
- border: 2px solid transparent;
1941
- transition: transform 200ms var(--rf-ease), border-color 160ms var(--rf-ease);
1942
- }
1943
- .swatch[aria-pressed="true"] { border-color: var(--rf-text); transform: scale(1.15); }
1944
- .toolbar .done-btn {
1945
- height: 30px;
1946
- padding: 0 13px;
1947
- font-size: 13px;
1948
- font-weight: 550;
1949
- border-radius: 7px;
1950
- color: var(--rf-primary-text);
1951
- background: var(--rf-primary);
1952
- transition: opacity 160ms var(--rf-ease);
1953
- }
1954
- .toolbar .done-btn:hover { opacity: 0.88; }
2823
+
1955
2824
 
1956
2825
  .picker-box {
1957
2826
  position: fixed;
@@ -2008,26 +2877,9 @@ textarea:focus, input:focus { outline: none; border-color: color-mix(in srgb, va
2008
2877
  box-shadow: var(--rf-shadow-inset);
2009
2878
  }
2010
2879
 
2011
- @media (pointer: coarse) {
2012
- .icon-btn, .tool, .swatch {
2013
- width: 44px;
2014
- height: 44px;
2015
- }
2016
-
2017
- .launcher, .segmented button, .ghost-btn, .submit, .toolbar .done-btn {
2018
- min-height: 44px;
2019
- }
2020
-
2021
- .dismiss-btn, .footer a {
2022
- display: inline-flex;
2023
- min-height: 44px;
2024
- align-items: center;
2025
- }
2026
2880
 
2027
- .footer a {
2028
- min-width: 44px;
2029
- justify-content: center;
2030
- }
2881
+ @media (prefers-reduced-motion: reduce) {
2882
+ *, *::before, *::after { animation: none !important; transition: none !important; }
2031
2883
  }
2032
2884
  `;
2033
2885
  //#endregion
@@ -2277,10 +3129,55 @@ async function captureElementCloseUp(element) {
2277
3129
  }
2278
3130
  }
2279
3131
  //#endregion
3132
+ //#region src/feedback/core/submission/upload-screenshots.ts
3133
+ async function uploadImage(transport, image) {
3134
+ const { uploadUrl } = await transport.getScreenshotUploadUrl();
3135
+ return transport.uploadImage(uploadUrl, image);
3136
+ }
3137
+ async function prepareScreenshot(transport, screenshot, index) {
3138
+ const { image, annotations, context } = screenshot;
3139
+ const annotated = annotations.length > 0 ? await renderAnnotatedImage(image, annotations) : null;
3140
+ if (annotations.length > 0 && !annotated) throw new Error("Could not render your annotations. Please try again.");
3141
+ try {
3142
+ const storageId = await uploadImage(transport, image);
3143
+ return {
3144
+ annotatedStorageId: annotated ? await uploadImage(transport, annotated) : void 0,
3145
+ annotations: annotations.length > 0 ? toWireAnnotations(annotations) : void 0,
3146
+ captureSource: "widget",
3147
+ filename: `screenshot-${index + 1}.png`,
3148
+ height: image.height,
3149
+ mimeType: image.mimeType,
3150
+ pageUrl: context.url,
3151
+ size: image.blob.size,
3152
+ storageId,
3153
+ width: image.width
3154
+ };
3155
+ } finally {
3156
+ releaseCapture(annotated);
3157
+ }
3158
+ }
3159
+ async function uploadScreenshots(transport, submission) {
3160
+ const prepared = (await Promise.allSettled(submission.screenshots.map((screenshot, index) => prepareScreenshot(transport, screenshot, index)))).map((result) => {
3161
+ if (result.status === "rejected") throw result.reason;
3162
+ return result.value;
3163
+ });
3164
+ const element = submission.element;
3165
+ if (element) prepared.push({
3166
+ captureSource: "element",
3167
+ filename: "element.png",
3168
+ height: element.height,
3169
+ mimeType: element.mimeType,
3170
+ pageUrl: submission.context.url,
3171
+ size: element.blob.size,
3172
+ storageId: await uploadImage(transport, element),
3173
+ width: element.width
3174
+ });
3175
+ return prepared;
3176
+ }
3177
+ //#endregion
2280
3178
  //#region src/feedback/core/submit.ts
2281
3179
  const MAX_TITLE_LENGTH = 100;
2282
3180
  const NEWLINE = /\r?\n/;
2283
- /** First line of the message, cut on a word boundary so titles stay readable. */
2284
3181
  function deriveTitle(message) {
2285
3182
  const firstLine = message.split(NEWLINE).map((line) => line.trim()).find(Boolean) ?? "";
2286
3183
  if (firstLine.length <= MAX_TITLE_LENGTH) return firstLine;
@@ -2294,64 +3191,31 @@ function buildDescription(params) {
2294
3191
  const contact = isAnonymous && email ? `\n\n---\nContact: ${email}` : "";
2295
3192
  return `[${label}] ${message.trim()}${contact}`;
2296
3193
  }
2297
- async function upload(transport, image) {
2298
- const { uploadUrl } = await transport.getScreenshotUploadUrl();
2299
- return await transport.uploadImage(uploadUrl, image);
2300
- }
2301
- async function attachScreenshot(transport, feedbackId, submission) {
2302
- const { annotated, annotations, context, screenshot } = submission;
2303
- if (!screenshot) return false;
2304
- const storageId = await upload(transport, screenshot);
2305
- const annotatedStorageId = annotated ? await upload(transport, annotated) : void 0;
2306
- const wireAnnotations = toWireAnnotations(annotations);
2307
- await transport.saveScreenshot({
2308
- annotatedStorageId,
2309
- annotations: wireAnnotations.length > 0 ? wireAnnotations : void 0,
2310
- captureSource: "widget",
2311
- feedbackId,
2312
- filename: "screenshot.png",
2313
- height: screenshot.height,
2314
- mimeType: screenshot.mimeType,
2315
- pageUrl: context.url,
2316
- size: screenshot.blob.size,
2317
- storageId,
2318
- width: screenshot.width
2319
- });
2320
- return true;
2321
- }
2322
- async function attachElement(transport, feedbackId, submission) {
2323
- const { context, element } = submission;
2324
- if (!element) return;
2325
- await transport.saveScreenshot({
2326
- captureSource: "element",
2327
- feedbackId,
2328
- filename: "element.png",
2329
- height: element.height,
2330
- mimeType: element.mimeType,
2331
- pageUrl: context.url,
2332
- size: element.blob.size,
2333
- storageId: await upload(transport, element),
2334
- width: element.width
2335
- });
3194
+ async function attachPreparedScreenshots(transport, result) {
3195
+ const results = await Promise.allSettled(result.pendingScreenshots.map((screenshot) => transport.saveScreenshot({
3196
+ ...screenshot,
3197
+ feedbackId: result.feedbackId
3198
+ })));
3199
+ const pendingScreenshots = result.pendingScreenshots.filter((_, index) => results[index]?.status === "rejected");
3200
+ return {
3201
+ feedbackId: result.feedbackId,
3202
+ pendingScreenshots
3203
+ };
2336
3204
  }
2337
- /**
2338
- * Creates the feedback first, then attaches the capture. A failed upload is
2339
- * reported back but never discards what the user wrote.
2340
- */
2341
- async function submitWidgetFeedback(transport, submission) {
2342
- const title = deriveTitle(submission.message);
2343
- if (!title) throw new Error("Please describe your feedback before sending it.");
3205
+ async function createReportWithScreenshots(transport, submission, pendingScreenshots) {
2344
3206
  const { feedbackId } = await transport.create({
2345
3207
  context: submission.context,
2346
3208
  description: buildDescription(submission),
2347
- title
3209
+ title: deriveTitle(submission.message)
2348
3210
  });
2349
- const screenshotSaved = await attachScreenshot(transport, feedbackId, submission).catch(() => false);
2350
- await attachElement(transport, feedbackId, submission).catch(() => void 0);
2351
- return {
3211
+ return attachPreparedScreenshots(transport, {
2352
3212
  feedbackId,
2353
- screenshotSaved
2354
- };
3213
+ pendingScreenshots
3214
+ });
3215
+ }
3216
+ async function submitWidgetFeedback(transport, submission) {
3217
+ if (!deriveTitle(submission.message)) throw new Error("Please describe your feedback before sending it.");
3218
+ return createReportWithScreenshots(transport, submission, await uploadScreenshots(transport, submission));
2355
3219
  }
2356
3220
  function isStorageResponse(value) {
2357
3221
  return typeof value === "object" && value !== null && typeof Reflect.get(value, "storageId") === "string";
@@ -2375,6 +3239,220 @@ function createFeedbackTransport(client) {
2375
3239
  };
2376
3240
  }
2377
3241
  //#endregion
3242
+ //#region src/feedback/ui/state/use-feedback-submission.ts
3243
+ function useFeedbackSubmission(options) {
3244
+ const [activeRequest, setActiveRequest] = useState(null);
3245
+ const [pendingReport, setPendingReport] = useState(null);
3246
+ const requestRef = useRef(null);
3247
+ useEffect(() => () => {
3248
+ requestRef.current = null;
3249
+ }, []);
3250
+ const resetSubmission = useCallback(() => {
3251
+ requestRef.current = null;
3252
+ setPendingReport(null);
3253
+ setActiveRequest(null);
3254
+ }, []);
3255
+ const finishSubmission = (result) => {
3256
+ if (result.pendingScreenshots.length > 0) {
3257
+ setPendingReport(result);
3258
+ options.onError(options.partialUploadMessage);
3259
+ return;
3260
+ }
3261
+ setPendingReport(null);
3262
+ options.onSubmitted(result.feedbackId);
3263
+ };
3264
+ const submit = async (draft) => {
3265
+ if (requestRef.current) return;
3266
+ if (!options.client) {
3267
+ options.onError("Reflet is missing a publicKey. Pass one to RefletFeedback or RefletProvider.");
3268
+ return;
3269
+ }
3270
+ const request = {};
3271
+ requestRef.current = request;
3272
+ setActiveRequest(request);
3273
+ options.onError(null);
3274
+ try {
3275
+ const transport = createFeedbackTransport(options.client);
3276
+ const result = pendingReport ? await attachPreparedScreenshots(transport, pendingReport) : await submitWidgetFeedback(transport, draft);
3277
+ if (requestRef.current === request) finishSubmission(result);
3278
+ } catch (error) {
3279
+ if (requestRef.current === request) options.onError(error instanceof Error ? error.message : "Something went wrong. Please try again.");
3280
+ } finally {
3281
+ if (requestRef.current === request) {
3282
+ requestRef.current = null;
3283
+ setActiveRequest(null);
3284
+ }
3285
+ }
3286
+ };
3287
+ return {
3288
+ hasPendingAttachments: pendingReport !== null,
3289
+ isEditingDisabled: activeRequest !== null || pendingReport !== null,
3290
+ isSubmitting: activeRequest !== null,
3291
+ resetSubmission,
3292
+ submit
3293
+ };
3294
+ }
3295
+ //#endregion
3296
+ //#region src/feedback/ui/state/use-owned-capture.ts
3297
+ function useOwnedCapture() {
3298
+ const [image, setImage] = useState(null);
3299
+ const ownedImage = useRef(null);
3300
+ useEffect(() => () => releaseCapture(ownedImage.current), []);
3301
+ return [image, useCallback((nextImage) => {
3302
+ releaseCapture(ownedImage.current);
3303
+ ownedImage.current = nextImage;
3304
+ setImage(nextImage);
3305
+ }, [])];
3306
+ }
3307
+ //#endregion
3308
+ //#region src/feedback/ui/state/use-screenshot-drafts.ts
3309
+ function useScreenshotDrafts(captureFailed, onError) {
3310
+ const [screenshots, setScreenshots] = useState([]);
3311
+ const [activeId, setActiveId] = useState(null);
3312
+ const [pendingCapture, setPendingCapture] = useState(null);
3313
+ const ownedImages = useRef(/* @__PURE__ */ new Map());
3314
+ const captureRequest = useRef(null);
3315
+ useEffect(() => {
3316
+ const images = ownedImages.current;
3317
+ return () => {
3318
+ captureRequest.current = null;
3319
+ for (const image of images.values()) releaseCapture(image);
3320
+ images.clear();
3321
+ };
3322
+ }, []);
3323
+ const storeScreenshot = useCallback((screenshot) => {
3324
+ releaseCapture(ownedImages.current.get(screenshot.id));
3325
+ ownedImages.current.set(screenshot.id, screenshot.image);
3326
+ setScreenshots((previous) => {
3327
+ return previous.some(({ id }) => id === screenshot.id) ? previous.map((item) => item.id === screenshot.id ? screenshot : item) : [...previous, screenshot];
3328
+ });
3329
+ setActiveId(screenshot.id);
3330
+ }, []);
3331
+ const cancelPendingCapture = useCallback(() => {
3332
+ captureRequest.current = null;
3333
+ setPendingCapture(null);
3334
+ }, []);
3335
+ const takeScreenshot = useCallback(async (request) => {
3336
+ captureRequest.current = request;
3337
+ setPendingCapture(request);
3338
+ onError(null);
3339
+ const context = collectPageContext({ sdkVersion: SDK_VERSION });
3340
+ const image = await captureViewport().catch(() => null);
3341
+ if (captureRequest.current !== request) {
3342
+ releaseCapture(image);
3343
+ return;
3344
+ }
3345
+ cancelPendingCapture();
3346
+ if (!image) {
3347
+ onError(captureFailed);
3348
+ return;
3349
+ }
3350
+ storeScreenshot({
3351
+ ...request,
3352
+ annotations: [],
3353
+ context,
3354
+ image
3355
+ });
3356
+ }, [
3357
+ cancelPendingCapture,
3358
+ captureFailed,
3359
+ onError,
3360
+ storeScreenshot
3361
+ ]);
3362
+ const takeCapture = useCallback(() => {
3363
+ if (!captureRequest.current) takeScreenshot({
3364
+ id: crypto.randomUUID(),
3365
+ source: "manual"
3366
+ });
3367
+ }, [takeScreenshot]);
3368
+ const refreshAutomatic = useCallback(() => {
3369
+ takeScreenshot({
3370
+ id: ownedImages.current.keys().next().value ?? captureRequest.current?.id ?? crypto.randomUUID(),
3371
+ source: "automatic"
3372
+ });
3373
+ }, [takeScreenshot]);
3374
+ const retakeCapture = useCallback((id) => {
3375
+ if (!captureRequest.current && ownedImages.current.has(id)) takeScreenshot({
3376
+ id,
3377
+ source: "manual"
3378
+ });
3379
+ }, [takeScreenshot]);
3380
+ const removeCapture = useCallback((id) => {
3381
+ if (captureRequest.current?.id === id) cancelPendingCapture();
3382
+ releaseCapture(ownedImages.current.get(id));
3383
+ ownedImages.current.delete(id);
3384
+ setScreenshots((previous) => previous.filter((item) => item.id !== id));
3385
+ setActiveId((current) => current === id ? null : current);
3386
+ }, [cancelPendingCapture]);
3387
+ const resetScreenshots = useCallback(() => {
3388
+ cancelPendingCapture();
3389
+ for (const image of ownedImages.current.values()) releaseCapture(image);
3390
+ ownedImages.current.clear();
3391
+ setScreenshots([]);
3392
+ setActiveId(null);
3393
+ }, [cancelPendingCapture]);
3394
+ const setAnnotations = useCallback((annotations) => {
3395
+ setScreenshots((previous) => previous.map((item) => item.id === activeId ? {
3396
+ ...item,
3397
+ annotations
3398
+ } : item));
3399
+ }, [activeId]);
3400
+ const clearSelectionAnnotations = useCallback(() => {
3401
+ setScreenshots((previous) => previous.map((item) => ({
3402
+ ...item,
3403
+ annotations: item.annotations.filter(({ id }) => !id.startsWith("selection-"))
3404
+ })));
3405
+ }, []);
3406
+ return {
3407
+ activeScreenshot: screenshots.find(({ id }) => id === activeId) ?? screenshots.at(-1) ?? null,
3408
+ cancelPendingCapture,
3409
+ clearSelectionAnnotations,
3410
+ pendingCapture,
3411
+ refreshAutomatic,
3412
+ removeCapture,
3413
+ resetScreenshots,
3414
+ retakeCapture,
3415
+ screenshots,
3416
+ selectScreenshot: setActiveId,
3417
+ setAnnotations,
3418
+ storeScreenshot,
3419
+ takeCapture
3420
+ };
3421
+ }
3422
+ //#endregion
3423
+ //#region src/feedback/ui/state/use-widget-config.ts
3424
+ function defaultCategory(props) {
3425
+ const preferred = props.defaultCategory ?? "bug";
3426
+ return props.categories?.find((category) => category === preferred) ?? props.categories?.[0] ?? preferred;
3427
+ }
3428
+ function dismissalDuration(days) {
3429
+ return days !== void 0 && Number.isFinite(days) && days > 0 ? days : null;
3430
+ }
3431
+ function useWidgetConfig(props) {
3432
+ const context = useContext(RefletContext);
3433
+ const publicKey = props.publicKey ?? context?.publicKey;
3434
+ const baseUrl = props.baseUrl ?? context?.baseUrl;
3435
+ const user = props.user ?? context?.user;
3436
+ const userToken = props.userToken ?? context?.userToken;
3437
+ return {
3438
+ client: useMemo(() => publicKey ? new Reflet({
3439
+ baseUrl,
3440
+ publicKey,
3441
+ user,
3442
+ userToken
3443
+ }) : null, [
3444
+ baseUrl,
3445
+ publicKey,
3446
+ user,
3447
+ userToken
3448
+ ]),
3449
+ defaultCategory: defaultCategory(props),
3450
+ dismissalKey: `reflet-feedback-dismissed:${publicKey ?? "default"}:${user?.id ?? "anonymous"}`,
3451
+ dismissForDays: dismissalDuration(props.dismissForDays),
3452
+ isAnonymous: !(user || userToken)
3453
+ };
3454
+ }
3455
+ //#endregion
2378
3456
  //#region src/feedback/ui/use-capture-sync.ts
2379
3457
  const RESIZE_DEBOUNCE = 400;
2380
3458
  /**
@@ -2424,7 +3502,6 @@ function useCaptureSync(enabled, refresh) {
2424
3502
  const SUCCESS_CLOSE_DELAY = 2400;
2425
3503
  const IS_APPLE = /Mac|iPhone|iPad/;
2426
3504
  const MILLISECONDS_PER_DAY = 864e5;
2427
- const DISMISSAL_STORAGE_PREFIX = "reflet-feedback-dismissed";
2428
3505
  function readDismissedUntil(key) {
2429
3506
  if (typeof window === "undefined") return 0;
2430
3507
  try {
@@ -2441,10 +3518,6 @@ function writeDismissedUntil(key, until) {
2441
3518
  window.localStorage.setItem(key, String(until));
2442
3519
  } catch {}
2443
3520
  }
2444
- /**
2445
- * Matches shortcuts written as `alt+f` or `mod+shift+k`, where `mod` is the
2446
- * platform's command key.
2447
- */
2448
3521
  function matchesHotkey(event, hotkey, platform = typeof navigator === "undefined" ? "" : navigator.platform) {
2449
3522
  const parts = hotkey.toLowerCase().split("+").map((part) => part.trim());
2450
3523
  const key = parts.at(-1);
@@ -2459,48 +3532,27 @@ function matchesHotkey(event, hotkey, platform = typeof navigator === "undefined
2459
3532
  };
2460
3533
  return event.altKey === expected.alt && event.ctrlKey === expected.ctrl && event.metaKey === expected.meta && event.shiftKey === expected.shift;
2461
3534
  }
2462
- /**
2463
- * A capture whose object URL is revoked the moment it is replaced or dropped —
2464
- * holding one without this leaks the blob for the lifetime of the page.
2465
- */
2466
- function useOwnedCapture() {
2467
- const [image, setImage] = useState(null);
2468
- const ref = useRef(null);
2469
- useEffect(() => {
2470
- ref.current = image;
2471
- }, [image]);
2472
- useEffect(() => () => releaseCapture(ref.current), []);
2473
- return [image, useCallback((next) => {
2474
- releaseCapture(ref.current);
2475
- setImage(next);
2476
- }, [])];
2477
- }
2478
3535
  function useWidgetState(props) {
2479
- const context = useContext(RefletContext);
2480
- const publicKey = props.publicKey ?? context?.publicKey;
2481
- const baseUrl = props.baseUrl ?? context?.baseUrl;
2482
- const user = props.user ?? context?.user;
2483
- const userToken = props.userToken ?? context?.userToken;
2484
- const isAnonymous = !(user || userToken);
2485
- const dismissForDays = props.dismissForDays !== void 0 && Number.isFinite(props.dismissForDays) && props.dismissForDays > 0 ? props.dismissForDays : null;
2486
- const dismissalKey = `${DISMISSAL_STORAGE_PREFIX}:${publicKey ?? "default"}:${user?.id ?? "anonymous"}`;
3536
+ const { client, defaultCategory, dismissalKey, dismissForDays, isAnonymous } = useWidgetConfig(props);
2487
3537
  const [isOpen, setIsOpen] = useState(false);
3538
+ const [annotationTrigger, setAnnotationTrigger] = useState(null);
2488
3539
  const [step, setStep] = useState("compose");
2489
- const [category, setCategory] = useState(props.defaultCategory ?? "bug");
3540
+ const [category, setCategory] = useState(defaultCategory);
2490
3541
  const [message, setMessage] = useState("");
2491
3542
  const [email, setEmail] = useState("");
2492
3543
  const [honeypot, setHoneypot] = useState("");
2493
- const [capture, replaceCapture] = useOwnedCapture();
2494
3544
  const [elementCapture, replaceElementCapture] = useOwnedCapture();
2495
- const [annotations, setAnnotations] = useState([]);
2496
3545
  const [selection, setSelection] = useState(null);
2497
- const [isCapturing, setIsCapturing] = useState(false);
2498
- const [isSubmitting, setIsSubmitting] = useState(false);
3546
+ const [isElementCapturing, setIsElementCapturing] = useState(false);
3547
+ const successTimer = useRef(null);
2499
3548
  const [error, setError] = useState(null);
2500
3549
  const [dismissedUntil, setDismissedUntil] = useState(() => readDismissedUntil(dismissalKey));
2501
3550
  const pickRef = useRef(0);
3551
+ useEffect(() => () => {
3552
+ pickRef.current++;
3553
+ if (successTimer.current) clearTimeout(successTimer.current);
3554
+ }, []);
2502
3555
  const consoleRef = useRef(null);
2503
- const frozenContext = useRef(null);
2504
3556
  useEffect(() => {
2505
3557
  setDismissedUntil(readDismissedUntil(dismissalKey));
2506
3558
  }, [dismissalKey]);
@@ -2513,33 +3565,45 @@ function useWidgetState(props) {
2513
3565
  recorder.stop();
2514
3566
  };
2515
3567
  }, [props.captureConsole]);
2516
- /** Frozen at the same instant as the screenshot, so shot and URL always match. */
2517
- const takeCapture = useCallback(() => {
2518
- const context = collectPageContext({ sdkVersion: SDK_VERSION });
2519
- setIsCapturing(true);
2520
- captureViewport().catch(() => null).then((image) => {
2521
- setIsCapturing(false);
2522
- replaceCapture(image);
2523
- frozenContext.current = image ? context : null;
2524
- });
2525
- }, [replaceCapture]);
2526
- useCaptureSync(isOpen && step === "compose" && annotations.length === 0 && !selection && props.captureOnOpen !== false, takeCapture);
3568
+ const captureFailed = props.labels?.captureFailed ?? DEFAULT_WIDGET_LABELS.captureFailed;
3569
+ const gallery = useScreenshotDrafts(captureFailed, setError);
3570
+ const { activeScreenshot, cancelPendingCapture, clearSelectionAnnotations, refreshAutomatic, resetScreenshots, storeScreenshot } = gallery;
3571
+ const capture = activeScreenshot?.image ?? null;
3572
+ const annotations = activeScreenshot?.annotations ?? [];
3573
+ const isCapturing = gallery.pendingCapture !== null || isElementCapturing;
3574
+ const submission = useFeedbackSubmission({
3575
+ client,
3576
+ onError: setError,
3577
+ onSubmitted: (feedbackId) => {
3578
+ setStep("success");
3579
+ props.onSubmit?.({ feedbackId });
3580
+ successTimer.current = setTimeout(close, SUCCESS_CLOSE_DELAY);
3581
+ },
3582
+ partialUploadMessage: props.labels?.attachmentUploadFailed ?? DEFAULT_WIDGET_LABELS.attachmentUploadFailed
3583
+ });
3584
+ const { resetSubmission } = submission;
3585
+ const firstCapture = gallery.pendingCapture ?? gallery.screenshots[0];
3586
+ const canRefreshAutomatically = gallery.screenshots.length <= 1 && firstCapture?.source === "automatic";
3587
+ useCaptureSync(isOpen && !submission.isEditingDisabled && step === "compose" && canRefreshAutomatically && annotations.length === 0 && !selection && props.captureOnOpen !== false, refreshAutomatic);
2527
3588
  const reset = useCallback(() => {
3589
+ if (successTimer.current) clearTimeout(successTimer.current);
3590
+ setAnnotationTrigger(null);
3591
+ resetSubmission();
2528
3592
  pickRef.current++;
2529
- frozenContext.current = null;
2530
- replaceCapture(null);
3593
+ setIsElementCapturing(false);
3594
+ resetScreenshots();
2531
3595
  replaceElementCapture(null);
2532
- setAnnotations([]);
2533
3596
  setSelection(null);
2534
3597
  setMessage("");
2535
3598
  setEmail("");
2536
3599
  setHoneypot("");
2537
3600
  setError(null);
2538
3601
  setStep("compose");
2539
- setCategory(props.defaultCategory ?? "bug");
3602
+ setCategory(defaultCategory);
2540
3603
  }, [
2541
- props.defaultCategory,
2542
- replaceCapture,
3604
+ defaultCategory,
3605
+ resetSubmission,
3606
+ resetScreenshots,
2543
3607
  replaceElementCapture
2544
3608
  ]);
2545
3609
  const close = useCallback(() => {
@@ -2565,14 +3629,15 @@ function useWidgetState(props) {
2565
3629
  setIsOpen(true);
2566
3630
  setStep("compose");
2567
3631
  props.onOpen?.();
2568
- if (props.captureOnOpen !== false) takeCapture();
3632
+ if (props.captureOnOpen !== false) refreshAutomatic();
2569
3633
  }, [
2570
3634
  props.captureOnOpen,
2571
3635
  props.onOpen,
2572
- takeCapture
3636
+ refreshAutomatic
2573
3637
  ]);
2574
- /** Fresh pixels so the highlight maps onto the scroll position the element had. */
2575
3638
  const selectElement = useCallback(async (element) => {
3639
+ cancelPendingCapture();
3640
+ setIsElementCapturing(true);
2576
3641
  const picked = buildElementSelection(element);
2577
3642
  setSelection(picked);
2578
3643
  setStep("compose");
@@ -2584,89 +3649,58 @@ function useWidgetState(props) {
2584
3649
  releaseCapture(closeUp);
2585
3650
  return;
2586
3651
  }
2587
- replaceCapture(fresh);
3652
+ setIsElementCapturing(false);
3653
+ if (fresh) storeScreenshot({
3654
+ annotations: [highlightFor(picked, fresh)],
3655
+ context,
3656
+ id: crypto.randomUUID(),
3657
+ image: fresh,
3658
+ source: "manual"
3659
+ });
3660
+ else setError(captureFailed);
2588
3661
  replaceElementCapture(closeUp);
2589
- frozenContext.current = fresh ? context : null;
2590
- setAnnotations(fresh ? [highlightFor(picked, fresh)] : []);
2591
- }, [replaceCapture, replaceElementCapture]);
3662
+ }, [
3663
+ cancelPendingCapture,
3664
+ captureFailed,
3665
+ storeScreenshot,
3666
+ replaceElementCapture
3667
+ ]);
2592
3668
  const clearSelection = useCallback(() => {
2593
3669
  pickRef.current++;
2594
3670
  replaceElementCapture(null);
2595
3671
  setSelection(null);
2596
- setAnnotations((previous) => previous.filter((item) => !item.id.startsWith("selection-")));
2597
- }, [replaceElementCapture]);
2598
- const removeCapture = useCallback(() => {
2599
- frozenContext.current = null;
2600
- replaceCapture(null);
2601
- setAnnotations([]);
2602
- }, [replaceCapture]);
2603
- const client = useMemo(() => publicKey ? new Reflet({
2604
- baseUrl,
2605
- publicKey,
2606
- user,
2607
- userToken
2608
- }) : null, [
2609
- baseUrl,
2610
- publicKey,
2611
- user,
2612
- userToken
2613
- ]);
2614
- const submit = useCallback(async () => {
3672
+ setIsElementCapturing(false);
3673
+ clearSelectionAnnotations();
3674
+ }, [clearSelectionAnnotations, replaceElementCapture]);
3675
+ const submit = () => {
2615
3676
  if (honeypot) {
2616
3677
  setStep("success");
2617
3678
  return;
2618
3679
  }
2619
- if (!client) {
2620
- setError("Reflet is missing a publicKey. Pass one to RefletFeedback or RefletProvider.");
2621
- return;
2622
- }
2623
- setIsSubmitting(true);
2624
- setError(null);
2625
- let annotated = null;
2626
- try {
2627
- annotated = capture ? await renderAnnotatedImage(capture, annotations) : null;
2628
- const result = await submitWidgetFeedback(createFeedbackTransport(client), {
2629
- annotated,
2630
- annotations,
2631
- category,
2632
- context: {
2633
- ...frozenContext.current ?? collectPageContext({ sdkVersion: "0.3.2" }),
2634
- consoleEvents: consoleRef.current?.events(),
2635
- metadata: props.metadata,
2636
- selection: selection ?? void 0
2637
- },
2638
- element: elementCapture,
2639
- email,
2640
- isAnonymous,
2641
- message,
2642
- screenshot: capture
2643
- });
2644
- setStep("success");
2645
- props.onSubmit?.({ feedbackId: result.feedbackId });
2646
- setTimeout(close, SUCCESS_CLOSE_DELAY);
2647
- } catch (submitError) {
2648
- setError(submitError instanceof Error ? submitError.message : "Something went wrong. Please try again.");
2649
- } finally {
2650
- releaseCapture(annotated);
2651
- setIsSubmitting(false);
2652
- }
2653
- }, [
2654
- annotations,
2655
- capture,
2656
- category,
2657
- client,
2658
- close,
2659
- elementCapture,
2660
- email,
2661
- honeypot,
2662
- isAnonymous,
2663
- message,
2664
- props.metadata,
2665
- props.onSubmit,
2666
- selection
2667
- ]);
3680
+ return submission.submit({
3681
+ category,
3682
+ context: {
3683
+ ...activeScreenshot?.context ?? collectPageContext({ sdkVersion: "0.3.4" }),
3684
+ consoleEvents: consoleRef.current?.events(),
3685
+ metadata: props.metadata,
3686
+ selection: selection ?? void 0
3687
+ },
3688
+ element: elementCapture,
3689
+ email,
3690
+ isAnonymous,
3691
+ message,
3692
+ screenshots: gallery.screenshots
3693
+ });
3694
+ };
2668
3695
  return {
3696
+ activeScreenshot,
3697
+ annotateScreenshot: (id, trigger) => {
3698
+ setAnnotationTrigger(trigger);
3699
+ gallery.selectScreenshot(id);
3700
+ setStep("annotate");
3701
+ },
2669
3702
  annotations,
3703
+ annotationTrigger,
2670
3704
  canDismiss: dismissForDays !== null,
2671
3705
  capture,
2672
3706
  category,
@@ -2677,18 +3711,23 @@ function useWidgetState(props) {
2677
3711
  elementCapture,
2678
3712
  email,
2679
3713
  error,
3714
+ hasPendingAttachments: submission.hasPendingAttachments,
2680
3715
  honeypot,
2681
3716
  isAnonymous,
2682
3717
  isCapturing,
2683
3718
  isDismissed: dismissedUntil > Date.now(),
3719
+ isEditingDisabled: submission.isEditingDisabled,
2684
3720
  isOpen,
2685
- isSubmitting,
3721
+ isSubmitting: submission.isSubmitting,
2686
3722
  message,
2687
3723
  open,
2688
- removeCapture,
3724
+ pendingCapture: gallery.pendingCapture,
3725
+ removeCapture: gallery.removeCapture,
3726
+ retakeCapture: gallery.retakeCapture,
3727
+ screenshots: gallery.screenshots,
2689
3728
  selectElement,
2690
3729
  selection,
2691
- setAnnotations,
3730
+ setAnnotations: gallery.setAnnotations,
2692
3731
  setCategory,
2693
3732
  setEmail,
2694
3733
  setHoneypot,
@@ -2696,7 +3735,7 @@ function useWidgetState(props) {
2696
3735
  setStep,
2697
3736
  step,
2698
3737
  submit,
2699
- takeCapture
3738
+ takeCapture: gallery.takeCapture
2700
3739
  };
2701
3740
  }
2702
3741
  //#endregion
@@ -2706,14 +3745,8 @@ const DEFAULT_CATEGORIES = [
2706
3745
  "idea",
2707
3746
  "question"
2708
3747
  ];
2709
- const DEFAULT_OFFSET = 20;
2710
- /**
2711
- * Floating feedback button with a screenshot, annotation and element picker
2712
- * flow. Mount it once, anywhere below `RefletProvider` or with its own
2713
- * `publicKey`.
2714
- */
2715
3748
  function RefletFeedback(props) {
2716
- const { categories = DEFAULT_CATEGORIES, enabled = true, hotkey = null, offset = DEFAULT_OFFSET, position = "bottom-right", theme = "auto" } = props;
3749
+ const { categories = DEFAULT_CATEGORIES, enabled = true, hotkey = null, offset = 20, position = "bottom-right", theme = "auto" } = props;
2717
3750
  const labels = {
2718
3751
  ...DEFAULT_WIDGET_LABELS,
2719
3752
  ...props.labels
@@ -2738,44 +3771,38 @@ function RefletFeedback(props) {
2738
3771
  isOpen,
2739
3772
  open
2740
3773
  ]);
2741
- useEffect(() => {
2742
- if (!isOpen || state.step !== "compose") return;
2743
- const onKeyDown = (event) => {
2744
- if (event.key === "Escape") close();
2745
- };
2746
- document.addEventListener("keydown", onKeyDown);
2747
- return () => document.removeEventListener("keydown", onKeyDown);
2748
- }, [
2749
- close,
2750
- isOpen,
2751
- state.step
2752
- ]);
2753
3774
  if (!enabled || state.isDismissed) return null;
2754
- const showPanel = isOpen && state.step !== "picking" && state.step !== "annotate";
2755
3775
  return /* @__PURE__ */ jsxs(ShadowPortal, {
2756
3776
  offset,
2757
3777
  primaryColor: props.primaryColor,
2758
3778
  theme,
2759
3779
  children: [
2760
- /* @__PURE__ */ jsxs("div", {
2761
- className: "root",
2762
- "data-position": position,
2763
- children: [showPanel && /* @__PURE__ */ jsx(FeedbackPanel, {
2764
- categories,
2765
- labels,
2766
- state
2767
- }), /* @__PURE__ */ jsx(Launcher, {
2768
- isOpen,
2769
- label: labels.trigger,
2770
- onClick: isOpen ? close : open
2771
- })]
3780
+ /* @__PURE__ */ jsx("div", {
3781
+ "aria-hidden": "true",
3782
+ className: "capture-halo",
3783
+ "data-active": state.isCapturing
2772
3784
  }),
2773
- isOpen && state.step === "annotate" && state.capture && /* @__PURE__ */ jsx(Annotator, {
2774
- annotations: state.annotations,
2775
- capture: state.capture,
3785
+ /* @__PURE__ */ jsx(FloatingWidget, {
2776
3786
  labels,
2777
- onChange: state.setAnnotations,
2778
- onDone: () => state.setStep("compose")
3787
+ options: {
3788
+ categories,
3789
+ position
3790
+ },
3791
+ state
3792
+ }),
3793
+ isOpen && state.step === "annotate" && state.activeScreenshot && /* @__PURE__ */ jsx(Annotator, {
3794
+ capture: state.activeScreenshot.image,
3795
+ editor: {
3796
+ annotations: state.annotations,
3797
+ onChange: state.setAnnotations,
3798
+ onDone: () => state.setStep("compose"),
3799
+ onRetake: () => {
3800
+ state.setStep("compose");
3801
+ if (state.activeScreenshot) state.retakeCapture(state.activeScreenshot.id);
3802
+ },
3803
+ trigger: state.annotationTrigger
3804
+ },
3805
+ labels
2779
3806
  }),
2780
3807
  isOpen && state.step === "picking" && /* @__PURE__ */ jsx(ElementPicker, {
2781
3808
  hint: labels.pickElementHint,