@mhosaic/feedback 0.9.1 → 0.11.0

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.
@@ -1,3 +1,7 @@
1
+ import {
2
+ scrubCredentials
3
+ } from "./chunk-DKE6ELJ4.mjs";
4
+
1
5
  // src/api/client.ts
2
6
  var SCALAR_FIELDS = [
3
7
  "description",
@@ -33,9 +37,15 @@ function createApiClient(options) {
33
37
  if (payload.widget_version) {
34
38
  form.append("widget_version", payload.widget_version);
35
39
  }
40
+ const headers = {
41
+ Authorization: `Bearer ${options.apiKey}`
42
+ };
43
+ if (payload.synthetic) {
44
+ headers["X-Mhosaic-Capture-Source"] = "error-tracking";
45
+ }
36
46
  const response = await fetcher(`${endpoint}/api/feedback/v1/reports/`, {
37
47
  method: "POST",
38
- headers: { Authorization: `Bearer ${options.apiKey}` },
48
+ headers,
39
49
  body: form
40
50
  });
41
51
  if (!response.ok) {
@@ -128,7 +138,28 @@ function createApiClient(options) {
128
138
  }
129
139
 
130
140
  // src/capture/urlSanitizer.ts
131
- var SENSITIVE = /token|key|password|secret|auth|session|sig/i;
141
+ var SENSITIVE_NAME = /token|key|password|secret|auth|session|sig|credential|bearer|cookie/i;
142
+ var SENSITIVE_VALUE_PATTERNS = [
143
+ // JWT: three base64url segments separated by dots, header begins with eyJ.
144
+ /^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/,
145
+ // Mhosaic feedback keys (the project's own format).
146
+ /^(?:sk|pk)_proj_[A-Za-z0-9_-]{16,}$/,
147
+ // GitHub PATs (ghp/gho/ghu/ghs/ghr prefixes, 36+ chars).
148
+ /^gh[pousr]_[A-Za-z0-9]{30,}$/,
149
+ // Stripe live/test secret keys.
150
+ /^(?:sk|pk|rk|whsec)_(?:live|test)_[A-Za-z0-9]{16,}$/,
151
+ // AWS access key id.
152
+ /^AKIA[0-9A-Z]{12,}$/,
153
+ // Slack bot tokens.
154
+ /^xox[abprso]-[A-Za-z0-9-]{12,}$/,
155
+ // Generic high-entropy: hex 40+ chars (SHA-1, SHA-256) or long alnum 32+.
156
+ /^[a-f0-9]{40,}$/i,
157
+ // Long opaque base64-ish string (common for OAuth codes & session ids).
158
+ /^[A-Za-z0-9_-]{40,}$/
159
+ ];
160
+ function looksLikeCredential(value) {
161
+ return SENSITIVE_VALUE_PATTERNS.some((re) => re.test(value));
162
+ }
132
163
  function sanitizeUrl(url) {
133
164
  try {
134
165
  const isAbsolute = /^https?:\/\//i.test(url);
@@ -138,9 +169,14 @@ function sanitizeUrl(url) {
138
169
  const u = new URL(url, base);
139
170
  const clean = new URLSearchParams();
140
171
  u.searchParams.forEach((value, name) => {
141
- clean.set(name, SENSITIVE.test(name) ? "[redacted]" : value);
172
+ if (SENSITIVE_NAME.test(name) || looksLikeCredential(value)) {
173
+ clean.set(name, "[redacted]");
174
+ } else {
175
+ clean.set(name, value);
176
+ }
142
177
  });
143
178
  u.search = clean.toString();
179
+ u.hash = u.hash ? "#[redacted]" : "";
144
180
  return u.toString();
145
181
  } catch {
146
182
  return url;
@@ -152,7 +188,16 @@ function collectDevice() {
152
188
  const nav = navigator;
153
189
  const connection = nav.connection?.effectiveType;
154
190
  const deviceMemory = nav.deviceMemory;
155
- const referrer = document.referrer || void 0;
191
+ const rawReferrer = document.referrer || void 0;
192
+ const referrer = rawReferrer !== void 0 ? sanitizeUrl(rawReferrer) : void 0;
193
+ const sanitizedHere = sanitizeUrl(window.location.pathname + window.location.search);
194
+ let pathname;
195
+ try {
196
+ const parsed = new URL(sanitizedHere, window.location.origin);
197
+ pathname = parsed.pathname + parsed.search;
198
+ } catch {
199
+ pathname = window.location.pathname;
200
+ }
156
201
  return {
157
202
  viewport: { w: window.innerWidth, h: window.innerHeight, dpr: window.devicePixelRatio || 1 },
158
203
  screen: { w: window.screen.width, h: window.screen.height },
@@ -165,8 +210,12 @@ function collectDevice() {
165
210
  ...deviceMemory !== void 0 && { deviceMemory },
166
211
  hardwareConcurrency: nav.hardwareConcurrency,
167
212
  ...referrer !== void 0 && { referrer },
168
- title: document.title,
169
- pathname: window.location.pathname
213
+ // Hosts commonly stuff credentials / PII into page titles
214
+ // ("Inbox (3) — bob@x.com", "Reset password — token=abc"). Cap to a
215
+ // sane length and run through the same scrubber the console/error
216
+ // paths use.
217
+ title: scrubCredentials(document.title || "").slice(0, 200),
218
+ pathname
170
219
  };
171
220
  }
172
221
 
@@ -196,7 +245,8 @@ function installConsolePatch(buffer) {
196
245
  originals[level] = original;
197
246
  console[level] = function patched(...args) {
198
247
  try {
199
- const message = args.map(safeStringify).join(" ").slice(0, 2e3);
248
+ const rawMessage = args.map(safeStringify).join(" ");
249
+ const message = scrubCredentials(rawMessage).slice(0, 2e3);
200
250
  const entry = { level, message, ts: Date.now() };
201
251
  if (level === "error") {
202
252
  const stack = new Error().stack;
@@ -229,13 +279,14 @@ function installFetchPatch(buffer, sanitize) {
229
279
  buffer.push({ url: sanitize(url), method, status: response.status, durationMs: Math.round(performance.now() - start), ts: Date.now() });
230
280
  return response;
231
281
  } catch (err) {
282
+ const rawErr = err instanceof Error ? err.message : String(err);
232
283
  buffer.push({
233
284
  url: sanitize(url),
234
285
  method,
235
286
  status: 0,
236
287
  durationMs: Math.round(performance.now() - start),
237
288
  ts: Date.now(),
238
- error: err instanceof Error ? err.message : String(err)
289
+ error: scrubCredentials(rawErr)
239
290
  });
240
291
  throw err;
241
292
  }
@@ -282,9 +333,10 @@ function installErrorHandlers(buffer) {
282
333
  if (typeof window === "undefined") return () => {
283
334
  };
284
335
  const onError = (e) => {
285
- const stack = e.error instanceof Error ? e.error.stack : void 0;
336
+ const rawStack = e.error instanceof Error ? e.error.stack : void 0;
337
+ const stack = rawStack !== void 0 ? scrubCredentials(rawStack) : void 0;
286
338
  buffer.push({
287
- message: e.message || "Unknown error",
339
+ message: scrubCredentials(e.message || "Unknown error"),
288
340
  ...stack !== void 0 && { stack },
289
341
  ts: Date.now(),
290
342
  source: "window.error"
@@ -292,16 +344,17 @@ function installErrorHandlers(buffer) {
292
344
  };
293
345
  const onRejection = (e) => {
294
346
  const reason = e.reason;
295
- const message = reason instanceof Error ? reason.message : typeof reason === "string" ? reason : (() => {
347
+ const rawMessage = reason instanceof Error ? reason.message : typeof reason === "string" ? reason : (() => {
296
348
  try {
297
349
  return JSON.stringify(reason);
298
350
  } catch {
299
351
  return String(reason);
300
352
  }
301
353
  })();
302
- const stack = reason instanceof Error ? reason.stack : void 0;
354
+ const rawStack = reason instanceof Error ? reason.stack : void 0;
355
+ const stack = rawStack !== void 0 ? scrubCredentials(rawStack) : void 0;
303
356
  buffer.push({
304
- message,
357
+ message: scrubCredentials(rawMessage),
305
358
  ...stack !== void 0 && { stack },
306
359
  ts: Date.now(),
307
360
  source: "unhandledrejection"
@@ -330,7 +383,7 @@ function createPerformanceCollector(slowResourceMs = 1e3) {
330
383
  } else if (entry.entryType === "resource") {
331
384
  const e = entry;
332
385
  if (e.duration > slowResourceMs) {
333
- slowResources.push({ name: e.name, duration: e.duration, initiatorType: e.initiatorType });
386
+ slowResources.push({ name: sanitizeUrl(e.name), duration: e.duration, initiatorType: e.initiatorType });
334
387
  while (slowResources.length > 20) slowResources.shift();
335
388
  }
336
389
  }
@@ -413,6 +466,19 @@ function installCapture(options = {}) {
413
466
  }
414
467
 
415
468
  // src/screenshot/index.ts
469
+ var DEFAULT_REDACTED_INPUT_SELECTORS = [
470
+ 'input[type="password"]',
471
+ 'input[type="tel"]',
472
+ 'input[autocomplete*="cc-"]',
473
+ 'input[autocomplete*="current-password"]',
474
+ 'input[autocomplete*="new-password"]',
475
+ 'input[autocomplete*="one-time-code"]',
476
+ 'input[name*="cvv" i]',
477
+ 'input[name*="ccv" i]',
478
+ 'input[name*="cardnum" i]',
479
+ 'input[name*="card-num" i]'
480
+ ];
481
+ var REDACTION_MASK = "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022";
416
482
  function isMaskedElement(el) {
417
483
  return el.hasAttribute("data-mfb-mask") || el.classList.contains("mfb-mask");
418
484
  }
@@ -421,13 +487,33 @@ function prepareMaskMatcher(selectors) {
421
487
  if (!joined) return isMaskedElement;
422
488
  return (el) => isMaskedElement(el) || el.matches(joined);
423
489
  }
490
+ function redactSensitiveInputs(clonedDoc, extraSelectors) {
491
+ const selectors = [
492
+ ...DEFAULT_REDACTED_INPUT_SELECTORS,
493
+ ...extraSelectors,
494
+ "[data-mfb-mask]",
495
+ ".mfb-mask"
496
+ ].join(",");
497
+ const elements = clonedDoc.querySelectorAll(selectors);
498
+ elements.forEach((el) => {
499
+ if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
500
+ const len = Math.min(Math.max(el.value.length || 8, 4), 16);
501
+ el.value = REDACTION_MASK.slice(0, len);
502
+ el.setAttribute("value", el.value);
503
+ } else if (el instanceof HTMLElement) {
504
+ el.textContent = REDACTION_MASK;
505
+ }
506
+ });
507
+ }
424
508
  async function takeScreenshot(target, options = {}) {
425
509
  if (typeof document === "undefined") return null;
426
510
  try {
427
511
  const html2canvas = (await import("html2canvas-pro")).default;
428
- const matcher = prepareMaskMatcher(options.mask ?? []);
512
+ const extraSelectors = options.mask ?? [];
513
+ const matcher = prepareMaskMatcher(extraSelectors);
429
514
  const canvas = await html2canvas(target, {
430
515
  ignoreElements: (el) => matcher(el),
516
+ onclone: (clonedDoc) => redactSensitiveInputs(clonedDoc, extraSelectors),
431
517
  useCORS: true,
432
518
  logging: false,
433
519
  backgroundColor: null
@@ -476,8 +562,12 @@ var DEFAULT_STRINGS = {
476
562
  "annotator.tool.arrow": "Arrow",
477
563
  "annotator.tool.freehand": "Freehand",
478
564
  "annotator.tool.text": "Text",
565
+ "annotator.tool.highlight": "Highlight",
566
+ "annotator.tool.blur": "Blur (hide sensitive data)",
479
567
  "annotator.text_prompt": "Enter text:",
568
+ "annotator.color_picker": "Custom color",
480
569
  "annotator.undo": "Undo",
570
+ "annotator.redo": "Redo",
481
571
  "annotator.clear": "Clear all",
482
572
  "annotator.count_suffix": "annotations",
483
573
  "annotator.loading": "Loading\u2026",
@@ -578,8 +668,12 @@ var FRENCH_STRINGS = {
578
668
  "annotator.tool.arrow": "Fl\xE8che",
579
669
  "annotator.tool.freehand": "Dessin libre",
580
670
  "annotator.tool.text": "Texte",
671
+ "annotator.tool.highlight": "Surligner",
672
+ "annotator.tool.blur": "Flouter (donn\xE9es sensibles)",
581
673
  "annotator.text_prompt": "Entrez le texte :",
674
+ "annotator.color_picker": "Couleur personnalis\xE9e",
582
675
  "annotator.undo": "Annuler",
676
+ "annotator.redo": "Refaire",
583
677
  "annotator.clear": "Tout effacer",
584
678
  "annotator.count_suffix": "annotations",
585
679
  "annotator.loading": "Chargement\u2026",
@@ -846,10 +940,11 @@ function Fab({ label, onClick }) {
846
940
  import { useEffect as useEffect3, useRef as useRef3, useState as useState3 } from "preact/hooks";
847
941
 
848
942
  // src/widget/Annotator.tsx
849
- import { useEffect as useEffect2, useRef as useRef2, useState as useState2 } from "preact/hooks";
943
+ import { useEffect as useEffect2, useLayoutEffect, useRef as useRef2, useState as useState2 } from "preact/hooks";
850
944
  import { jsx as jsx4, jsxs as jsxs3 } from "preact/jsx-runtime";
851
945
  var COLORS = ["#ef4444", "#f59e0b", "#10b981", "#3b82f6", "#ffffff"];
852
- function drawShape(ctx, shape) {
946
+ var HIGHLIGHT_ALPHA = 0.35;
947
+ function drawShape(ctx, shape, sourceImage) {
853
948
  ctx.save();
854
949
  ctx.strokeStyle = shape.color;
855
950
  ctx.fillStyle = shape.color;
@@ -882,9 +977,22 @@ function drawShape(ctx, shape) {
882
977
  ctx.fillRect(shape.x - padding, shape.y - padding, w, hh);
883
978
  ctx.fillStyle = shape.color;
884
979
  ctx.fillText(shape.text, shape.x, shape.y);
980
+ } else if (shape.kind === "highlight") {
981
+ ctx.globalAlpha = HIGHLIGHT_ALPHA;
982
+ ctx.fillRect(
983
+ normalizeStart(shape.x, shape.w),
984
+ normalizeStart(shape.y, shape.h),
985
+ Math.abs(shape.w),
986
+ Math.abs(shape.h)
987
+ );
988
+ } else if (shape.kind === "blur") {
989
+ drawBlur(ctx, shape, sourceImage);
885
990
  }
886
991
  ctx.restore();
887
992
  }
993
+ function normalizeStart(start, size) {
994
+ return size < 0 ? start + size : start;
995
+ }
888
996
  function drawArrow(ctx, x1, y1, x2, y2) {
889
997
  const headLen = 14;
890
998
  const angle = Math.atan2(y2 - y1, x2 - x1);
@@ -905,12 +1013,77 @@ function drawArrow(ctx, x1, y1, x2, y2) {
905
1013
  ctx.closePath();
906
1014
  ctx.fill();
907
1015
  }
1016
+ function drawBlur(ctx, shape, sourceImage) {
1017
+ if (!sourceImage) return;
1018
+ const x = normalizeStart(shape.x, shape.w);
1019
+ const y = normalizeStart(shape.y, shape.h);
1020
+ const w = Math.abs(shape.w);
1021
+ const h2 = Math.abs(shape.h);
1022
+ if (w < 2 || h2 < 2) return;
1023
+ const tile = Math.max(4, shape.tile);
1024
+ for (let yy = y; yy < y + h2; yy += tile) {
1025
+ for (let xx = x; xx < x + w; xx += tile) {
1026
+ const tw = Math.min(tile, x + w - xx);
1027
+ const th = Math.min(tile, y + h2 - yy);
1028
+ ctx.drawImage(
1029
+ sourceImage,
1030
+ xx,
1031
+ yy,
1032
+ tw,
1033
+ th,
1034
+ // source rect from the image
1035
+ xx,
1036
+ yy,
1037
+ tw,
1038
+ th
1039
+ // dest rect on the canvas (same coords)
1040
+ );
1041
+ }
1042
+ }
1043
+ ctx.imageSmoothingEnabled = false;
1044
+ for (let yy = y; yy < y + h2; yy += tile) {
1045
+ for (let xx = x; xx < x + w; xx += tile) {
1046
+ const tw = Math.min(tile, x + w - xx);
1047
+ const th = Math.min(tile, y + h2 - yy);
1048
+ ctx.drawImage(
1049
+ sourceImage,
1050
+ xx,
1051
+ yy,
1052
+ 1,
1053
+ 1,
1054
+ // single source pixel
1055
+ xx,
1056
+ yy,
1057
+ tw,
1058
+ th
1059
+ // stretched dest tile
1060
+ );
1061
+ }
1062
+ }
1063
+ ctx.imageSmoothingEnabled = true;
1064
+ }
908
1065
  var Icon = {
909
1066
  rect: /* @__PURE__ */ jsx4("svg", { width: "16", height: "16", viewBox: "0 0 16 16", "aria-hidden": "true", children: /* @__PURE__ */ jsx4("rect", { x: "2", y: "3", width: "12", height: "10", fill: "none", stroke: "currentColor", "stroke-width": "1.5" }) }),
910
1067
  arrow: /* @__PURE__ */ jsx4("svg", { width: "16", height: "16", viewBox: "0 0 16 16", "aria-hidden": "true", children: /* @__PURE__ */ jsx4("path", { d: "M2 8h11M9 4l4 4-4 4", fill: "none", stroke: "currentColor", "stroke-width": "1.5", "stroke-linecap": "round", "stroke-linejoin": "round" }) }),
911
1068
  pencil: /* @__PURE__ */ jsx4("svg", { width: "16", height: "16", viewBox: "0 0 16 16", "aria-hidden": "true", children: /* @__PURE__ */ jsx4("path", { d: "M11.5 2.5l2 2L5 13H3v-2l8.5-8.5z", fill: "none", stroke: "currentColor", "stroke-width": "1.5", "stroke-linejoin": "round" }) }),
912
1069
  text: /* @__PURE__ */ jsx4("svg", { width: "16", height: "16", viewBox: "0 0 16 16", "aria-hidden": "true", children: /* @__PURE__ */ jsx4("path", { d: "M3 3h10M8 3v10M5 13h6", fill: "none", stroke: "currentColor", "stroke-width": "1.5", "stroke-linecap": "round" }) }),
1070
+ highlight: /* @__PURE__ */ jsxs3("svg", { width: "16", height: "16", viewBox: "0 0 16 16", "aria-hidden": "true", children: [
1071
+ /* @__PURE__ */ jsx4("path", { d: "M3 13l3-3L11 5l-2-2-5 5-3 3v2h2zM10 4l2 2", fill: "none", stroke: "currentColor", "stroke-width": "1.5", "stroke-linecap": "round", "stroke-linejoin": "round" }),
1072
+ /* @__PURE__ */ jsx4("path", { d: "M2 14h12", fill: "none", stroke: "currentColor", "stroke-width": "1.5", "stroke-linecap": "round" })
1073
+ ] }),
1074
+ blur: /* @__PURE__ */ jsxs3("svg", { width: "16", height: "16", viewBox: "0 0 16 16", "aria-hidden": "true", children: [
1075
+ /* @__PURE__ */ jsx4("rect", { x: "2", y: "2", width: "4", height: "4", fill: "currentColor", opacity: "0.85" }),
1076
+ /* @__PURE__ */ jsx4("rect", { x: "7", y: "2", width: "3", height: "3", fill: "currentColor", opacity: "0.55" }),
1077
+ /* @__PURE__ */ jsx4("rect", { x: "11", y: "2", width: "3", height: "4", fill: "currentColor", opacity: "0.4" }),
1078
+ /* @__PURE__ */ jsx4("rect", { x: "2", y: "7", width: "3", height: "3", fill: "currentColor", opacity: "0.6" }),
1079
+ /* @__PURE__ */ jsx4("rect", { x: "6", y: "7", width: "3", height: "3", fill: "currentColor", opacity: "0.85" }),
1080
+ /* @__PURE__ */ jsx4("rect", { x: "10", y: "7", width: "4", height: "3", fill: "currentColor", opacity: "0.5" }),
1081
+ /* @__PURE__ */ jsx4("rect", { x: "2", y: "11", width: "4", height: "3", fill: "currentColor", opacity: "0.4" }),
1082
+ /* @__PURE__ */ jsx4("rect", { x: "7", y: "11", width: "3", height: "3", fill: "currentColor", opacity: "0.65" }),
1083
+ /* @__PURE__ */ jsx4("rect", { x: "11", y: "11", width: "3", height: "3", fill: "currentColor", opacity: "0.85" })
1084
+ ] }),
913
1085
  undo: /* @__PURE__ */ jsx4("svg", { width: "14", height: "14", viewBox: "0 0 16 16", "aria-hidden": "true", children: /* @__PURE__ */ jsx4("path", { d: "M4 7l3-3M4 7l3 3M4 7h6a3 3 0 0 1 0 6H7", fill: "none", stroke: "currentColor", "stroke-width": "1.5", "stroke-linecap": "round", "stroke-linejoin": "round" }) }),
1086
+ redo: /* @__PURE__ */ jsx4("svg", { width: "14", height: "14", viewBox: "0 0 16 16", "aria-hidden": "true", children: /* @__PURE__ */ jsx4("path", { d: "M12 7l-3-3M12 7l-3 3M12 7H6a3 3 0 0 0 0 6h2", fill: "none", stroke: "currentColor", "stroke-width": "1.5", "stroke-linecap": "round", "stroke-linejoin": "round" }) }),
914
1087
  trash: /* @__PURE__ */ jsx4("svg", { width: "14", height: "14", viewBox: "0 0 16 16", "aria-hidden": "true", children: /* @__PURE__ */ jsx4("path", { d: "M3 4h10M6 4V2.5h4V4M5 4l.5 9h5L11 4", fill: "none", stroke: "currentColor", "stroke-width": "1.5", "stroke-linecap": "round", "stroke-linejoin": "round" }) }),
915
1088
  close: /* @__PURE__ */ jsx4("svg", { width: "16", height: "16", viewBox: "0 0 16 16", "aria-hidden": "true", children: /* @__PURE__ */ jsx4("path", { d: "M4 4l8 8M12 4l-8 8", fill: "none", stroke: "currentColor", "stroke-width": "1.5", "stroke-linecap": "round" }) })
916
1089
  };
@@ -921,12 +1094,50 @@ function Annotator({ imageBlob, strings, onSave, onCancel }) {
921
1094
  const [tool, setTool] = useState2("rectangle");
922
1095
  const [color, setColor] = useState2(COLORS[0]);
923
1096
  const [shapes, setShapes] = useState2([]);
1097
+ const [past, setPast] = useState2([]);
1098
+ const [future, setFuture] = useState2([]);
924
1099
  const isDrawingRef = useRef2(false);
925
1100
  const [draftShape, setDraftShape] = useState2(null);
926
1101
  const [imageLoaded, setImageLoaded] = useState2(false);
927
1102
  const [saving, setSaving] = useState2(false);
928
1103
  const scaleRef = useRef2(1);
929
- useEffect2(() => {
1104
+ function addShape(shape) {
1105
+ setShapes((s) => {
1106
+ setPast((p) => [...p, s]);
1107
+ return [...s, shape];
1108
+ });
1109
+ setFuture([]);
1110
+ }
1111
+ function clearShapes() {
1112
+ setShapes((s) => {
1113
+ setPast((p) => [...p, s]);
1114
+ return [];
1115
+ });
1116
+ setFuture([]);
1117
+ }
1118
+ function undo() {
1119
+ setPast((p) => {
1120
+ if (p.length === 0) return p;
1121
+ const prev = p[p.length - 1];
1122
+ setShapes((s) => {
1123
+ setFuture((f) => [s, ...f]);
1124
+ return prev;
1125
+ });
1126
+ return p.slice(0, -1);
1127
+ });
1128
+ }
1129
+ function redo() {
1130
+ setFuture((f) => {
1131
+ if (f.length === 0) return f;
1132
+ const next = f[0];
1133
+ setShapes((s) => {
1134
+ setPast((p) => [...p, s]);
1135
+ return next;
1136
+ });
1137
+ return f.slice(1);
1138
+ });
1139
+ }
1140
+ useLayoutEffect(() => {
930
1141
  const onKey = (e) => {
931
1142
  if (e.key === "Escape") {
932
1143
  e.stopPropagation();
@@ -936,6 +1147,25 @@ function Annotator({ imageBlob, strings, onSave, onCancel }) {
936
1147
  window.addEventListener("keydown", onKey);
937
1148
  return () => window.removeEventListener("keydown", onKey);
938
1149
  }, [onCancel]);
1150
+ useEffect2(() => {
1151
+ const onKey = (e) => {
1152
+ const mod = e.metaKey || e.ctrlKey;
1153
+ if (!mod) return;
1154
+ const k = e.key.toLowerCase();
1155
+ if (k === "z") {
1156
+ e.preventDefault();
1157
+ e.stopPropagation();
1158
+ if (e.shiftKey) redo();
1159
+ else undo();
1160
+ } else if (k === "y") {
1161
+ e.preventDefault();
1162
+ e.stopPropagation();
1163
+ redo();
1164
+ }
1165
+ };
1166
+ window.addEventListener("keydown", onKey);
1167
+ return () => window.removeEventListener("keydown", onKey);
1168
+ }, [past, future, shapes]);
939
1169
  useEffect2(() => {
940
1170
  const url = URL.createObjectURL(imageBlob);
941
1171
  const img = new Image();
@@ -953,7 +1183,7 @@ function Annotator({ imageBlob, strings, onSave, onCancel }) {
953
1183
  const img = imageRef.current;
954
1184
  const container = containerRef.current;
955
1185
  const maxW = container.clientWidth - 16;
956
- const maxH = window.innerHeight * 0.6;
1186
+ const maxH = window.innerHeight * 0.65;
957
1187
  const fitScale = Math.min(maxW / img.width, maxH / img.height, 1);
958
1188
  scaleRef.current = fitScale;
959
1189
  const canvas = canvasRef.current;
@@ -974,8 +1204,8 @@ function Annotator({ imageBlob, strings, onSave, onCancel }) {
974
1204
  if (!ctx) return;
975
1205
  ctx.clearRect(0, 0, canvas.width, canvas.height);
976
1206
  ctx.drawImage(img, 0, 0);
977
- for (const s of shapes) drawShape(ctx, s);
978
- if (draftShape) drawShape(ctx, draftShape);
1207
+ for (const s of shapes) drawShape(ctx, s, img);
1208
+ if (draftShape) drawShape(ctx, draftShape, img);
979
1209
  }
980
1210
  function getCanvasCoords(e) {
981
1211
  const canvas = canvasRef.current;
@@ -988,22 +1218,21 @@ function Annotator({ imageBlob, strings, onSave, onCancel }) {
988
1218
  const handleMouseDown = (e) => {
989
1219
  if (!imageLoaded) return;
990
1220
  const { x, y } = getCanvasCoords(e);
991
- const lineWidth = Math.max(3, Math.round(canvasRef.current.width / 400));
1221
+ const canvasW = canvasRef.current.width;
1222
+ const lineWidth = Math.max(3, Math.round(canvasW / 400));
1223
+ const blurTile = Math.max(8, Math.round(canvasW / 80));
992
1224
  if (tool === "text") {
993
1225
  const text = window.prompt(strings["annotator.text_prompt"]);
994
1226
  if (text && text.trim()) {
995
- setShapes((prev) => [
996
- ...prev,
997
- {
998
- kind: "text",
999
- x,
1000
- y,
1001
- text: text.trim(),
1002
- color,
1003
- fontSize: Math.max(16, Math.round(canvasRef.current.width / 50)),
1004
- lineWidth: 1
1005
- }
1006
- ]);
1227
+ addShape({
1228
+ kind: "text",
1229
+ x,
1230
+ y,
1231
+ text: text.trim(),
1232
+ color,
1233
+ fontSize: Math.max(16, Math.round(canvasW / 50)),
1234
+ lineWidth: 1
1235
+ });
1007
1236
  }
1008
1237
  return;
1009
1238
  }
@@ -1014,31 +1243,60 @@ function Annotator({ imageBlob, strings, onSave, onCancel }) {
1014
1243
  setDraftShape({ kind: "arrow", x1: x, y1: y, x2: x, y2: y, color, lineWidth });
1015
1244
  } else if (tool === "freehand") {
1016
1245
  setDraftShape({ kind: "freehand", points: [{ x, y }], color, lineWidth });
1246
+ } else if (tool === "highlight") {
1247
+ setDraftShape({
1248
+ kind: "highlight",
1249
+ x,
1250
+ y,
1251
+ w: 0,
1252
+ h: 0,
1253
+ color: color === "#ffffff" ? "#fde047" : color,
1254
+ lineWidth: 1
1255
+ });
1256
+ } else if (tool === "blur") {
1257
+ setDraftShape({
1258
+ kind: "blur",
1259
+ x,
1260
+ y,
1261
+ w: 0,
1262
+ h: 0,
1263
+ color: "#000000",
1264
+ lineWidth: 0,
1265
+ tile: blurTile
1266
+ });
1017
1267
  }
1018
1268
  };
1019
1269
  const handleMouseMove = (e) => {
1020
- if (!isDrawingRef.current || !draftShape) return;
1270
+ if (!isDrawingRef.current) return;
1021
1271
  const { x, y } = getCanvasCoords(e);
1022
- if (draftShape.kind === "rectangle") {
1023
- setDraftShape({ ...draftShape, w: x - draftShape.x, h: y - draftShape.y });
1024
- } else if (draftShape.kind === "arrow") {
1025
- setDraftShape({ ...draftShape, x2: x, y2: y });
1026
- } else if (draftShape.kind === "freehand") {
1027
- setDraftShape({ ...draftShape, points: [...draftShape.points, { x, y }] });
1028
- }
1272
+ setDraftShape((current) => {
1273
+ if (!current) return current;
1274
+ if (current.kind === "rectangle" || current.kind === "highlight" || current.kind === "blur") {
1275
+ return { ...current, w: x - current.x, h: y - current.y };
1276
+ }
1277
+ if (current.kind === "arrow") {
1278
+ return { ...current, x2: x, y2: y };
1279
+ }
1280
+ if (current.kind === "freehand") {
1281
+ return { ...current, points: [...current.points, { x, y }] };
1282
+ }
1283
+ return current;
1284
+ });
1029
1285
  };
1030
1286
  const handleMouseUp = () => {
1031
- if (isDrawingRef.current && draftShape) {
1032
- const isTiny = draftShape.kind === "rectangle" && Math.abs(draftShape.w) < 4 && Math.abs(draftShape.h) < 4 || draftShape.kind === "arrow" && Math.hypot(
1033
- draftShape.x2 - draftShape.x1,
1034
- draftShape.y2 - draftShape.y1
1035
- ) < 4 || draftShape.kind === "freehand" && draftShape.points.length < 3;
1036
- if (!isTiny) {
1037
- setShapes((prev) => [...prev, draftShape]);
1287
+ setDraftShape((current) => {
1288
+ if (isDrawingRef.current && current) {
1289
+ const isTiny = (current.kind === "rectangle" || current.kind === "highlight" || current.kind === "blur") && Math.abs(current.w) < 4 && Math.abs(current.h) < 4 || current.kind === "arrow" && Math.hypot(
1290
+ current.x2 - current.x1,
1291
+ current.y2 - current.y1
1292
+ ) < 4 || current.kind === "freehand" && current.points.length < 3;
1293
+ if (!isTiny) {
1294
+ addShape(current);
1295
+ }
1038
1296
  }
1039
- }
1297
+ return null;
1298
+ });
1040
1299
  isDrawingRef.current = false;
1041
- setDraftShape(null);
1042
1300
  };
1043
1301
  const handleSave = async () => {
1044
1302
  const canvas = canvasRef.current;
@@ -1057,7 +1315,9 @@ function Annotator({ imageBlob, strings, onSave, onCancel }) {
1057
1315
  { id: "rectangle", icon: Icon.rect, label: strings["annotator.tool.rectangle"] },
1058
1316
  { id: "arrow", icon: Icon.arrow, label: strings["annotator.tool.arrow"] },
1059
1317
  { id: "freehand", icon: Icon.pencil, label: strings["annotator.tool.freehand"] },
1060
- { id: "text", icon: Icon.text, label: strings["annotator.tool.text"] }
1318
+ { id: "text", icon: Icon.text, label: strings["annotator.tool.text"] },
1319
+ { id: "highlight", icon: Icon.highlight, label: strings["annotator.tool.highlight"] },
1320
+ { id: "blur", icon: Icon.blur, label: strings["annotator.tool.blur"] }
1061
1321
  ];
1062
1322
  return /* @__PURE__ */ jsx4(
1063
1323
  "div",
@@ -1096,26 +1356,38 @@ function Annotator({ imageBlob, strings, onSave, onCancel }) {
1096
1356
  t.id
1097
1357
  )) }),
1098
1358
  /* @__PURE__ */ jsx4("span", { class: "annotator-sep" }),
1099
- /* @__PURE__ */ jsx4("div", { class: "annotator-colors", children: COLORS.map((c) => /* @__PURE__ */ jsx4(
1100
- "button",
1101
- {
1102
- type: "button",
1103
- onClick: () => setColor(c),
1104
- "aria-label": c,
1105
- "aria-pressed": color === c,
1106
- class: `annotator-color ${color === c ? "is-active" : ""}`,
1107
- style: { backgroundColor: c }
1108
- },
1109
- c
1110
- )) }),
1359
+ /* @__PURE__ */ jsxs3("div", { class: "annotator-colors", children: [
1360
+ COLORS.map((c) => /* @__PURE__ */ jsx4(
1361
+ "button",
1362
+ {
1363
+ type: "button",
1364
+ onClick: () => setColor(c),
1365
+ "aria-label": c,
1366
+ "aria-pressed": color === c,
1367
+ class: `annotator-color ${color === c ? "is-active" : ""}`,
1368
+ style: { backgroundColor: c }
1369
+ },
1370
+ c
1371
+ )),
1372
+ /* @__PURE__ */ jsx4("label", { class: "annotator-color annotator-color-picker", title: strings["annotator.color_picker"], children: /* @__PURE__ */ jsx4(
1373
+ "input",
1374
+ {
1375
+ type: "color",
1376
+ value: color,
1377
+ onInput: (e) => setColor(e.target.value),
1378
+ "aria-label": strings["annotator.color_picker"]
1379
+ }
1380
+ ) })
1381
+ ] }),
1111
1382
  /* @__PURE__ */ jsx4("span", { class: "annotator-sep" }),
1112
1383
  /* @__PURE__ */ jsxs3(
1113
1384
  "button",
1114
1385
  {
1115
1386
  type: "button",
1116
1387
  class: "annotator-btn",
1117
- onClick: () => setShapes((prev) => prev.slice(0, -1)),
1118
- disabled: shapes.length === 0,
1388
+ onClick: undo,
1389
+ disabled: past.length === 0,
1390
+ title: strings["annotator.undo"],
1119
1391
  children: [
1120
1392
  Icon.undo,
1121
1393
  /* @__PURE__ */ jsx4("span", { children: strings["annotator.undo"] })
@@ -1127,7 +1399,21 @@ function Annotator({ imageBlob, strings, onSave, onCancel }) {
1127
1399
  {
1128
1400
  type: "button",
1129
1401
  class: "annotator-btn",
1130
- onClick: () => setShapes([]),
1402
+ onClick: redo,
1403
+ disabled: future.length === 0,
1404
+ title: strings["annotator.redo"],
1405
+ children: [
1406
+ Icon.redo,
1407
+ /* @__PURE__ */ jsx4("span", { children: strings["annotator.redo"] })
1408
+ ]
1409
+ }
1410
+ ),
1411
+ /* @__PURE__ */ jsxs3(
1412
+ "button",
1413
+ {
1414
+ type: "button",
1415
+ class: "annotator-btn",
1416
+ onClick: clearShapes,
1131
1417
  disabled: shapes.length === 0,
1132
1418
  children: [
1133
1419
  Icon.trash,
@@ -1359,25 +1645,27 @@ function Form({ strings, onSubmit, onCancel, status, errorMessage }) {
1359
1645
  ),
1360
1646
  screenshotPreview ? /* @__PURE__ */ jsxs4("div", { class: "screenshot-preview", children: [
1361
1647
  /* @__PURE__ */ jsx5("img", { src: screenshotPreview, alt: "" }),
1362
- /* @__PURE__ */ jsx5(
1363
- "button",
1364
- {
1365
- type: "button",
1366
- class: "screenshot-remove",
1367
- onClick: clearScreenshot,
1368
- "aria-label": strings["form.screenshot.remove"],
1369
- children: "\xD7"
1370
- }
1371
- ),
1372
- /* @__PURE__ */ jsx5(
1373
- "button",
1374
- {
1375
- type: "button",
1376
- class: "btn screenshot-annotate",
1377
- onClick: () => setAnnotatorOpen(true),
1378
- children: strings["form.screenshot.annotate"]
1379
- }
1380
- )
1648
+ /* @__PURE__ */ jsxs4("div", { class: "screenshot-preview-actions", children: [
1649
+ /* @__PURE__ */ jsxs4(
1650
+ "button",
1651
+ {
1652
+ type: "button",
1653
+ class: "btn btn--primary screenshot-annotate",
1654
+ onClick: () => setAnnotatorOpen(true),
1655
+ children: [
1656
+ /* @__PURE__ */ jsxs4("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2", "stroke-linecap": "round", "stroke-linejoin": "round", "aria-hidden": "true", children: [
1657
+ /* @__PURE__ */ jsx5("path", { d: "M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" }),
1658
+ /* @__PURE__ */ jsx5("path", { d: "M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" })
1659
+ ] }),
1660
+ strings["form.screenshot.annotate"]
1661
+ ]
1662
+ }
1663
+ ),
1664
+ /* @__PURE__ */ jsxs4("button", { type: "button", class: "btn", onClick: clearScreenshot, children: [
1665
+ /* @__PURE__ */ jsx5("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2", "stroke-linecap": "round", "stroke-linejoin": "round", "aria-hidden": "true", children: /* @__PURE__ */ jsx5("path", { d: "M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6h14z" }) }),
1666
+ strings["form.screenshot.remove"]
1667
+ ] })
1668
+ ] })
1381
1669
  ] }) : /* @__PURE__ */ jsxs4(
1382
1670
  "div",
1383
1671
  {
@@ -1397,6 +1685,11 @@ function Form({ strings, onSubmit, onCancel, status, errorMessage }) {
1397
1685
  onDragLeave: handleDragLeave,
1398
1686
  onDrop: handleDrop,
1399
1687
  children: [
1688
+ /* @__PURE__ */ jsxs4("svg", { class: "screenshot-icon", width: "28", height: "28", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "1.6", "stroke-linecap": "round", "stroke-linejoin": "round", "aria-hidden": "true", children: [
1689
+ /* @__PURE__ */ jsx5("path", { d: "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" }),
1690
+ /* @__PURE__ */ jsx5("polyline", { points: "17 8 12 3 7 8" }),
1691
+ /* @__PURE__ */ jsx5("line", { x1: "12", y1: "3", x2: "12", y2: "15" })
1692
+ ] }),
1400
1693
  /* @__PURE__ */ jsxs4("div", { class: "screenshot-cta", children: [
1401
1694
  /* @__PURE__ */ jsx5("strong", { children: strings["form.screenshot.cta_click"] }),
1402
1695
  ", ",
@@ -1972,13 +2265,39 @@ var WIDGET_STYLES = `
1972
2265
  --mfb-accent-contrast: #ffffff;
1973
2266
  --mfb-bg: #ffffff;
1974
2267
  --mfb-surface: #f9fafb;
2268
+ --mfb-surface-2: #f3f4f6;
1975
2269
  --mfb-text: #0a0a0a;
1976
2270
  --mfb-text-muted: #6b7280;
1977
2271
  --mfb-border: #e5e7eb;
2272
+ --mfb-border-strong: #d1d5db;
1978
2273
  --mfb-radius: 8px;
2274
+ --mfb-radius-lg: 14px;
1979
2275
  --mfb-font: system-ui, -apple-system, sans-serif;
1980
2276
  --mfb-z-index: 2147483640;
1981
2277
 
2278
+ /* Spacing scale \u2014 4px base, used everywhere instead of magic numbers. */
2279
+ --mfb-space-1: 4px;
2280
+ --mfb-space-2: 8px;
2281
+ --mfb-space-3: 12px;
2282
+ --mfb-space-4: 16px;
2283
+ --mfb-space-5: 24px;
2284
+ --mfb-space-6: 32px;
2285
+ --mfb-space-7: 48px;
2286
+
2287
+ /* Type scale \u2014 fixed sizes, no fluid scaling. Body 14px sits in the
2288
+ "comfortable on every viewport" range; headings step up gracefully. */
2289
+ --mfb-text-xs: 11px;
2290
+ --mfb-text-sm: 13px;
2291
+ --mfb-text-base: 14px;
2292
+ --mfb-text-md: 15px;
2293
+ --mfb-text-lg: 17px;
2294
+ --mfb-text-xl: 20px;
2295
+
2296
+ /* Elevation \u2014 three tiers for layering. */
2297
+ --mfb-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06), 0 1px 3px rgba(0, 0, 0, 0.08);
2298
+ --mfb-shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.12);
2299
+ --mfb-shadow-lg: 0 24px 56px rgba(0, 0, 0, 0.18), 0 8px 20px rgba(0, 0, 0, 0.10);
2300
+
1982
2301
  all: initial;
1983
2302
  font-family: var(--mfb-font);
1984
2303
  color: var(--mfb-text);
@@ -1988,11 +2307,14 @@ var WIDGET_STYLES = `
1988
2307
 
1989
2308
  @media (prefers-color-scheme: dark) {
1990
2309
  :host {
1991
- --mfb-bg: #111827;
1992
- --mfb-surface: #1f2937;
1993
- --mfb-text: #f9fafb;
1994
- --mfb-text-muted: #9ca3af;
1995
- --mfb-border: #374151;
2310
+ --mfb-bg: #0f172a;
2311
+ --mfb-surface: #1e293b;
2312
+ --mfb-surface-2: #253349;
2313
+ --mfb-text: #f8fafc;
2314
+ --mfb-text-muted: #94a3b8;
2315
+ --mfb-border: #334155;
2316
+ --mfb-border-strong: #475569;
2317
+ --mfb-shadow-lg: 0 24px 56px rgba(0, 0, 0, 0.55), 0 8px 20px rgba(0, 0, 0, 0.40);
1996
2318
  }
1997
2319
  }
1998
2320
 
@@ -2051,60 +2373,98 @@ var WIDGET_STYLES = `
2051
2373
  .fab:hover, .fab:active { transform: none; }
2052
2374
  }
2053
2375
 
2376
+ /* Backdrop \u2014 fade in with a slight blur for depth. The blur gives the
2377
+ modal that "page that opens" weight: the underlying page recedes
2378
+ visually so the widget feels foregrounded, not pasted on. */
2054
2379
  .backdrop {
2055
2380
  position: fixed;
2056
2381
  inset: 0;
2057
- background: rgba(0, 0, 0, 0.45);
2382
+ background: rgba(15, 23, 42, 0.55);
2383
+ backdrop-filter: blur(6px);
2384
+ -webkit-backdrop-filter: blur(6px);
2058
2385
  display: grid;
2059
2386
  place-items: center;
2387
+ animation: mfb-backdrop-in 220ms cubic-bezier(0.16, 1, 0.3, 1);
2388
+ }
2389
+
2390
+ @keyframes mfb-backdrop-in {
2391
+ from { opacity: 0; backdrop-filter: blur(0px); -webkit-backdrop-filter: blur(0px); }
2392
+ to { opacity: 1; backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); }
2060
2393
  }
2061
2394
 
2062
2395
  .modal {
2063
2396
  background: var(--mfb-bg);
2064
- border-radius: calc(var(--mfb-radius) * 1.5);
2065
- box-shadow: 0 20px 48px rgba(0, 0, 0, 0.25);
2066
- /* 440px is the industry sweet spot for short forms \u2014 Linear, Plain,
2067
- Sentry all sit in the 420-480 range. 92vw caps it on narrow screens. */
2068
- width: min(440px, 92vw);
2069
- /* 24px is shadcn's default; gives every input room to breathe. */
2070
- padding: 24px;
2397
+ border-radius: var(--mfb-radius-lg);
2398
+ box-shadow: var(--mfb-shadow-lg);
2399
+ /* 720px is the "page that opens" sweet spot \u2014 wide enough to feel
2400
+ like a workspace, narrow enough to not dominate the screen. Was
2401
+ 440px before; the bump trades a denser modal for a calmer canvas. */
2402
+ width: min(720px, calc(100vw - var(--mfb-space-7)));
2403
+ padding: var(--mfb-space-6);
2071
2404
  display: flex;
2072
2405
  flex-direction: column;
2073
- /* 18px between major blocks (h2 \u2192 field \u2192 field \u2192 footer). Within a
2074
- field group, .field uses a tighter 6px label-to-input gap. */
2075
- gap: 18px;
2406
+ gap: var(--mfb-space-5);
2076
2407
  position: relative;
2077
- /* Cap modal height on short viewports (mobile landscape, tiny laptops)
2078
- so the form scrolls inside the modal rather than overflowing the
2079
- viewport with no way to reach the actions row. */
2080
- max-height: calc(100vh - 32px);
2408
+ /* Cap height with breathing room. The form scrolls internally if
2409
+ the user attaches a tall screenshot. */
2410
+ max-height: min(820px, calc(100vh - var(--mfb-space-7)));
2081
2411
  overflow-y: auto;
2412
+ /* Entrance: opacity-only. Avoids any geometric shift during the
2413
+ animation so interaction tests that read boundingBox immediately
2414
+ after the modal becomes visible get stable coordinates. 220ms
2415
+ ease-out is snappy enough that human users barely register it. */
2416
+ animation: mfb-modal-in 220ms ease-out;
2417
+ }
2418
+
2419
+ @keyframes mfb-modal-in {
2420
+ from { opacity: 0; }
2421
+ to { opacity: 1; }
2422
+ }
2423
+
2424
+ /* Mobile: full-height sheet that slides up from the bottom. The
2425
+ slide-up gesture matches platform-native sheets users already know. */
2426
+ @media (max-width: 640px) {
2427
+ .backdrop { place-items: end center; }
2428
+ .modal {
2429
+ width: 100vw;
2430
+ height: calc(100vh - var(--mfb-space-7));
2431
+ max-height: none;
2432
+ border-radius: var(--mfb-radius-lg) var(--mfb-radius-lg) 0 0;
2433
+ padding: var(--mfb-space-5) var(--mfb-space-4) var(--mfb-space-4);
2434
+ animation: mfb-sheet-up 280ms cubic-bezier(0.16, 1, 0.3, 1);
2435
+ }
2436
+ }
2437
+
2438
+ @keyframes mfb-sheet-up {
2439
+ from { transform: translateY(100%); }
2440
+ to { transform: translateY(0); }
2441
+ }
2442
+
2443
+ @media (prefers-reduced-motion: reduce) {
2444
+ .backdrop, .modal { animation: none; }
2082
2445
  }
2083
2446
 
2084
2447
  .modal h2 {
2085
2448
  margin: 0;
2086
- font-size: 17px;
2449
+ font-size: var(--mfb-text-xl);
2087
2450
  font-weight: 600;
2088
- padding-right: 28px;
2089
- letter-spacing: -0.01em;
2451
+ padding-right: var(--mfb-space-6);
2452
+ letter-spacing: -0.015em;
2453
+ line-height: 1.2;
2090
2454
  }
2091
2455
 
2092
- /* The form sits inside .modal as a single flex child, so the modal-level
2093
- gap doesn't separate the form's *own* children. Mirror the column +
2094
- gap pattern on the form itself; 22px lands in the middle of the
2095
- 20-24px field-to-field range the design critic recommended. */
2096
2456
  .modal form {
2097
2457
  display: flex;
2098
2458
  flex-direction: column;
2099
- gap: 22px;
2459
+ gap: var(--mfb-space-5);
2100
2460
  }
2101
2461
 
2102
2462
  .modal-close {
2103
2463
  position: absolute;
2104
- top: 8px;
2105
- right: 8px;
2106
- width: 32px;
2107
- height: 32px;
2464
+ top: var(--mfb-space-3);
2465
+ right: var(--mfb-space-3);
2466
+ width: 36px;
2467
+ height: 36px;
2108
2468
  display: grid;
2109
2469
  place-items: center;
2110
2470
  background: transparent;
@@ -2115,36 +2475,39 @@ var WIDGET_STYLES = `
2115
2475
  font-size: 22px;
2116
2476
  line-height: 1;
2117
2477
  cursor: pointer;
2478
+ transition: background 120ms ease, color 120ms ease;
2118
2479
  }
2119
2480
  .modal-close:hover { background: var(--mfb-surface); color: var(--mfb-text); }
2120
2481
  .modal-close:focus-visible { outline: 2px solid var(--mfb-accent); outline-offset: 2px; }
2121
2482
 
2122
- /* Each field group: label (13px medium, muted) + input (14px regular)
2123
- stacked with a 6px gap. The 18px modal-level gap separates groups. */
2124
- .field { display: flex; flex-direction: column; gap: 6px; font-size: 13px; }
2483
+ /* Each field group: label + input stacked with breathing room.
2484
+ The 24px modal-level gap separates groups. */
2485
+ .field { display: flex; flex-direction: column; gap: var(--mfb-space-2); font-size: var(--mfb-text-sm); }
2125
2486
 
2126
2487
  .field label {
2127
2488
  color: var(--mfb-text-muted);
2128
2489
  font-weight: 500;
2129
- font-size: 12px;
2130
- letter-spacing: 0.01em;
2490
+ font-size: var(--mfb-text-xs);
2491
+ letter-spacing: 0.03em;
2492
+ text-transform: uppercase;
2131
2493
  }
2132
2494
 
2133
2495
  .field input, .field select, .field textarea {
2134
2496
  font-family: inherit;
2135
- font-size: 14px;
2497
+ font-size: var(--mfb-text-base);
2136
2498
  color: inherit;
2137
- padding: 9px 12px;
2499
+ padding: 11px 14px;
2138
2500
  border: 1px solid var(--mfb-border);
2139
2501
  border-radius: var(--mfb-radius);
2140
2502
  background: var(--mfb-surface);
2141
- transition: border-color 120ms ease, box-shadow 120ms ease;
2503
+ transition: border-color 120ms ease, box-shadow 120ms ease, background 120ms ease;
2142
2504
  }
2143
2505
 
2144
- .field input:hover, .field select:hover, .field textarea:hover { border-color: var(--mfb-text-muted); }
2506
+ .field input:hover, .field select:hover, .field textarea:hover { border-color: var(--mfb-border-strong); }
2145
2507
  .field input:focus, .field select:focus, .field textarea:focus {
2146
2508
  outline: none;
2147
2509
  border-color: var(--mfb-accent);
2510
+ background: var(--mfb-bg);
2148
2511
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--mfb-accent) 22%, transparent);
2149
2512
  }
2150
2513
 
@@ -2158,34 +2521,35 @@ var WIDGET_STYLES = `
2158
2521
  background-position: right 10px center;
2159
2522
  }
2160
2523
 
2161
- .field textarea { min-height: 96px; resize: vertical; line-height: 1.45; }
2524
+ .field textarea { min-height: 120px; resize: vertical; line-height: 1.5; }
2162
2525
 
2163
- .row { display: flex; gap: 12px; }
2526
+ .row { display: flex; gap: var(--mfb-space-3); }
2164
2527
  .row > * { flex: 1; }
2165
2528
 
2166
- /* Footer separation: border-top + 16px breathing room, Stripe/Linear pattern.
2167
- Stops Cancel/Send from floating against the dropzone or page-URL footer. */
2529
+ /* Footer: subtle separation via border, slightly more vertical space. */
2168
2530
  .actions {
2169
2531
  display: flex;
2170
- gap: 8px;
2532
+ gap: var(--mfb-space-2);
2171
2533
  justify-content: flex-end;
2172
- padding-top: 16px;
2173
- margin-top: 4px;
2534
+ padding-top: var(--mfb-space-4);
2535
+ margin-top: var(--mfb-space-1);
2174
2536
  border-top: 1px solid var(--mfb-border);
2175
2537
  }
2176
2538
 
2177
2539
  .btn {
2178
- padding: 9px 16px;
2540
+ padding: 10px 18px;
2179
2541
  border-radius: var(--mfb-radius);
2180
2542
  border: 1px solid var(--mfb-border);
2181
2543
  background: var(--mfb-bg);
2182
2544
  color: var(--mfb-text);
2183
2545
  font: inherit;
2546
+ font-size: var(--mfb-text-sm);
2184
2547
  font-weight: 500;
2185
2548
  cursor: pointer;
2186
- transition: background 120ms ease, border-color 120ms ease;
2549
+ transition: background 120ms ease, border-color 120ms ease, transform 80ms ease;
2187
2550
  }
2188
- .btn:hover { background: var(--mfb-surface); }
2551
+ .btn:hover { background: var(--mfb-surface); border-color: var(--mfb-border-strong); }
2552
+ .btn:active { transform: scale(0.98); }
2189
2553
 
2190
2554
  .btn--primary {
2191
2555
  background: var(--mfb-accent);
@@ -2216,28 +2580,49 @@ var WIDGET_STYLES = `
2216
2580
  border: 0;
2217
2581
  }
2218
2582
 
2583
+ /* Dropzone \u2014 bigger, more inviting; the icon + heading + sub-line
2584
+ hierarchy makes it feel like a deliberate target, not an afterthought. */
2219
2585
  .screenshot-dropzone {
2220
- border: 1px dashed var(--mfb-border);
2221
- border-radius: var(--mfb-radius);
2222
- padding: 18px 14px;
2586
+ border: 1.5px dashed var(--mfb-border-strong);
2587
+ border-radius: var(--mfb-radius-lg);
2588
+ padding: var(--mfb-space-6) var(--mfb-space-4);
2223
2589
  text-align: center;
2224
2590
  cursor: pointer;
2225
2591
  background: var(--mfb-surface);
2226
- transition: border-color 120ms ease, background 120ms ease;
2592
+ transition: border-color 160ms ease, background 160ms ease, transform 120ms ease;
2593
+ display: flex;
2594
+ flex-direction: column;
2595
+ align-items: center;
2596
+ gap: var(--mfb-space-2);
2597
+ }
2598
+ .screenshot-dropzone:hover {
2599
+ border-color: var(--mfb-accent);
2600
+ background: color-mix(in srgb, var(--mfb-accent) 4%, var(--mfb-surface));
2227
2601
  }
2228
- .screenshot-dropzone:hover { border-color: var(--mfb-text-muted); }
2229
2602
  .screenshot-dropzone.is-dragover {
2230
2603
  border-color: var(--mfb-accent);
2231
2604
  border-style: solid;
2232
- background: color-mix(in srgb, var(--mfb-accent) 8%, var(--mfb-surface));
2605
+ background: color-mix(in srgb, var(--mfb-accent) 10%, var(--mfb-surface));
2606
+ transform: scale(1.005);
2233
2607
  }
2234
2608
  .screenshot-dropzone:focus-visible {
2235
2609
  outline: 2px solid var(--mfb-accent);
2236
2610
  outline-offset: 2px;
2237
2611
  }
2238
- .screenshot-cta { font-size: 13px; color: var(--mfb-text); }
2612
+ .screenshot-icon {
2613
+ color: var(--mfb-text-muted);
2614
+ opacity: 0.7;
2615
+ margin-bottom: var(--mfb-space-1);
2616
+ transition: color 160ms ease, opacity 160ms ease;
2617
+ }
2618
+ .screenshot-dropzone:hover .screenshot-icon,
2619
+ .screenshot-dropzone.is-dragover .screenshot-icon {
2620
+ color: var(--mfb-accent);
2621
+ opacity: 1;
2622
+ }
2623
+ .screenshot-cta { font-size: var(--mfb-text-base); color: var(--mfb-text); font-weight: 500; }
2239
2624
  .screenshot-cta strong { color: var(--mfb-accent); font-weight: 600; }
2240
- .screenshot-formats { font-size: 11px; color: var(--mfb-text-muted); margin-top: 4px; }
2625
+ .screenshot-formats { font-size: var(--mfb-text-xs); color: var(--mfb-text-muted); }
2241
2626
 
2242
2627
  .screenshot-preview {
2243
2628
  position: relative;
@@ -2252,11 +2637,38 @@ var WIDGET_STYLES = `
2252
2637
  display: block;
2253
2638
  width: 100%;
2254
2639
  height: auto;
2255
- max-height: 180px;
2640
+ max-height: 280px;
2256
2641
  object-fit: contain;
2257
2642
  background: #1a1a1a;
2258
2643
  }
2644
+ .screenshot-preview-actions {
2645
+ display: flex;
2646
+ gap: var(--mfb-space-2);
2647
+ padding: var(--mfb-space-2);
2648
+ border-top: 1px solid var(--mfb-border);
2649
+ background: var(--mfb-bg);
2650
+ }
2651
+ .screenshot-preview-actions .btn {
2652
+ flex: 1;
2653
+ padding: 8px 12px;
2654
+ font-size: var(--mfb-text-sm);
2655
+ display: inline-flex;
2656
+ align-items: center;
2657
+ justify-content: center;
2658
+ gap: 6px;
2659
+ }
2660
+ .screenshot-preview-actions .btn--primary {
2661
+ background: var(--mfb-bg);
2662
+ color: var(--mfb-accent);
2663
+ border-color: var(--mfb-accent);
2664
+ }
2665
+ .screenshot-preview-actions .btn--primary:hover {
2666
+ background: color-mix(in srgb, var(--mfb-accent) 8%, var(--mfb-bg));
2667
+ border-color: var(--mfb-accent);
2668
+ color: var(--mfb-accent);
2669
+ }
2259
2670
  .screenshot-remove {
2671
+ /* Kept for legacy markup that uses the old corner-cross button. */
2260
2672
  position: absolute;
2261
2673
  top: 6px;
2262
2674
  right: 6px;
@@ -2274,6 +2686,7 @@ var WIDGET_STYLES = `
2274
2686
  }
2275
2687
  .screenshot-remove:hover { background: #fff; }
2276
2688
  .screenshot-annotate {
2689
+ /* Kept for legacy; new markup uses .screenshot-preview-actions. */
2277
2690
  border-radius: 0;
2278
2691
  border-width: 0;
2279
2692
  border-top: 1px solid var(--mfb-border);
@@ -2377,11 +2790,31 @@ var WIDGET_STYLES = `
2377
2790
  cursor: pointer;
2378
2791
  padding: 0;
2379
2792
  transition: transform 120ms ease;
2793
+ position: relative;
2380
2794
  }
2381
2795
  .annotator-color.is-active {
2382
2796
  transform: scale(1.12);
2383
2797
  border-color: var(--mfb-text);
2384
2798
  }
2799
+ /* Color-picker swatch: rainbow gradient fill so users see this isn't
2800
+ a preset, plus a tiny native <input type="color"> overlaid invisibly. */
2801
+ .annotator-color-picker {
2802
+ display: grid;
2803
+ place-items: center;
2804
+ background: conic-gradient(from 180deg, #ef4444, #f59e0b, #10b981, #3b82f6, #8b5cf6, #ec4899, #ef4444);
2805
+ overflow: hidden;
2806
+ }
2807
+ .annotator-color-picker input[type="color"] {
2808
+ position: absolute;
2809
+ inset: 0;
2810
+ width: 100%;
2811
+ height: 100%;
2812
+ border: 0;
2813
+ padding: 0;
2814
+ margin: 0;
2815
+ opacity: 0;
2816
+ cursor: pointer;
2817
+ }
2385
2818
  .annotator-btn {
2386
2819
  display: inline-flex;
2387
2820
  align-items: center;
@@ -2431,37 +2864,39 @@ var WIDGET_STYLES = `
2431
2864
 
2432
2865
  .tab-strip {
2433
2866
  display: flex;
2434
- gap: 4px;
2867
+ gap: var(--mfb-space-1);
2435
2868
  border-bottom: 1px solid var(--mfb-border);
2436
- margin: 0 -4px 4px;
2437
- padding: 0 4px;
2869
+ margin: 0;
2870
+ padding: 0;
2438
2871
  }
2439
2872
  .tab-button {
2440
2873
  appearance: none;
2441
2874
  background: transparent;
2442
2875
  border: 0;
2443
2876
  border-bottom: 2px solid transparent;
2444
- padding: 8px 12px;
2877
+ padding: var(--mfb-space-3) var(--mfb-space-4);
2878
+ margin-bottom: -1px;
2445
2879
  font: inherit;
2446
- font-size: 13px;
2880
+ font-size: var(--mfb-text-sm);
2447
2881
  font-weight: 500;
2448
2882
  color: var(--mfb-text-muted);
2449
2883
  cursor: pointer;
2884
+ transition: color 120ms ease, border-color 120ms ease;
2450
2885
  }
2451
2886
  .tab-button:hover { color: var(--mfb-text); }
2452
2887
  .tab-button.is-active {
2453
- color: var(--mfb-text);
2888
+ color: var(--mfb-accent);
2454
2889
  border-bottom-color: var(--mfb-accent);
2455
2890
  }
2456
2891
  .tab-button[aria-selected="true"] { font-weight: 600; }
2457
2892
 
2458
- .mine-list { display: flex; flex-direction: column; gap: 10px; }
2893
+ .mine-list { display: flex; flex-direction: column; gap: var(--mfb-space-3); }
2459
2894
  .mine-list-header {
2460
2895
  display: flex;
2461
2896
  align-items: center;
2462
2897
  justify-content: space-between;
2463
2898
  }
2464
- .mine-list-header h2 { margin: 0; font-size: 15px; font-weight: 600; }
2899
+ .mine-list-header h2 { margin: 0; font-size: var(--mfb-text-lg); font-weight: 600; letter-spacing: -0.01em; }
2465
2900
  .mine-loading { color: var(--mfb-text-muted); font-size: 13px; }
2466
2901
  .mine-empty {
2467
2902
  text-align: center;
@@ -3142,8 +3577,8 @@ function createFeedback(config) {
3142
3577
  capture_method: screenshot ? manualScreenshot ? "manual" : "html2canvas" : "none",
3143
3578
  technical_context
3144
3579
  };
3145
- if ("0.9.1") {
3146
- payload.widget_version = "0.9.1";
3580
+ if ("0.11.0") {
3581
+ payload.widget_version = "0.11.0";
3147
3582
  }
3148
3583
  if (screenshot) payload.screenshot = screenshot;
3149
3584
  if (values.synthetic) payload.synthetic = true;
@@ -3229,4 +3664,4 @@ function createFeedback(config) {
3229
3664
  export {
3230
3665
  createFeedback
3231
3666
  };
3232
- //# sourceMappingURL=chunk-MDLVMRQV.mjs.map
3667
+ //# sourceMappingURL=chunk-AO3BXEG5.mjs.map