reflet-sdk 0.3.3 → 0.3.5

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,8 +1,10 @@
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. */
@@ -184,7 +186,7 @@ function fitWithin(size, maxWidth) {
184
186
  width: maxWidth
185
187
  };
186
188
  }
187
- function toBlob$1(canvas) {
189
+ function toBlob(canvas) {
188
190
  return new Promise((resolve) => {
189
191
  canvas.toBlob(resolve, "image/png");
190
192
  });
@@ -206,7 +208,7 @@ async function capturedFromCanvas(source, maxWidth) {
206
208
  width: source.width
207
209
  }, maxWidth);
208
210
  const output = fitted.scale === 1 ? source : downscale(source, fitted);
209
- const blob = await toBlob$1(output);
211
+ const blob = await toBlob(output);
210
212
  if (!blob) return null;
211
213
  return {
212
214
  blob,
@@ -807,29 +809,40 @@ function collectPageContext(options) {
807
809
  }
808
810
  //#endregion
809
811
  //#region src/feedback/types.ts
810
- /** Reported with every submission. */
811
- const SDK_VERSION = "0.3.3";
812
+ const SDK_VERSION = "0.3.5";
812
813
  const DEFAULT_WIDGET_LABELS = {
813
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.",
814
816
  attachScreenshot: "Attach a screenshot",
815
817
  back: "Back",
816
818
  cancel: "Cancel",
819
+ captureFailed: "Screenshot unavailable. Try again or send without it.",
820
+ capturing: "Taking screenshot…",
817
821
  categoryBug: "Bug",
818
822
  categoryIdea: "Idea",
823
+ categoryLabel: "Feedback type",
819
824
  categoryQuestion: "Question",
820
825
  clearAnnotations: "Clear drawing",
821
- descriptionPlaceholder: "What happened? What did you expect?",
826
+ clearSelection: "Remove selected element",
827
+ descriptionPlaceholder: "What would you like to share?",
822
828
  dismissForDays: "Hide for {days} days",
823
829
  done: "Done",
830
+ emailInvalid: "Enter a valid email address.",
824
831
  emailLabel: "Email",
825
832
  emailPlaceholder: "you@company.com",
826
833
  errorGeneric: "Something went wrong. Please try again.",
827
834
  errorTitleRequired: "Tell us a bit about it first.",
835
+ minimize: "Minimize feedback",
836
+ moreOptions: "More options",
837
+ moveFeedback: "Move feedback",
828
838
  pickElement: "Point at an element",
829
839
  pickElementHint: "Click the element you are talking about — cancel with",
830
840
  recapture: "Retake",
831
841
  removeScreenshot: "Remove",
842
+ resume: "Resume feedback",
832
843
  retakeHint: "Screenshot of this page",
844
+ retryAttachments: "Retry attachments",
845
+ screenshot: "Screenshot",
833
846
  submit: "Send feedback",
834
847
  successMessage: "We read every report. Thanks for taking the time.",
835
848
  successTitle: "Feedback sent",
@@ -840,15 +853,10 @@ const DEFAULT_WIDGET_LABELS = {
840
853
  };
841
854
  //#endregion
842
855
  //#region src/feedback/core/annotations.ts
843
- const ANNOTATION_COLORS = [
844
- "#ef4444",
845
- "#f59e0b",
846
- "#22c55e",
847
- "#3b82f6",
848
- "#111827"
849
- ];
850
856
  const BOX_TOOLS = [
857
+ "text",
851
858
  "rectangle",
859
+ "spotlight",
852
860
  "highlight",
853
861
  "blur"
854
862
  ];
@@ -889,6 +897,7 @@ function simplifyPath(points, tolerance = DEFAULT_PATH_TOLERANCE) {
889
897
  return kept;
890
898
  }
891
899
  function isDegenerate(annotation) {
900
+ if (annotation.tool === "text") return !annotation.text?.trim();
892
901
  if (annotation.tool === "pen") return (annotation.points?.length ?? 0) < 2;
893
902
  const { start, end } = annotation;
894
903
  return Math.hypot(end.x - start.x, end.y - start.y) < MIN_GESTURE_LENGTH;
@@ -907,6 +916,7 @@ function toWire(annotation) {
907
916
  return {
908
917
  color,
909
918
  height: rect.height,
919
+ ...tool === "text" ? { text: annotation.text } : {},
910
920
  type: tool,
911
921
  width: rect.width,
912
922
  x: rect.x,
@@ -927,6 +937,526 @@ function toWireAnnotations(annotations) {
927
937
  return annotations.filter((item) => !isDegenerate(item)).map(toWire);
928
938
  }
929
939
  //#endregion
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("")}`;
954
+ }
955
+ //#endregion
956
+ //#region src/feedback/ui/icons.tsx
957
+ function Svg({ children, className, size = 16 }) {
958
+ return /* @__PURE__ */ jsx("svg", {
959
+ "aria-hidden": "true",
960
+ className,
961
+ fill: "none",
962
+ height: size,
963
+ stroke: "currentColor",
964
+ strokeLinecap: "round",
965
+ strokeLinejoin: "round",
966
+ strokeWidth: "1.8",
967
+ viewBox: "0 0 24 24",
968
+ width: size,
969
+ children
970
+ });
971
+ }
972
+ function ChatIcon({ className }) {
973
+ return /* @__PURE__ */ jsx(Svg, {
974
+ className,
975
+ size: 15,
976
+ children: /* @__PURE__ */ jsx("path", { d: "M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" })
977
+ });
978
+ }
979
+ function CloseIcon({ className }) {
980
+ return /* @__PURE__ */ jsx(Svg, {
981
+ className,
982
+ children: /* @__PURE__ */ jsx("path", { d: "M18 6 6 18M6 6l12 12" })
983
+ });
984
+ }
985
+ function CameraIcon() {
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", {
987
+ cx: "12",
988
+ cy: "13",
989
+ r: "3.2"
990
+ })] });
991
+ }
992
+ function PencilIcon() {
993
+ return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M16.5 4.5a2.1 2.1 0 0 1 3 3L8 19l-4 1 1-4z" }) });
994
+ }
995
+ function TargetIcon() {
996
+ return /* @__PURE__ */ jsxs(Svg, { children: [/* @__PURE__ */ jsx("path", { d: "M12 3v3M12 18v3M3 12h3M18 12h3" }), /* @__PURE__ */ jsx("circle", {
997
+ cx: "12",
998
+ cy: "12",
999
+ r: "5"
1000
+ })] });
1001
+ }
1002
+ function TrashIcon() {
1003
+ return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M4 7h16M9 7V5h6v2M6 7l1 13h10l1-13M10 11v5M14 11v5" }) });
1004
+ }
1005
+ function ArrowIcon() {
1006
+ return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M5 19 19 5M11 5h8v8" }) });
1007
+ }
1008
+ function UndoIcon() {
1009
+ return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M4 9h11a5 5 0 0 1 0 10h-5M4 9l4-4M4 9l4 4" }) });
1010
+ }
1011
+ function CheckIcon({ size = 28 }) {
1012
+ return /* @__PURE__ */ jsx("svg", {
1013
+ "aria-hidden": "true",
1014
+ fill: "none",
1015
+ height: size,
1016
+ stroke: "currentColor",
1017
+ strokeLinecap: "round",
1018
+ strokeLinejoin: "round",
1019
+ strokeWidth: "2.4",
1020
+ viewBox: "0 0 24 24",
1021
+ width: size,
1022
+ children: /* @__PURE__ */ jsx("path", { d: "m5 13 4 4L19 7" })
1023
+ });
1024
+ }
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" }) });
1027
+ }
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
930
1460
  //#region src/feedback/core/annotation-renderer.ts
931
1461
  const STROKE_WIDTH = 3;
932
1462
  const ARROW_HEAD_SIZE = 16;
@@ -998,6 +1528,18 @@ function drawPen(context, points, scale) {
998
1528
  }
999
1529
  context.stroke();
1000
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
+ }
1001
1543
  function drawAnnotation(context, annotation, scale) {
1002
1544
  const { color, tool } = annotation;
1003
1545
  const rect = scaleRect(normalizeRect(annotation.start, annotation.end), scale);
@@ -1008,12 +1550,23 @@ function drawAnnotation(context, annotation, scale) {
1008
1550
  context.strokeStyle = color;
1009
1551
  context.fillStyle = color;
1010
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
+ }
1011
1561
  case "pen":
1012
1562
  drawPen(context, annotation.points ?? [], scale);
1013
1563
  break;
1014
1564
  case "arrow":
1015
1565
  drawArrow(context, scalePoint(annotation.start, scale), scalePoint(annotation.end, scale), scale);
1016
1566
  break;
1567
+ case "spotlight":
1568
+ drawSpotlight(context, rect);
1569
+ break;
1017
1570
  case "rectangle":
1018
1571
  context.strokeRect(rect.x, rect.y, rect.width, rect.height);
1019
1572
  break;
@@ -1037,11 +1590,6 @@ function loadImage(url) {
1037
1590
  image.src = url;
1038
1591
  });
1039
1592
  }
1040
- function toBlob(canvas) {
1041
- return new Promise((resolve) => {
1042
- canvas.toBlob(resolve, "image/png");
1043
- });
1044
- }
1045
1593
  /**
1046
1594
  * Burns the annotations into a copy of the capture so reviewers and agents see
1047
1595
  * the drawing without needing to replay the vector data.
@@ -1056,211 +1604,160 @@ async function renderAnnotatedImage(source, annotations) {
1056
1604
  const image = await loadImage(source.objectUrl);
1057
1605
  context.drawImage(image, 0, 0, source.width, source.height);
1058
1606
  drawAnnotations(context, annotations);
1059
- const blob = await toBlob(canvas);
1060
- if (!blob) return null;
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;
1626
+ useEffect(() => {
1627
+ const canvas = canvasRef.current;
1628
+ const context = canvas?.getContext("2d");
1629
+ if (!(canvas && context && bitmap && isDecoded)) return;
1630
+ context.clearRect(0, 0, canvas.width, canvas.height);
1631
+ context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
1632
+ drawAnnotations(context, annotations);
1633
+ }, [
1634
+ annotations,
1635
+ bitmap,
1636
+ isDecoded
1637
+ ]);
1061
1638
  return {
1062
- blob,
1063
- height: source.height,
1064
- mimeType: "image/png",
1065
- objectUrl: URL.createObjectURL(blob),
1066
- width: source.width
1639
+ canvasRef,
1640
+ hasError: failedUrl === capture.objectUrl,
1641
+ isDecoded
1067
1642
  };
1068
1643
  }
1069
1644
  //#endregion
1070
- //#region src/feedback/ui/icons.tsx
1071
- function Svg({ children, className, size = 16 }) {
1072
- return /* @__PURE__ */ jsx("svg", {
1073
- "aria-hidden": "true",
1074
- className,
1075
- fill: "none",
1076
- height: size,
1077
- stroke: "currentColor",
1078
- strokeLinecap: "round",
1079
- strokeLinejoin: "round",
1080
- strokeWidth: "1.8",
1081
- viewBox: "0 0 24 24",
1082
- width: size,
1083
- children
1084
- });
1085
- }
1086
- function ChatIcon({ className }) {
1087
- return /* @__PURE__ */ jsx(Svg, {
1088
- className,
1089
- size: 15,
1090
- children: /* @__PURE__ */ jsx("path", { d: "M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" })
1091
- });
1092
- }
1093
- function CloseIcon({ className }) {
1094
- return /* @__PURE__ */ jsx(Svg, {
1095
- className,
1096
- children: /* @__PURE__ */ jsx("path", { d: "M18 6 6 18M6 6l12 12" })
1097
- });
1098
- }
1099
- function BugIcon() {
1100
- return /* @__PURE__ */ jsx(Svg, {
1101
- size: 14,
1102
- 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" })
1103
- });
1104
- }
1105
- function IdeaIcon() {
1106
- return /* @__PURE__ */ jsx(Svg, {
1107
- size: 14,
1108
- 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" })
1109
- });
1110
- }
1111
- function QuestionIcon() {
1112
- return /* @__PURE__ */ jsxs(Svg, {
1113
- size: 14,
1114
- children: [/* @__PURE__ */ jsx("circle", {
1115
- cx: "12",
1116
- cy: "12",
1117
- r: "9"
1118
- }), /* @__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" })]
1119
- });
1120
- }
1121
- function CameraIcon() {
1122
- 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", {
1123
- cx: "12",
1124
- cy: "13",
1125
- r: "3.2"
1126
- })] });
1127
- }
1128
- function PencilIcon() {
1129
- return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M16.5 4.5a2.1 2.1 0 0 1 3 3L8 19l-4 1 1-4z" }) });
1130
- }
1131
- function TargetIcon() {
1132
- return /* @__PURE__ */ jsxs(Svg, { children: [/* @__PURE__ */ jsx("path", { d: "M12 3v3M12 18v3M3 12h3M18 12h3" }), /* @__PURE__ */ jsx("circle", {
1133
- cx: "12",
1134
- cy: "12",
1135
- r: "5"
1136
- })] });
1137
- }
1138
- function TrashIcon() {
1139
- return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M4 7h16M9 7V5h6v2M6 7l1 13h10l1-13M10 11v5M14 11v5" }) });
1140
- }
1141
- function ArrowIcon() {
1142
- return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M5 19 19 5M11 5h8v8" }) });
1143
- }
1144
- function SquareIcon() {
1145
- return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("rect", {
1146
- height: "13",
1147
- rx: "1.5",
1148
- width: "16",
1149
- x: "4",
1150
- y: "5.5"
1151
- }) });
1152
- }
1153
- function HighlightIcon() {
1154
- return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M4 15h16M6 5h12v7H6z" }) });
1155
- }
1156
- function BlurIcon() {
1157
- 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" })] });
1158
- }
1159
- function UndoIcon() {
1160
- return /* @__PURE__ */ jsx(Svg, { children: /* @__PURE__ */ jsx("path", { d: "M4 9h11a5 5 0 0 1 0 10h-5M4 9l4-4M4 9l4 4" }) });
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
+ };
1161
1658
  }
1162
- function CheckIcon({ size = 28 }) {
1163
- return /* @__PURE__ */ jsx("svg", {
1164
- "aria-hidden": "true",
1165
- fill: "none",
1166
- height: size,
1167
- stroke: "currentColor",
1168
- strokeLinecap: "round",
1169
- strokeLinejoin: "round",
1170
- strokeWidth: "2.4",
1171
- viewBox: "0 0 24 24",
1172
- width: size,
1173
- children: /* @__PURE__ */ jsx("path", { d: "m5 13 4 4L19 7" })
1174
- });
1659
+ function expandedFrame(visual) {
1660
+ return {
1661
+ clipPath: `inset(0px 0px 0px 0px round ${getComputedStyle(visual).borderRadius})`,
1662
+ transform: "none"
1663
+ };
1175
1664
  }
1176
- function InfoIcon({ className }) {
1177
- return /* @__PURE__ */ jsxs(Svg, {
1178
- className,
1179
- size: 12,
1180
- children: [/* @__PURE__ */ jsx("circle", {
1181
- cx: "12",
1182
- cy: "12",
1183
- r: "9"
1184
- }), /* @__PURE__ */ jsx("path", { d: "M12 8h.01M11 12h1v4h1" })]
1185
- });
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
+ };
1186
1718
  }
1187
1719
  //#endregion
1188
1720
  //#region src/feedback/ui/annotator.tsx
1189
- const TOOLS = [
1190
- {
1191
- icon: PencilIcon,
1192
- id: "pen",
1193
- label: "Draw"
1194
- },
1195
- {
1196
- icon: ArrowIcon,
1197
- id: "arrow",
1198
- label: "Arrow"
1199
- },
1200
- {
1201
- icon: SquareIcon,
1202
- id: "rectangle",
1203
- label: "Box"
1204
- },
1205
- {
1206
- icon: HighlightIcon,
1207
- id: "highlight",
1208
- label: "Highlight"
1209
- },
1210
- {
1211
- icon: BlurIcon,
1212
- id: "blur",
1213
- label: "Hide"
1214
- }
1215
- ];
1216
1721
  function toImagePoint(canvas, clientX, clientY) {
1217
1722
  const bounds = canvas.getBoundingClientRect();
1218
1723
  return {
1219
- x: (clientX - bounds.left) / bounds.width * canvas.width,
1220
- y: (clientY - bounds.top) / bounds.height * canvas.height
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))
1221
1726
  };
1222
1727
  }
1223
- function Annotator({ annotations, capture, labels, onChange, onDone }) {
1224
- const canvasRef = useRef(null);
1225
- const nextId = useRef(0);
1226
- const [tool, setTool] = useState("pen");
1227
- const [color, setColor] = useState(ANNOTATION_COLORS[0]);
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);
1228
1735
  const [draft, setDraft] = useState(null);
1229
- const [base, setBase] = useState(null);
1230
- useEffect(() => {
1231
- let active = true;
1232
- const image = new Image();
1233
- image.onload = () => {
1234
- if (active) setBase(image);
1235
- };
1236
- image.src = capture.objectUrl;
1237
- return () => {
1238
- active = false;
1239
- setBase(null);
1240
- };
1241
- }, [capture.objectUrl]);
1242
- useEffect(() => {
1243
- const canvas = canvasRef.current;
1244
- const context = canvas?.getContext("2d");
1245
- if (!(canvas && context)) return;
1246
- context.clearRect(0, 0, canvas.width, canvas.height);
1247
- if (base) context.drawImage(base, 0, 0, canvas.width, canvas.height);
1248
- drawAnnotations(context, draft ? [...annotations, draft] : annotations);
1249
- }, [
1736
+ const { canvasRef, hasError, isDecoded } = useAnnotationCanvas(capture, draft ? [...annotations, draft] : annotations);
1737
+ const text = useTextAnnotation({
1250
1738
  annotations,
1251
- base,
1252
- draft
1253
- ]);
1739
+ color,
1740
+ onChange
1741
+ });
1742
+ useEffect(() => {
1743
+ const dialog = dialogRef.current;
1744
+ dialog?.showModal();
1745
+ return () => dialog?.close();
1746
+ }, []);
1254
1747
  const onPointerDown = (event) => {
1255
1748
  const canvas = canvasRef.current;
1256
- if (!canvas) return;
1257
- canvas.setPointerCapture(event.pointerId);
1749
+ if (!(canvas && isDecoded) || event.button !== 0) return;
1258
1750
  const point = toImagePoint(canvas, event.clientX, event.clientY);
1259
- nextId.current += 1;
1751
+ if (tool === "text") {
1752
+ event.preventDefault();
1753
+ text.beginText(canvas, point);
1754
+ return;
1755
+ }
1756
+ canvas.setPointerCapture(event.pointerId);
1260
1757
  setDraft({
1261
1758
  color,
1262
1759
  end: point,
1263
- id: `a${nextId.current}`,
1760
+ id: crypto.randomUUID(),
1264
1761
  points: tool === "pen" ? [point] : void 0,
1265
1762
  start: point,
1266
1763
  tool
@@ -1284,100 +1781,83 @@ function Annotator({ annotations, capture, labels, onChange, onDone }) {
1284
1781
  if (!isDegenerate(draft)) onChange([...annotations, draft]);
1285
1782
  setDraft(null);
1286
1783
  };
1287
- useEffect(() => {
1288
- const onKeyDown = (event) => {
1289
- if (event.key === "Escape") {
1290
- event.preventDefault();
1291
- onDone();
1292
- }
1293
- };
1294
- document.addEventListener("keydown", onKeyDown);
1295
- return () => document.removeEventListener("keydown", onKeyDown);
1296
- }, [onDone]);
1297
- return /* @__PURE__ */ jsxs("div", {
1784
+ return /* @__PURE__ */ jsxs("dialog", {
1785
+ "aria-label": labels.annotateHint,
1298
1786
  className: "overlay",
1299
- children: [/* @__PURE__ */ jsx("div", {
1300
- className: "editor",
1301
- children: /* @__PURE__ */ jsx("canvas", {
1302
- height: capture.height,
1303
- onPointerCancel: onPointerUp,
1304
- onPointerDown,
1305
- onPointerMove,
1306
- onPointerUp,
1307
- ref: canvasRef,
1308
- width: capture.width
1309
- })
1310
- }), /* @__PURE__ */ jsxs("div", {
1311
- className: "toolbar",
1312
- children: [
1313
- /* @__PURE__ */ jsx("div", {
1314
- className: "group",
1315
- children: TOOLS.map(({ icon: Icon, id, label }) => /* @__PURE__ */ jsx("button", {
1316
- "aria-label": label,
1317
- "aria-pressed": tool === id,
1318
- className: "tool",
1319
- onClick: () => setTool(id),
1320
- title: label,
1321
- type: "button",
1322
- children: /* @__PURE__ */ jsx(Icon, {})
1323
- }, id))
1324
- }),
1325
- /* @__PURE__ */ jsx("div", {
1326
- className: "group",
1327
- children: ANNOTATION_COLORS.map((value) => /* @__PURE__ */ jsx("button", {
1328
- "aria-label": `Color ${value}`,
1329
- "aria-pressed": color === value,
1330
- className: "swatch",
1331
- onClick: () => setColor(value),
1332
- style: { background: value },
1333
- type: "button"
1334
- }, value))
1335
- }),
1336
- /* @__PURE__ */ jsxs("div", {
1337
- className: "group",
1338
- children: [
1339
- /* @__PURE__ */ jsx("button", {
1340
- "aria-label": labels.undo,
1341
- className: "tool",
1342
- disabled: annotations.length === 0,
1343
- onClick: () => onChange(annotations.slice(0, -1)),
1344
- title: labels.undo,
1345
- type: "button",
1346
- children: /* @__PURE__ */ jsx(UndoIcon, {})
1347
- }),
1348
- /* @__PURE__ */ jsx("button", {
1349
- "aria-label": labels.clearAnnotations,
1350
- className: "tool",
1351
- disabled: annotations.length === 0,
1352
- onClick: () => onChange([]),
1353
- title: labels.clearAnnotations,
1354
- type: "button",
1355
- children: /* @__PURE__ */ jsx(TrashIcon, {})
1356
- }),
1357
- /* @__PURE__ */ jsx("button", {
1358
- className: "done-btn",
1359
- onClick: onDone,
1360
- type: "button",
1361
- children: labels.done
1362
- })
1363
- ]
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
+ })
1364
1825
  })
1365
- ]
1366
- })]
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
+ ]
1367
1850
  });
1368
1851
  }
1369
1852
  //#endregion
1370
1853
  //#region src/feedback/ui/launcher.tsx
1371
- /**
1372
- * Never changes size between states — the panel opening must not move the
1373
- * button out from under the pointer.
1374
- */
1375
- function Launcher({ isOpen, label, onClick }) {
1854
+ function Launcher({ isOpen, label, onClick, buttonRef }) {
1376
1855
  return /* @__PURE__ */ jsxs("button", {
1377
1856
  "aria-expanded": isOpen,
1378
- className: "launcher",
1857
+ className: "launcher glass",
1379
1858
  "data-open": isOpen,
1380
1859
  onClick,
1860
+ ref: buttonRef,
1381
1861
  type: "button",
1382
1862
  children: [/* @__PURE__ */ jsxs("span", {
1383
1863
  className: "launcher-icon",
@@ -1386,277 +1866,571 @@ function Launcher({ isOpen, label, onClick }) {
1386
1866
  });
1387
1867
  }
1388
1868
  //#endregion
1389
- //#region src/feedback/ui/panel.tsx
1390
- const CATEGORY_META = {
1391
- bug: {
1392
- icon: BugIcon,
1393
- labelKey: "categoryBug"
1394
- },
1395
- idea: {
1396
- icon: IdeaIcon,
1397
- labelKey: "categoryIdea"
1398
- },
1399
- question: {
1400
- icon: QuestionIcon,
1401
- labelKey: "categoryQuestion"
1402
- }
1403
- };
1404
- function Attachment({ labels, state }) {
1405
- if (state.isCapturing) return /* @__PURE__ */ jsxs("div", {
1406
- className: "card",
1407
- children: [/* @__PURE__ */ jsx("div", { className: "thumb" }), /* @__PURE__ */ jsxs("div", {
1408
- className: "meta",
1409
- children: [/* @__PURE__ */ jsx("strong", { children: labels.retakeHint }), /* @__PURE__ */ jsx("span", { children: "…" })]
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",
1904
+ children: [
1905
+ /* @__PURE__ */ jsx(PencilIcon, {}),
1906
+ /* @__PURE__ */ jsx("span", {
1907
+ className: "attachment-caption-label",
1908
+ children: labels.screenshot
1909
+ }),
1910
+ multiple && /* @__PURE__ */ jsx("span", { children: number }),
1911
+ draft.annotations.length > 0 && /* @__PURE__ */ jsx("span", {
1912
+ className: "annotation-count",
1913
+ children: draft.annotations.length
1914
+ })
1915
+ ]
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
+ })]
1410
1940
  })]
1411
1941
  });
1412
- if (!state.capture) return /* @__PURE__ */ jsxs("button", {
1413
- className: "ghost-btn",
1414
- onClick: () => state.takeCapture(),
1415
- type: "button",
1416
- children: [/* @__PURE__ */ jsx(CameraIcon, {}), labels.attachScreenshot]
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
+ })]
1417
1974
  });
1418
- const annotationCount = state.annotations.length;
1975
+ }
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);
1419
1984
  return /* @__PURE__ */ jsxs("div", {
1420
- className: "card",
1421
- children: [
1422
- /* @__PURE__ */ jsx("div", {
1423
- className: "thumb",
1424
- children: /* @__PURE__ */ jsx("img", {
1425
- alt: "",
1426
- height: state.capture.height,
1427
- src: state.capture.objectUrl,
1428
- width: state.capture.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
+ })]
1429
2012
  })
1430
- }),
1431
- /* @__PURE__ */ jsxs("div", {
1432
- className: "meta",
1433
- children: [/* @__PURE__ */ jsx("strong", {
1434
- className: "truncate",
1435
- children: labels.retakeHint
1436
- }), /* @__PURE__ */ jsxs("span", {
1437
- className: "truncate",
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",
1438
2040
  children: [
1439
- state.capture.width,
1440
- "×",
1441
- state.capture.height,
1442
- annotationCount > 0 ? ` · ${annotationCount} marks` : ""
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
+ })
1443
2066
  ]
1444
- })]
1445
- }),
1446
- /* @__PURE__ */ jsxs("div", {
1447
- className: "actions",
1448
- children: [
1449
- /* @__PURE__ */ jsx("button", {
1450
- "aria-label": labels.annotateHint,
1451
- className: "icon-btn",
1452
- onClick: () => state.setStep("annotate"),
1453
- title: labels.annotateHint,
1454
- type: "button",
1455
- children: /* @__PURE__ */ jsx(PencilIcon, {})
1456
- }),
1457
- /* @__PURE__ */ jsx("button", {
1458
- "aria-label": labels.recapture,
1459
- className: "icon-btn",
1460
- onClick: () => state.takeCapture(),
1461
- title: labels.recapture,
1462
- type: "button",
1463
- children: /* @__PURE__ */ jsx(CameraIcon, {})
1464
- }),
1465
- /* @__PURE__ */ jsx("button", {
1466
- "aria-label": labels.removeScreenshot,
1467
- className: "icon-btn",
1468
- onClick: state.removeCapture,
1469
- title: labels.removeScreenshot,
1470
- type: "button",
1471
- children: /* @__PURE__ */ jsx(TrashIcon, {})
1472
- })
1473
- ]
1474
- })
1475
- ]
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
+ })]
1476
2091
  });
1477
2092
  }
1478
- /** Shows the reporter exactly what will be sent about the element they picked. */
1479
- function Selection({ labels, state }) {
1480
- const { elementCapture, selection } = state;
1481
- if (!selection) return /* @__PURE__ */ jsxs("button", {
1482
- className: "ghost-btn",
1483
- onClick: () => state.setStep("picking"),
1484
- type: "button",
1485
- children: [/* @__PURE__ */ jsx(TargetIcon, {}), labels.pickElement]
1486
- });
1487
- const component = selection.componentStack[0];
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 }) {
1488
2101
  return /* @__PURE__ */ jsxs("div", {
1489
- className: "card",
1490
- children: [
1491
- /* @__PURE__ */ jsx("div", {
1492
- className: "thumb contain",
1493
- children: elementCapture && /* @__PURE__ */ jsx("img", {
1494
- alt: "",
1495
- height: elementCapture.height,
1496
- src: elementCapture.objectUrl,
1497
- width: elementCapture.width
1498
- })
1499
- }),
1500
- /* @__PURE__ */ jsxs("div", {
1501
- className: "meta",
1502
- children: [/* @__PURE__ */ jsx("strong", {
1503
- className: "truncate",
1504
- children: component ? `<${component}>` : selection.label
1505
- }), /* @__PURE__ */ jsx("span", {
1506
- className: "truncate",
1507
- children: selection.region ?? selection.sourceLocation ?? selection.selector
1508
- })]
1509
- }),
1510
- /* @__PURE__ */ jsxs("div", {
1511
- className: "actions",
1512
- children: [/* @__PURE__ */ jsx("button", {
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", {
1513
2133
  "aria-label": labels.pickElement,
1514
2134
  className: "icon-btn",
2135
+ disabled: state.isEditingDisabled || state.isCapturing,
1515
2136
  onClick: () => state.setStep("picking"),
1516
2137
  title: labels.pickElement,
1517
2138
  type: "button",
1518
2139
  children: /* @__PURE__ */ jsx(TargetIcon, {})
1519
- }), /* @__PURE__ */ jsx("button", {
1520
- "aria-label": labels.cancel,
1521
- className: "icon-btn",
1522
- onClick: state.clearSelection,
1523
- title: labels.cancel,
1524
- type: "button",
1525
- children: /* @__PURE__ */ jsx(CloseIcon, {})
1526
- })]
1527
- })
1528
- ]
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
+ })]
1529
2175
  });
1530
2176
  }
1531
- function FeedbackPanel({ categories, labels, state }) {
1532
- const messageRef = useRef(null);
2177
+ //#endregion
2178
+ //#region src/feedback/ui/panel.tsx
2179
+ function FeedbackPanel({ options, labels, state }) {
2180
+ const panelRef = useRef(null);
1533
2181
  useEffect(() => {
1534
- messageRef.current?.focus();
2182
+ panelRef.current?.focus();
1535
2183
  }, []);
1536
- if (state.step === "success") return /* @__PURE__ */ jsx("section", {
2184
+ if (state.step === "success") return /* @__PURE__ */ jsxs("section", {
1537
2185
  "aria-live": "polite",
1538
2186
  className: "panel",
1539
- children: /* @__PURE__ */ jsxs("div", {
1540
- className: "done",
2187
+ children: [/* @__PURE__ */ jsxs("div", {
2188
+ className: "done success-panel glass",
1541
2189
  children: [
1542
2190
  /* @__PURE__ */ jsx(CheckIcon, {}),
1543
2191
  /* @__PURE__ */ jsx("h2", { children: labels.successTitle }),
1544
2192
  /* @__PURE__ */ jsx("p", { children: labels.successMessage })
1545
2193
  ]
1546
- })
2194
+ }), /* @__PURE__ */ jsx("div", {
2195
+ className: "composer-toolbar",
2196
+ children: options.floatingControls
2197
+ })]
1547
2198
  });
1548
2199
  return /* @__PURE__ */ jsxs("section", {
1549
2200
  "aria-label": labels.title,
1550
2201
  className: "panel",
1551
- children: [
1552
- /* @__PURE__ */ jsxs("header", {
1553
- className: "head",
1554
- children: [
1555
- /* @__PURE__ */ jsx("h2", { children: labels.title }),
1556
- /* @__PURE__ */ jsx("span", { className: "spacer" }),
1557
- /* @__PURE__ */ jsx("button", {
1558
- "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,
1559
2416
  className: "icon-btn",
1560
- onClick: state.close,
2417
+ onClick: minimize,
2418
+ title: labels.minimize,
1561
2419
  type: "button",
1562
- children: /* @__PURE__ */ jsx(CloseIcon, {})
1563
- })
1564
- ]
1565
- }),
1566
- /* @__PURE__ */ jsxs("form", {
1567
- className: "body",
1568
- onSubmit: (event) => {
1569
- event.preventDefault();
1570
- state.submit();
1571
- },
1572
- children: [
1573
- categories.length > 1 && /* @__PURE__ */ jsx("fieldset", {
1574
- className: "segmented",
1575
- children: categories.map((value) => {
1576
- const { icon: Icon, labelKey } = CATEGORY_META[value];
1577
- return /* @__PURE__ */ jsxs("button", {
1578
- "aria-pressed": state.category === value,
1579
- onClick: () => state.setCategory(value),
1580
- type: "button",
1581
- children: [/* @__PURE__ */ jsx(Icon, {}), labels[labelKey]]
1582
- }, value);
1583
- })
1584
- }),
1585
- /* @__PURE__ */ jsx("textarea", {
1586
- "aria-label": labels.descriptionPlaceholder,
1587
- maxLength: 4e3,
1588
- onChange: (event) => state.setMessage(event.target.value),
1589
- placeholder: labels.descriptionPlaceholder,
1590
- ref: messageRef,
1591
- value: state.message
1592
- }),
1593
- /* @__PURE__ */ jsxs("div", {
1594
- className: "attachments",
1595
- children: [/* @__PURE__ */ jsx(Attachment, {
1596
- labels,
1597
- state
1598
- }), /* @__PURE__ */ jsx(Selection, {
1599
- labels,
1600
- state
1601
- })]
1602
- }),
1603
- state.isAnonymous && /* @__PURE__ */ jsx("input", {
1604
- "aria-label": labels.emailLabel,
1605
- autoComplete: "email",
1606
- onChange: (event) => state.setEmail(event.target.value),
1607
- placeholder: labels.emailPlaceholder,
1608
- type: "email",
1609
- value: state.email
1610
- }),
1611
- /* @__PURE__ */ jsx("div", {
1612
- "aria-hidden": "true",
1613
- className: "hp",
1614
- children: /* @__PURE__ */ jsx("input", {
1615
- autoComplete: "off",
1616
- name: "company-website",
1617
- onChange: (event) => state.setHoneypot(event.target.value),
1618
- tabIndex: -1,
1619
- value: state.honeypot
1620
- })
1621
- }),
1622
- state.error && /* @__PURE__ */ jsx("p", {
1623
- className: "error",
1624
- children: state.error
1625
- }),
1626
- /* @__PURE__ */ jsx("button", {
1627
- className: "submit",
1628
- disabled: state.isSubmitting || !state.message.trim(),
1629
- type: "submit",
1630
- children: state.isSubmitting ? /* @__PURE__ */ jsx("span", { className: "spinner" }) : labels.submit
1631
- })
1632
- ]
1633
- }),
1634
- /* @__PURE__ */ jsxs("footer", {
1635
- className: "footer",
1636
- "data-dismissible": state.canDismiss,
1637
- children: [state.canDismiss && /* @__PURE__ */ jsx("button", {
1638
- className: "dismiss-btn",
1639
- onClick: state.dismiss,
1640
- type: "button",
1641
- children: labels.dismissForDays.replace("{days}", String(state.dismissForDays))
1642
- }), /* @__PURE__ */ jsxs("span", { children: [
1643
- "Powered by",
1644
- " ",
1645
- /* @__PURE__ */ jsx("a", {
1646
- href: "https://reflet.app",
1647
- rel: "noopener noreferrer",
1648
- target: "_blank",
1649
- children: "Reflet"
1650
- }),
1651
- /* @__PURE__ */ jsx("span", {
1652
- className: "version-info",
1653
- role: "note",
1654
- title: `Reflet SDK ${SDK_VERSION}`,
1655
- children: /* @__PURE__ */ jsx(InfoIcon, {})
1656
- })
1657
- ] })]
1658
- })
1659
- ]
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
+ })]
1660
2434
  });
1661
2435
  }
1662
2436
  //#endregion
@@ -1763,29 +2537,30 @@ function ElementPicker({ hint, onCancel, onPick }) {
1763
2537
  }
1764
2538
  //#endregion
1765
2539
  //#region src/feedback/ui/styles.ts
1766
- /**
1767
- * Styles for the feedback widget. Injected into a shadow root, so nothing here
1768
- * leaks into the host app and nothing from the host app leaks in.
1769
- */
1770
2540
  const WIDGET_STYLES = `
1771
2541
  :host {
2542
+ --rf-offset: 20px;
1772
2543
  --rf-bg: #ffffff;
1773
2544
  --rf-bg-subtle: rgb(55 53 47 / 4%);
1774
2545
  --rf-bg-hover: rgb(55 53 47 / 7%);
1775
2546
  --rf-border: rgb(55 53 47 / 9%);
1776
2547
  --rf-text: #37352f;
1777
- --rf-text-muted: rgb(55 53 47 / 55%);
2548
+ --rf-text-muted: rgb(55 53 47 / 68%);
1778
2549
  --rf-primary: #37352f;
1779
2550
  --rf-primary-text: #ffffff;
1780
2551
  --rf-accent: #2383e2;
1781
2552
  --rf-danger: #d44c47;
1782
2553
  --rf-success: #448361;
1783
- --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%);
1784
2560
  --rf-radius-sm: 8px;
1785
2561
  --rf-shadow-inset: inset 0 0 0 1px var(--rf-border);
1786
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%);
1787
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%);
1788
- --rf-ring: 0 0 0 3px color-mix(in srgb, var(--rf-accent) 20%, transparent);
1789
2564
  --rf-ease: cubic-bezier(0.16, 1, 0.3, 1);
1790
2565
  color-scheme: light;
1791
2566
  all: initial;
@@ -1794,349 +2569,258 @@ const WIDGET_STYLES = `
1794
2569
 
1795
2570
  :host([data-theme="dark"]) {
1796
2571
  --rf-bg: #202020;
1797
- --rf-bg-subtle: rgb(255 255 255 / 5%);
1798
- --rf-bg-hover: rgb(255 255 255 / 9%);
1799
- --rf-border: rgb(255 255 255 / 9%);
1800
- --rf-text: #ebeae8;
1801
- --rf-text-muted: rgb(255 255 255 / 46%);
1802
- --rf-primary: #f1efec;
1803
- --rf-primary-text: #1c1c1b;
1804
- --rf-accent: #529cca;
1805
- --rf-danger: #eb5757;
1806
- --rf-success: #4dab74;
1807
- --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%);
1808
- --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%);
1809
- color-scheme: dark;
1810
- }
1811
-
1812
- *, *::before, *::after { box-sizing: border-box; }
1813
-
1814
- .root {
1815
- position: fixed;
1816
- z-index: 2147483000;
1817
- display: flex;
1818
- flex-direction: column;
1819
- gap: 10px;
1820
- font-family: inherit;
1821
- font-size: 13.5px;
1822
- line-height: 1.45;
1823
- color: var(--rf-text);
1824
- }
1825
-
1826
- .root[data-position$="right"] { right: var(--rf-offset); align-items: flex-end; }
1827
- .root[data-position$="left"] { left: var(--rf-offset); align-items: flex-start; }
1828
- .root[data-position^="bottom"] { bottom: var(--rf-offset); }
1829
- .root[data-position^="top"] { top: var(--rf-offset); flex-direction: column-reverse; }
1830
-
1831
- button {
1832
- font: inherit;
1833
- color: inherit;
1834
- margin: 0;
1835
- cursor: pointer;
1836
- border: 0;
1837
- background: none;
1838
- }
1839
-
1840
- :focus-visible { outline: none; box-shadow: var(--rf-ring); }
1841
-
1842
- .launcher {
1843
- display: inline-flex;
1844
- align-items: center;
1845
- gap: 6px;
1846
- height: 32px;
1847
- padding: 0 11px;
1848
- border-radius: var(--rf-radius-sm);
1849
- background: var(--rf-primary);
1850
- color: var(--rf-primary-text);
1851
- font-size: 13px;
1852
- font-weight: 500;
1853
- letter-spacing: -0.005em;
1854
- box-shadow: var(--rf-shadow-low);
1855
- transition: transform 240ms var(--rf-ease), box-shadow 240ms var(--rf-ease), opacity 160ms var(--rf-ease);
1856
- }
1857
- .launcher:hover { transform: translateY(-1px); box-shadow: var(--rf-shadow); }
1858
- .launcher:active { transform: translateY(0) scale(0.98); }
1859
-
1860
- .launcher-icon { position: relative; width: 15px; height: 15px; flex: none; }
1861
- .launcher-icon svg { position: absolute; inset: 0; width: 100%; height: 100%; transition: opacity 200ms var(--rf-ease), transform 320ms var(--rf-ease); }
1862
- .launcher-icon .on-close { opacity: 0; transform: rotate(-90deg) scale(0.6); }
1863
- .launcher[data-open="true"] .on-open { opacity: 0; transform: rotate(90deg) scale(0.6); }
1864
- .launcher[data-open="true"] .on-close { opacity: 1; transform: none; }
1865
-
1866
- .panel {
1867
- width: min(352px, calc(100vw - 24px));
1868
- max-height: min(600px, calc(100vh - 96px));
1869
- display: flex;
1870
- flex-direction: column;
1871
- overflow: hidden;
1872
- background: var(--rf-bg);
1873
- border-radius: var(--rf-radius);
1874
- box-shadow: var(--rf-shadow);
1875
- animation: rf-in 220ms var(--rf-ease);
1876
- }
1877
- .root[data-position^="bottom"] .panel { transform-origin: bottom center; }
1878
- .root[data-position^="top"] .panel { transform-origin: top center; animation-name: rf-in-top; }
1879
-
1880
- @keyframes rf-in {
1881
- from { opacity: 0; transform: translateY(6px) scale(0.985); }
1882
- }
1883
- @keyframes rf-in-top {
1884
- from { opacity: 0; transform: translateY(-6px) scale(0.985); }
1885
- }
1886
- @keyframes rf-fade { from { opacity: 0; } }
1887
-
1888
- @media (prefers-reduced-motion: reduce) {
1889
- *, *::before, *::after { animation: none !important; transition: none !important; }
1890
- }
1891
-
1892
- .head {
1893
- display: flex;
1894
- align-items: center;
1895
- gap: 8px;
1896
- padding: 12px 10px 4px 14px;
1897
- }
1898
- .head h2 { margin: 0; font-size: 13.5px; font-weight: 600; letter-spacing: -0.01em; }
1899
- .head .spacer { flex: 1; }
1900
-
1901
- .icon-btn {
1902
- display: inline-flex;
1903
- align-items: center;
1904
- justify-content: center;
1905
- width: 26px;
1906
- height: 26px;
1907
- flex: none;
1908
- border-radius: 6px;
1909
- color: var(--rf-text-muted);
1910
- transition: background 120ms var(--rf-ease), color 120ms var(--rf-ease);
1911
- }
1912
- .icon-btn:hover { background: var(--rf-bg-hover); color: var(--rf-text); }
1913
-
1914
- .body { display: flex; flex-direction: column; gap: 8px; padding: 6px 14px 14px; overflow-y: auto; }
1915
-
1916
- .segmented {
1917
- border: 0;
1918
- margin: 0;
1919
- min-inline-size: 0;
1920
- display: grid;
1921
- grid-auto-flow: column;
1922
- grid-auto-columns: 1fr;
1923
- gap: 2px;
1924
- padding: 2px;
1925
- background: var(--rf-bg-subtle);
1926
- border-radius: var(--rf-radius-sm);
1927
- }
1928
- .segmented button {
1929
- display: flex;
1930
- align-items: center;
1931
- justify-content: center;
1932
- gap: 5px;
1933
- height: 26px;
1934
- border-radius: 6px;
1935
- font-size: 12.5px;
1936
- font-weight: 500;
1937
- color: var(--rf-text-muted);
1938
- transition: background 160ms var(--rf-ease), color 160ms var(--rf-ease);
1939
- }
1940
- .segmented button:hover { color: var(--rf-text); }
1941
- .segmented button[aria-pressed="true"] {
1942
- background: var(--rf-bg);
1943
- color: var(--rf-text);
1944
- box-shadow: var(--rf-shadow-low);
1945
- }
1946
-
1947
- textarea, input[type="email"] {
1948
- width: 100%;
1949
- padding: 8px 10px;
1950
- font: inherit;
1951
- color: var(--rf-text);
1952
- background: var(--rf-bg);
1953
- border: 1px solid var(--rf-border);
1954
- border-radius: var(--rf-radius-sm);
1955
- transition: border-color 160ms var(--rf-ease), box-shadow 160ms var(--rf-ease);
1956
- }
1957
- textarea { min-height: 84px; max-height: 220px; resize: none; }
1958
- textarea::placeholder, input::placeholder { color: var(--rf-text-muted); }
1959
- textarea:focus, input:focus { outline: none; border-color: color-mix(in srgb, var(--rf-accent) 55%, transparent); box-shadow: var(--rf-ring); }
1960
-
1961
- .attachments { display: flex; flex-wrap: wrap; gap: 6px; }
1962
-
1963
- .card {
1964
- flex: 1 0 100%;
1965
- display: flex;
1966
- align-items: center;
1967
- gap: 10px;
1968
- min-width: 0;
1969
- padding: 6px;
1970
- border-radius: 10px;
1971
- background: var(--rf-bg-subtle);
1972
- animation: rf-fade 200ms var(--rf-ease);
1973
- }
1974
- .thumb {
1975
- position: relative;
1976
- flex: 0 0 60px;
1977
- height: 42px;
1978
- overflow: hidden;
1979
- border-radius: 6px;
1980
- background: var(--rf-bg);
1981
- box-shadow: var(--rf-shadow-inset);
1982
- }
1983
- .thumb img { width: 100%; height: 100%; object-fit: cover; object-position: top center; display: block; }
1984
- .thumb.contain img { object-fit: contain; object-position: center; }
1985
- .card .meta { display: flex; flex-direction: column; justify-content: center; gap: 1px; min-width: 0; }
1986
- .card .meta strong { font-size: 12.5px; font-weight: 550; }
1987
- .card .meta span { font-size: 11.5px; color: var(--rf-text-muted); }
1988
- .card .actions { display: flex; gap: 2px; margin-left: auto; align-items: center; }
1989
- .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1990
- .card code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; }
1991
-
1992
- .ghost-btn {
1993
- flex: 0 0 auto;
1994
- display: inline-flex;
1995
- align-items: center;
1996
- gap: 6px;
1997
- height: 30px;
1998
- padding: 0 10px;
1999
- font-size: 12.5px;
2000
- font-weight: 500;
2001
- color: var(--rf-text-muted);
2002
- border-radius: var(--rf-radius-sm);
2003
- box-shadow: var(--rf-shadow-inset);
2004
- 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;
2005
2588
  }
2006
- .ghost-btn:hover { background: var(--rf-bg-subtle); color: var(--rf-text); }
2007
2589
 
2008
- .submit {
2009
- display: inline-flex;
2010
- align-items: center;
2011
- justify-content: center;
2012
- gap: 8px;
2013
- width: 100%;
2014
- height: 32px;
2015
- margin-top: 2px;
2016
- font-size: 13px;
2017
- font-weight: 550;
2018
- color: var(--rf-primary-text);
2019
- background: var(--rf-primary);
2020
- border-radius: var(--rf-radius-sm);
2021
- transition: opacity 160ms var(--rf-ease), transform 160ms var(--rf-ease);
2022
- }
2023
- .submit:hover:not(:disabled) { opacity: 0.88; }
2024
- .submit:active:not(:disabled) { transform: scale(0.99); }
2025
- .submit:disabled { opacity: 0.35; cursor: not-allowed; }
2590
+ *, *::before, *::after { box-sizing: border-box; }
2026
2591
 
2027
- .error { margin: 0; font-size: 12px; color: var(--rf-danger); }
2028
2592
 
2029
- .footer {
2030
- min-height: 34px;
2031
- padding: 0 14px;
2032
- border-top: 1px solid var(--rf-border);
2593
+ .root {
2594
+ position: fixed;
2595
+ z-index: 2147483000;
2033
2596
  display: flex;
2034
- align-items: center;
2035
- justify-content: center;
2036
- gap: 12px;
2037
- font-size: 11px;
2038
- 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);
2039
2601
  }
2040
- .footer[data-dismissible="true"] { justify-content: space-between; }
2041
- .footer a { color: inherit; text-decoration: none; font-weight: 550; transition: color 120ms var(--rf-ease); }
2042
- .footer a:hover { color: var(--rf-text); }
2043
- .version-info { display: inline-flex; vertical-align: -2px; margin-left: 4px; opacity: 0.55; cursor: help; transition: opacity 120ms var(--rf-ease); }
2044
- .version-info:hover { opacity: 1; }
2045
- .dismiss-btn {
2046
- align-self: stretch;
2047
- color: inherit;
2048
- font-size: 11px;
2049
- 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);
2050
2620
  }
2051
- .dismiss-btn:hover { color: var(--rf-text); }
2052
-
2053
- .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; }
2054
2643
  .done svg { color: var(--rf-success); }
2055
2644
  .done h2 { margin: 0; font-size: 14px; font-weight: 600; }
2056
- .done p { margin: 0; font-size: 12.5px; color: var(--rf-text-muted); }
2057
-
2058
- .spinner {
2059
- width: 14px;
2060
- height: 14px;
2061
- border: 2px solid currentColor;
2062
- border-right-color: transparent;
2063
- border-radius: 50%;
2064
- opacity: 0.6;
2065
- animation: rf-spin 700ms linear infinite;
2066
- }
2067
- @keyframes rf-spin { to { transform: rotate(360deg); } }
2068
-
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; }
2069
2647
  .hp { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
2070
-
2071
- .overlay {
2072
- 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;
2073
2664
  inset: 0;
2074
- z-index: 2147483001;
2075
- display: flex;
2076
- flex-direction: column;
2077
- background: rgb(15 15 15 / 76%);
2078
- backdrop-filter: blur(3px);
2079
- animation: rf-fade 180ms var(--rf-ease);
2665
+ z-index: -1;
2666
+ border-radius: inherit;
2667
+ background: var(--rf-bg);
2668
+ pointer-events: none;
2080
2669
  }
2081
-
2082
- .editor { flex: 1; display: flex; align-items: center; justify-content: center; padding: 24px 24px 0; min-height: 0; }
2083
- .editor canvas {
2084
- max-width: 100%;
2085
- max-height: 100%;
2086
- border-radius: 10px;
2087
- box-shadow: 0 20px 60px -16px rgb(0 0 0 / 70%);
2088
- cursor: crosshair;
2089
- 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
+ }
2090
2676
  }
2091
-
2092
- .toolbar {
2093
- display: flex;
2094
- align-items: center;
2095
- justify-content: center;
2096
- gap: 6px;
2097
- flex-wrap: wrap;
2098
- 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; }
2099
2679
  }
2100
- .toolbar .group {
2101
- display: flex;
2102
- align-items: center;
2103
- gap: 2px;
2104
- padding: 4px;
2105
- background: var(--rf-bg);
2106
- border-radius: 10px;
2107
- 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; }
2108
2761
  }
2109
- .tool {
2110
- display: inline-flex;
2111
- align-items: center;
2112
- justify-content: center;
2113
- width: 30px;
2114
- height: 30px;
2115
- border-radius: 7px;
2116
- color: var(--rf-text-muted);
2117
- 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; }
2118
2822
  }
2119
- .tool:hover { background: var(--rf-bg-hover); color: var(--rf-text); }
2120
- .tool[aria-pressed="true"] { background: var(--rf-bg-hover); color: var(--rf-text); }
2121
- .swatch {
2122
- width: 20px;
2123
- height: 20px;
2124
- border-radius: 50%;
2125
- border: 2px solid transparent;
2126
- transition: transform 200ms var(--rf-ease), border-color 160ms var(--rf-ease);
2127
- }
2128
- .swatch[aria-pressed="true"] { border-color: var(--rf-text); transform: scale(1.15); }
2129
- .toolbar .done-btn {
2130
- height: 30px;
2131
- padding: 0 13px;
2132
- font-size: 13px;
2133
- font-weight: 550;
2134
- border-radius: 7px;
2135
- color: var(--rf-primary-text);
2136
- background: var(--rf-primary);
2137
- transition: opacity 160ms var(--rf-ease);
2138
- }
2139
- .toolbar .done-btn:hover { opacity: 0.88; }
2823
+
2140
2824
 
2141
2825
  .picker-box {
2142
2826
  position: fixed;
@@ -2193,26 +2877,9 @@ textarea:focus, input:focus { outline: none; border-color: color-mix(in srgb, va
2193
2877
  box-shadow: var(--rf-shadow-inset);
2194
2878
  }
2195
2879
 
2196
- @media (pointer: coarse) {
2197
- .icon-btn, .tool, .swatch {
2198
- width: 44px;
2199
- height: 44px;
2200
- }
2201
-
2202
- .launcher, .segmented button, .ghost-btn, .submit, .toolbar .done-btn {
2203
- min-height: 44px;
2204
- }
2205
-
2206
- .dismiss-btn, .footer a {
2207
- display: inline-flex;
2208
- min-height: 44px;
2209
- align-items: center;
2210
- }
2211
2880
 
2212
- .footer a {
2213
- min-width: 44px;
2214
- justify-content: center;
2215
- }
2881
+ @media (prefers-reduced-motion: reduce) {
2882
+ *, *::before, *::after { animation: none !important; transition: none !important; }
2216
2883
  }
2217
2884
  `;
2218
2885
  //#endregion
@@ -2462,10 +3129,55 @@ async function captureElementCloseUp(element) {
2462
3129
  }
2463
3130
  }
2464
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
2465
3178
  //#region src/feedback/core/submit.ts
2466
3179
  const MAX_TITLE_LENGTH = 100;
2467
3180
  const NEWLINE = /\r?\n/;
2468
- /** First line of the message, cut on a word boundary so titles stay readable. */
2469
3181
  function deriveTitle(message) {
2470
3182
  const firstLine = message.split(NEWLINE).map((line) => line.trim()).find(Boolean) ?? "";
2471
3183
  if (firstLine.length <= MAX_TITLE_LENGTH) return firstLine;
@@ -2479,64 +3191,31 @@ function buildDescription(params) {
2479
3191
  const contact = isAnonymous && email ? `\n\n---\nContact: ${email}` : "";
2480
3192
  return `[${label}] ${message.trim()}${contact}`;
2481
3193
  }
2482
- async function upload(transport, image) {
2483
- const { uploadUrl } = await transport.getScreenshotUploadUrl();
2484
- return await transport.uploadImage(uploadUrl, image);
2485
- }
2486
- async function attachScreenshot(transport, feedbackId, submission) {
2487
- const { annotated, annotations, context, screenshot } = submission;
2488
- if (!screenshot) return false;
2489
- const storageId = await upload(transport, screenshot);
2490
- const annotatedStorageId = annotated ? await upload(transport, annotated) : void 0;
2491
- const wireAnnotations = toWireAnnotations(annotations);
2492
- await transport.saveScreenshot({
2493
- annotatedStorageId,
2494
- annotations: wireAnnotations.length > 0 ? wireAnnotations : void 0,
2495
- captureSource: "widget",
2496
- feedbackId,
2497
- filename: "screenshot.png",
2498
- height: screenshot.height,
2499
- mimeType: screenshot.mimeType,
2500
- pageUrl: context.url,
2501
- size: screenshot.blob.size,
2502
- storageId,
2503
- width: screenshot.width
2504
- });
2505
- return true;
2506
- }
2507
- async function attachElement(transport, feedbackId, submission) {
2508
- const { context, element } = submission;
2509
- if (!element) return;
2510
- await transport.saveScreenshot({
2511
- captureSource: "element",
2512
- feedbackId,
2513
- filename: "element.png",
2514
- height: element.height,
2515
- mimeType: element.mimeType,
2516
- pageUrl: context.url,
2517
- size: element.blob.size,
2518
- storageId: await upload(transport, element),
2519
- width: element.width
2520
- });
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
+ };
2521
3204
  }
2522
- /**
2523
- * Creates the feedback first, then attaches the capture. A failed upload is
2524
- * reported back but never discards what the user wrote.
2525
- */
2526
- async function submitWidgetFeedback(transport, submission) {
2527
- const title = deriveTitle(submission.message);
2528
- if (!title) throw new Error("Please describe your feedback before sending it.");
3205
+ async function createReportWithScreenshots(transport, submission, pendingScreenshots) {
2529
3206
  const { feedbackId } = await transport.create({
2530
3207
  context: submission.context,
2531
3208
  description: buildDescription(submission),
2532
- title
3209
+ title: deriveTitle(submission.message)
2533
3210
  });
2534
- const screenshotSaved = await attachScreenshot(transport, feedbackId, submission).catch(() => false);
2535
- await attachElement(transport, feedbackId, submission).catch(() => void 0);
2536
- return {
3211
+ return attachPreparedScreenshots(transport, {
2537
3212
  feedbackId,
2538
- screenshotSaved
2539
- };
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));
2540
3219
  }
2541
3220
  function isStorageResponse(value) {
2542
3221
  return typeof value === "object" && value !== null && typeof Reflect.get(value, "storageId") === "string";
@@ -2560,6 +3239,220 @@ function createFeedbackTransport(client) {
2560
3239
  };
2561
3240
  }
2562
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
2563
3456
  //#region src/feedback/ui/use-capture-sync.ts
2564
3457
  const RESIZE_DEBOUNCE = 400;
2565
3458
  /**
@@ -2609,7 +3502,6 @@ function useCaptureSync(enabled, refresh) {
2609
3502
  const SUCCESS_CLOSE_DELAY = 2400;
2610
3503
  const IS_APPLE = /Mac|iPhone|iPad/;
2611
3504
  const MILLISECONDS_PER_DAY = 864e5;
2612
- const DISMISSAL_STORAGE_PREFIX = "reflet-feedback-dismissed";
2613
3505
  function readDismissedUntil(key) {
2614
3506
  if (typeof window === "undefined") return 0;
2615
3507
  try {
@@ -2626,10 +3518,6 @@ function writeDismissedUntil(key, until) {
2626
3518
  window.localStorage.setItem(key, String(until));
2627
3519
  } catch {}
2628
3520
  }
2629
- /**
2630
- * Matches shortcuts written as `alt+f` or `mod+shift+k`, where `mod` is the
2631
- * platform's command key.
2632
- */
2633
3521
  function matchesHotkey(event, hotkey, platform = typeof navigator === "undefined" ? "" : navigator.platform) {
2634
3522
  const parts = hotkey.toLowerCase().split("+").map((part) => part.trim());
2635
3523
  const key = parts.at(-1);
@@ -2644,48 +3532,27 @@ function matchesHotkey(event, hotkey, platform = typeof navigator === "undefined
2644
3532
  };
2645
3533
  return event.altKey === expected.alt && event.ctrlKey === expected.ctrl && event.metaKey === expected.meta && event.shiftKey === expected.shift;
2646
3534
  }
2647
- /**
2648
- * A capture whose object URL is revoked the moment it is replaced or dropped —
2649
- * holding one without this leaks the blob for the lifetime of the page.
2650
- */
2651
- function useOwnedCapture() {
2652
- const [image, setImage] = useState(null);
2653
- const ref = useRef(null);
2654
- useEffect(() => {
2655
- ref.current = image;
2656
- }, [image]);
2657
- useEffect(() => () => releaseCapture(ref.current), []);
2658
- return [image, useCallback((next) => {
2659
- releaseCapture(ref.current);
2660
- setImage(next);
2661
- }, [])];
2662
- }
2663
3535
  function useWidgetState(props) {
2664
- const context = useContext(RefletContext);
2665
- const publicKey = props.publicKey ?? context?.publicKey;
2666
- const baseUrl = props.baseUrl ?? context?.baseUrl;
2667
- const user = props.user ?? context?.user;
2668
- const userToken = props.userToken ?? context?.userToken;
2669
- const isAnonymous = !(user || userToken);
2670
- const dismissForDays = props.dismissForDays !== void 0 && Number.isFinite(props.dismissForDays) && props.dismissForDays > 0 ? props.dismissForDays : null;
2671
- const dismissalKey = `${DISMISSAL_STORAGE_PREFIX}:${publicKey ?? "default"}:${user?.id ?? "anonymous"}`;
3536
+ const { client, defaultCategory, dismissalKey, dismissForDays, isAnonymous } = useWidgetConfig(props);
2672
3537
  const [isOpen, setIsOpen] = useState(false);
3538
+ const [annotationTrigger, setAnnotationTrigger] = useState(null);
2673
3539
  const [step, setStep] = useState("compose");
2674
- const [category, setCategory] = useState(props.defaultCategory ?? "bug");
3540
+ const [category, setCategory] = useState(defaultCategory);
2675
3541
  const [message, setMessage] = useState("");
2676
3542
  const [email, setEmail] = useState("");
2677
3543
  const [honeypot, setHoneypot] = useState("");
2678
- const [capture, replaceCapture] = useOwnedCapture();
2679
3544
  const [elementCapture, replaceElementCapture] = useOwnedCapture();
2680
- const [annotations, setAnnotations] = useState([]);
2681
3545
  const [selection, setSelection] = useState(null);
2682
- const [isCapturing, setIsCapturing] = useState(false);
2683
- const [isSubmitting, setIsSubmitting] = useState(false);
3546
+ const [isElementCapturing, setIsElementCapturing] = useState(false);
3547
+ const successTimer = useRef(null);
2684
3548
  const [error, setError] = useState(null);
2685
3549
  const [dismissedUntil, setDismissedUntil] = useState(() => readDismissedUntil(dismissalKey));
2686
3550
  const pickRef = useRef(0);
3551
+ useEffect(() => () => {
3552
+ pickRef.current++;
3553
+ if (successTimer.current) clearTimeout(successTimer.current);
3554
+ }, []);
2687
3555
  const consoleRef = useRef(null);
2688
- const frozenContext = useRef(null);
2689
3556
  useEffect(() => {
2690
3557
  setDismissedUntil(readDismissedUntil(dismissalKey));
2691
3558
  }, [dismissalKey]);
@@ -2698,33 +3565,45 @@ function useWidgetState(props) {
2698
3565
  recorder.stop();
2699
3566
  };
2700
3567
  }, [props.captureConsole]);
2701
- /** Frozen at the same instant as the screenshot, so shot and URL always match. */
2702
- const takeCapture = useCallback(() => {
2703
- const context = collectPageContext({ sdkVersion: SDK_VERSION });
2704
- setIsCapturing(true);
2705
- captureViewport().catch(() => null).then((image) => {
2706
- setIsCapturing(false);
2707
- replaceCapture(image);
2708
- frozenContext.current = image ? context : null;
2709
- });
2710
- }, [replaceCapture]);
2711
- 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);
2712
3588
  const reset = useCallback(() => {
3589
+ if (successTimer.current) clearTimeout(successTimer.current);
3590
+ setAnnotationTrigger(null);
3591
+ resetSubmission();
2713
3592
  pickRef.current++;
2714
- frozenContext.current = null;
2715
- replaceCapture(null);
3593
+ setIsElementCapturing(false);
3594
+ resetScreenshots();
2716
3595
  replaceElementCapture(null);
2717
- setAnnotations([]);
2718
3596
  setSelection(null);
2719
3597
  setMessage("");
2720
3598
  setEmail("");
2721
3599
  setHoneypot("");
2722
3600
  setError(null);
2723
3601
  setStep("compose");
2724
- setCategory(props.defaultCategory ?? "bug");
3602
+ setCategory(defaultCategory);
2725
3603
  }, [
2726
- props.defaultCategory,
2727
- replaceCapture,
3604
+ defaultCategory,
3605
+ resetSubmission,
3606
+ resetScreenshots,
2728
3607
  replaceElementCapture
2729
3608
  ]);
2730
3609
  const close = useCallback(() => {
@@ -2750,14 +3629,15 @@ function useWidgetState(props) {
2750
3629
  setIsOpen(true);
2751
3630
  setStep("compose");
2752
3631
  props.onOpen?.();
2753
- if (props.captureOnOpen !== false) takeCapture();
3632
+ if (props.captureOnOpen !== false) refreshAutomatic();
2754
3633
  }, [
2755
3634
  props.captureOnOpen,
2756
3635
  props.onOpen,
2757
- takeCapture
3636
+ refreshAutomatic
2758
3637
  ]);
2759
- /** Fresh pixels so the highlight maps onto the scroll position the element had. */
2760
3638
  const selectElement = useCallback(async (element) => {
3639
+ cancelPendingCapture();
3640
+ setIsElementCapturing(true);
2761
3641
  const picked = buildElementSelection(element);
2762
3642
  setSelection(picked);
2763
3643
  setStep("compose");
@@ -2769,89 +3649,58 @@ function useWidgetState(props) {
2769
3649
  releaseCapture(closeUp);
2770
3650
  return;
2771
3651
  }
2772
- 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);
2773
3661
  replaceElementCapture(closeUp);
2774
- frozenContext.current = fresh ? context : null;
2775
- setAnnotations(fresh ? [highlightFor(picked, fresh)] : []);
2776
- }, [replaceCapture, replaceElementCapture]);
3662
+ }, [
3663
+ cancelPendingCapture,
3664
+ captureFailed,
3665
+ storeScreenshot,
3666
+ replaceElementCapture
3667
+ ]);
2777
3668
  const clearSelection = useCallback(() => {
2778
3669
  pickRef.current++;
2779
3670
  replaceElementCapture(null);
2780
3671
  setSelection(null);
2781
- setAnnotations((previous) => previous.filter((item) => !item.id.startsWith("selection-")));
2782
- }, [replaceElementCapture]);
2783
- const removeCapture = useCallback(() => {
2784
- frozenContext.current = null;
2785
- replaceCapture(null);
2786
- setAnnotations([]);
2787
- }, [replaceCapture]);
2788
- const client = useMemo(() => publicKey ? new Reflet({
2789
- baseUrl,
2790
- publicKey,
2791
- user,
2792
- userToken
2793
- }) : null, [
2794
- baseUrl,
2795
- publicKey,
2796
- user,
2797
- userToken
2798
- ]);
2799
- const submit = useCallback(async () => {
3672
+ setIsElementCapturing(false);
3673
+ clearSelectionAnnotations();
3674
+ }, [clearSelectionAnnotations, replaceElementCapture]);
3675
+ const submit = () => {
2800
3676
  if (honeypot) {
2801
3677
  setStep("success");
2802
3678
  return;
2803
3679
  }
2804
- if (!client) {
2805
- setError("Reflet is missing a publicKey. Pass one to RefletFeedback or RefletProvider.");
2806
- return;
2807
- }
2808
- setIsSubmitting(true);
2809
- setError(null);
2810
- let annotated = null;
2811
- try {
2812
- annotated = capture ? await renderAnnotatedImage(capture, annotations) : null;
2813
- const result = await submitWidgetFeedback(createFeedbackTransport(client), {
2814
- annotated,
2815
- annotations,
2816
- category,
2817
- context: {
2818
- ...frozenContext.current ?? collectPageContext({ sdkVersion: "0.3.3" }),
2819
- consoleEvents: consoleRef.current?.events(),
2820
- metadata: props.metadata,
2821
- selection: selection ?? void 0
2822
- },
2823
- element: elementCapture,
2824
- email,
2825
- isAnonymous,
2826
- message,
2827
- screenshot: capture
2828
- });
2829
- setStep("success");
2830
- props.onSubmit?.({ feedbackId: result.feedbackId });
2831
- setTimeout(close, SUCCESS_CLOSE_DELAY);
2832
- } catch (submitError) {
2833
- setError(submitError instanceof Error ? submitError.message : "Something went wrong. Please try again.");
2834
- } finally {
2835
- releaseCapture(annotated);
2836
- setIsSubmitting(false);
2837
- }
2838
- }, [
2839
- annotations,
2840
- capture,
2841
- category,
2842
- client,
2843
- close,
2844
- elementCapture,
2845
- email,
2846
- honeypot,
2847
- isAnonymous,
2848
- message,
2849
- props.metadata,
2850
- props.onSubmit,
2851
- selection
2852
- ]);
3680
+ return submission.submit({
3681
+ category,
3682
+ context: {
3683
+ ...activeScreenshot?.context ?? collectPageContext({ sdkVersion: "0.3.5" }),
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
+ };
2853
3695
  return {
3696
+ activeScreenshot,
3697
+ annotateScreenshot: (id, trigger) => {
3698
+ setAnnotationTrigger(trigger);
3699
+ gallery.selectScreenshot(id);
3700
+ setStep("annotate");
3701
+ },
2854
3702
  annotations,
3703
+ annotationTrigger,
2855
3704
  canDismiss: dismissForDays !== null,
2856
3705
  capture,
2857
3706
  category,
@@ -2862,18 +3711,23 @@ function useWidgetState(props) {
2862
3711
  elementCapture,
2863
3712
  email,
2864
3713
  error,
3714
+ hasPendingAttachments: submission.hasPendingAttachments,
2865
3715
  honeypot,
2866
3716
  isAnonymous,
2867
3717
  isCapturing,
2868
3718
  isDismissed: dismissedUntil > Date.now(),
3719
+ isEditingDisabled: submission.isEditingDisabled,
2869
3720
  isOpen,
2870
- isSubmitting,
3721
+ isSubmitting: submission.isSubmitting,
2871
3722
  message,
2872
3723
  open,
2873
- removeCapture,
3724
+ pendingCapture: gallery.pendingCapture,
3725
+ removeCapture: gallery.removeCapture,
3726
+ retakeCapture: gallery.retakeCapture,
3727
+ screenshots: gallery.screenshots,
2874
3728
  selectElement,
2875
3729
  selection,
2876
- setAnnotations,
3730
+ setAnnotations: gallery.setAnnotations,
2877
3731
  setCategory,
2878
3732
  setEmail,
2879
3733
  setHoneypot,
@@ -2881,7 +3735,7 @@ function useWidgetState(props) {
2881
3735
  setStep,
2882
3736
  step,
2883
3737
  submit,
2884
- takeCapture
3738
+ takeCapture: gallery.takeCapture
2885
3739
  };
2886
3740
  }
2887
3741
  //#endregion
@@ -2891,14 +3745,8 @@ const DEFAULT_CATEGORIES = [
2891
3745
  "idea",
2892
3746
  "question"
2893
3747
  ];
2894
- const DEFAULT_OFFSET = 20;
2895
- /**
2896
- * Floating feedback button with a screenshot, annotation and element picker
2897
- * flow. Mount it once, anywhere below `RefletProvider` or with its own
2898
- * `publicKey`.
2899
- */
2900
3748
  function RefletFeedback(props) {
2901
- 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;
2902
3750
  const labels = {
2903
3751
  ...DEFAULT_WIDGET_LABELS,
2904
3752
  ...props.labels
@@ -2923,44 +3771,38 @@ function RefletFeedback(props) {
2923
3771
  isOpen,
2924
3772
  open
2925
3773
  ]);
2926
- useEffect(() => {
2927
- if (!isOpen || state.step !== "compose") return;
2928
- const onKeyDown = (event) => {
2929
- if (event.key === "Escape") close();
2930
- };
2931
- document.addEventListener("keydown", onKeyDown);
2932
- return () => document.removeEventListener("keydown", onKeyDown);
2933
- }, [
2934
- close,
2935
- isOpen,
2936
- state.step
2937
- ]);
2938
3774
  if (!enabled || state.isDismissed) return null;
2939
- const showPanel = isOpen && state.step !== "picking" && state.step !== "annotate";
2940
3775
  return /* @__PURE__ */ jsxs(ShadowPortal, {
2941
3776
  offset,
2942
3777
  primaryColor: props.primaryColor,
2943
3778
  theme,
2944
3779
  children: [
2945
- /* @__PURE__ */ jsxs("div", {
2946
- className: "root",
2947
- "data-position": position,
2948
- children: [showPanel && /* @__PURE__ */ jsx(FeedbackPanel, {
2949
- categories,
2950
- labels,
2951
- state
2952
- }), /* @__PURE__ */ jsx(Launcher, {
2953
- isOpen,
2954
- label: labels.trigger,
2955
- onClick: isOpen ? close : open
2956
- })]
3780
+ /* @__PURE__ */ jsx("div", {
3781
+ "aria-hidden": "true",
3782
+ className: "capture-halo",
3783
+ "data-active": state.isCapturing
2957
3784
  }),
2958
- isOpen && state.step === "annotate" && state.capture && /* @__PURE__ */ jsx(Annotator, {
2959
- annotations: state.annotations,
2960
- capture: state.capture,
3785
+ /* @__PURE__ */ jsx(FloatingWidget, {
2961
3786
  labels,
2962
- onChange: state.setAnnotations,
2963
- 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
2964
3806
  }),
2965
3807
  isOpen && state.step === "picking" && /* @__PURE__ */ jsx(ElementPicker, {
2966
3808
  hint: labels.pickElementHint,