@mhosaic/feedback 0.5.4 → 0.6.1

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.
@@ -0,0 +1,1680 @@
1
+ // src/api/client.ts
2
+ var SCALAR_FIELDS = [
3
+ "description",
4
+ "feedback_type",
5
+ "severity",
6
+ "env",
7
+ "page_url",
8
+ "user_agent",
9
+ "capture_method"
10
+ ];
11
+ function createApiClient(options) {
12
+ const endpoint = (options.endpoint ?? "").replace(/\/+$/, "");
13
+ if (!endpoint) {
14
+ throw new Error(
15
+ '[mhosaic-feedback] `endpoint` is required (e.g. "https://feedback.example.com").'
16
+ );
17
+ }
18
+ const fetcher = options.fetch ?? globalThis.fetch;
19
+ async function submitReport(input) {
20
+ let payload = input;
21
+ if (options.beforeSend) payload = await options.beforeSend(input);
22
+ if (payload === false) throw new Error("Submission cancelled by beforeSend");
23
+ const form = new FormData();
24
+ for (const field of SCALAR_FIELDS) {
25
+ form.append(field, String(payload[field]));
26
+ }
27
+ form.append("technical_context", JSON.stringify(payload.technical_context));
28
+ if (payload.screenshot) form.append("screenshot", payload.screenshot, "screenshot.png");
29
+ if (payload.synthetic) form.append("synthetic", "true");
30
+ const response = await fetcher(`${endpoint}/api/feedback/v1/reports/`, {
31
+ method: "POST",
32
+ headers: { Authorization: `Bearer ${options.apiKey}` },
33
+ body: form
34
+ });
35
+ if (!response.ok) {
36
+ const text = await response.text().catch(() => "");
37
+ throw new Error(`Feedback submit failed: ${response.status} ${text}`);
38
+ }
39
+ return response.json();
40
+ }
41
+ return { submitReport };
42
+ }
43
+
44
+ // src/capture/urlSanitizer.ts
45
+ var SENSITIVE = /token|key|password|secret|auth|session|sig/i;
46
+ function sanitizeUrl(url) {
47
+ try {
48
+ const isAbsolute = /^https?:\/\//i.test(url);
49
+ const isRootRelative = url.startsWith("/");
50
+ if (!isAbsolute && !isRootRelative) return url;
51
+ const base = typeof window !== "undefined" ? window.location.origin : "http://localhost";
52
+ const u = new URL(url, base);
53
+ const clean = new URLSearchParams();
54
+ u.searchParams.forEach((value, name) => {
55
+ clean.set(name, SENSITIVE.test(name) ? "[redacted]" : value);
56
+ });
57
+ u.search = clean.toString();
58
+ return u.toString();
59
+ } catch {
60
+ return url;
61
+ }
62
+ }
63
+
64
+ // src/capture/device.ts
65
+ function collectDevice() {
66
+ const nav = navigator;
67
+ const connection = nav.connection?.effectiveType;
68
+ const deviceMemory = nav.deviceMemory;
69
+ const referrer = document.referrer || void 0;
70
+ return {
71
+ viewport: { w: window.innerWidth, h: window.innerHeight, dpr: window.devicePixelRatio || 1 },
72
+ screen: { w: window.screen.width, h: window.screen.height },
73
+ platform: nav.platform,
74
+ language: nav.language,
75
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
76
+ timezoneOffset: (/* @__PURE__ */ new Date()).getTimezoneOffset(),
77
+ ...connection !== void 0 && { connection },
78
+ online: nav.onLine,
79
+ ...deviceMemory !== void 0 && { deviceMemory },
80
+ hardwareConcurrency: nav.hardwareConcurrency,
81
+ ...referrer !== void 0 && { referrer },
82
+ title: document.title,
83
+ pathname: window.location.pathname
84
+ };
85
+ }
86
+
87
+ // src/capture/console.ts
88
+ function safeStringify(arg) {
89
+ if (arg == null) return String(arg);
90
+ if (typeof arg === "string") return arg;
91
+ if (typeof arg === "number" || typeof arg === "boolean") return String(arg);
92
+ if (arg instanceof Error) return `${arg.name}: ${arg.message}`;
93
+ if (arg instanceof Element) return `<${arg.tagName.toLowerCase()}>`;
94
+ try {
95
+ return JSON.stringify(arg, (_k, v) => typeof v === "bigint" ? v.toString() : v);
96
+ } catch {
97
+ try {
98
+ return String(arg);
99
+ } catch {
100
+ return "[unserializable]";
101
+ }
102
+ }
103
+ }
104
+ function installConsolePatch(buffer) {
105
+ const levels = ["log", "info", "warn", "error", "debug"];
106
+ const originals = {};
107
+ for (const level of levels) {
108
+ const original = console[level];
109
+ if (typeof original !== "function") continue;
110
+ originals[level] = original;
111
+ console[level] = function patched(...args) {
112
+ try {
113
+ const message = args.map(safeStringify).join(" ").slice(0, 2e3);
114
+ const entry = { level, message, ts: Date.now() };
115
+ if (level === "error") {
116
+ const stack = new Error().stack;
117
+ if (stack) entry.stack = stack.split("\n").slice(2, 8).join("\n");
118
+ }
119
+ buffer.push(entry);
120
+ } catch {
121
+ }
122
+ original.apply(console, args);
123
+ };
124
+ }
125
+ return () => {
126
+ for (const [level, fn] of Object.entries(originals)) {
127
+ console[level] = fn;
128
+ }
129
+ };
130
+ }
131
+
132
+ // src/capture/network.ts
133
+ function installFetchPatch(buffer, sanitize) {
134
+ if (typeof window === "undefined" || typeof window.fetch !== "function") return () => {
135
+ };
136
+ const original = window.fetch.bind(window);
137
+ window.fetch = async function patched(input, init) {
138
+ const start = performance.now();
139
+ const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
140
+ const method = (init?.method || (input instanceof Request ? input.method : "GET")).toUpperCase();
141
+ try {
142
+ const response = await original(input, init);
143
+ buffer.push({ url: sanitize(url), method, status: response.status, durationMs: Math.round(performance.now() - start), ts: Date.now() });
144
+ return response;
145
+ } catch (err) {
146
+ buffer.push({
147
+ url: sanitize(url),
148
+ method,
149
+ status: 0,
150
+ durationMs: Math.round(performance.now() - start),
151
+ ts: Date.now(),
152
+ error: err instanceof Error ? err.message : String(err)
153
+ });
154
+ throw err;
155
+ }
156
+ };
157
+ return () => {
158
+ window.fetch = original;
159
+ };
160
+ }
161
+ function installXhrPatch(buffer, sanitize) {
162
+ if (typeof window === "undefined" || typeof window.XMLHttpRequest !== "function") return () => {
163
+ };
164
+ const Original = window.XMLHttpRequest;
165
+ const originalOpen = Original.prototype.open;
166
+ const originalSend = Original.prototype.send;
167
+ Original.prototype.open = function patchedOpen(method, url) {
168
+ this.__mfb = { method: method.toUpperCase(), url: typeof url === "string" ? url : url.toString(), start: performance.now() };
169
+ return originalOpen.apply(this, arguments);
170
+ };
171
+ Original.prototype.send = function patchedSend(body) {
172
+ this.addEventListener("loadend", () => {
173
+ try {
174
+ const ctx = this.__mfb;
175
+ if (!ctx) return;
176
+ buffer.push({
177
+ url: sanitize(ctx.url),
178
+ method: ctx.method,
179
+ status: this.status,
180
+ durationMs: Math.round(performance.now() - ctx.start),
181
+ ts: Date.now()
182
+ });
183
+ } catch {
184
+ }
185
+ });
186
+ return originalSend.call(this, body ?? null);
187
+ };
188
+ return () => {
189
+ Original.prototype.open = originalOpen;
190
+ Original.prototype.send = originalSend;
191
+ };
192
+ }
193
+
194
+ // src/capture/errors.ts
195
+ function installErrorHandlers(buffer) {
196
+ if (typeof window === "undefined") return () => {
197
+ };
198
+ const onError = (e) => {
199
+ const stack = e.error instanceof Error ? e.error.stack : void 0;
200
+ buffer.push({
201
+ message: e.message || "Unknown error",
202
+ ...stack !== void 0 && { stack },
203
+ ts: Date.now(),
204
+ source: "window.error"
205
+ });
206
+ };
207
+ const onRejection = (e) => {
208
+ const reason = e.reason;
209
+ const message = reason instanceof Error ? reason.message : typeof reason === "string" ? reason : (() => {
210
+ try {
211
+ return JSON.stringify(reason);
212
+ } catch {
213
+ return String(reason);
214
+ }
215
+ })();
216
+ const stack = reason instanceof Error ? reason.stack : void 0;
217
+ buffer.push({
218
+ message,
219
+ ...stack !== void 0 && { stack },
220
+ ts: Date.now(),
221
+ source: "unhandledrejection"
222
+ });
223
+ };
224
+ window.addEventListener("error", onError);
225
+ window.addEventListener("unhandledrejection", onRejection);
226
+ return () => {
227
+ window.removeEventListener("error", onError);
228
+ window.removeEventListener("unhandledrejection", onRejection);
229
+ };
230
+ }
231
+
232
+ // src/capture/performance.ts
233
+ function createPerformanceCollector(slowResourceMs = 1e3) {
234
+ const longTasks = [];
235
+ const slowResources = [];
236
+ let observer = null;
237
+ if (typeof PerformanceObserver !== "undefined") {
238
+ try {
239
+ observer = new PerformanceObserver((list) => {
240
+ for (const entry of list.getEntries()) {
241
+ if (entry.entryType === "longtask") {
242
+ longTasks.push({ duration: entry.duration, startTime: entry.startTime });
243
+ while (longTasks.length > 20) longTasks.shift();
244
+ } else if (entry.entryType === "resource") {
245
+ const e = entry;
246
+ if (e.duration > slowResourceMs) {
247
+ slowResources.push({ name: e.name, duration: e.duration, initiatorType: e.initiatorType });
248
+ while (slowResources.length > 20) slowResources.shift();
249
+ }
250
+ }
251
+ }
252
+ });
253
+ observer.observe({ entryTypes: ["longtask", "resource"] });
254
+ } catch {
255
+ }
256
+ }
257
+ return {
258
+ snapshot() {
259
+ const nav = typeof performance !== "undefined" ? performance.getEntriesByType("navigation")[0] : void 0;
260
+ const navigation = nav ? { type: nav.type, duration: nav.duration } : void 0;
261
+ return {
262
+ ...navigation !== void 0 && { navigation },
263
+ longTasks: longTasks.slice(),
264
+ slowResources: slowResources.slice()
265
+ };
266
+ },
267
+ dispose() {
268
+ observer?.disconnect();
269
+ }
270
+ };
271
+ }
272
+
273
+ // src/capture/ringBuffer.ts
274
+ var RingBuffer = class {
275
+ constructor(max) {
276
+ this.max = max;
277
+ }
278
+ max;
279
+ items = [];
280
+ push(item) {
281
+ this.items.push(item);
282
+ while (this.items.length > this.max) this.items.shift();
283
+ }
284
+ snapshot() {
285
+ return this.items.slice();
286
+ }
287
+ clear() {
288
+ this.items.length = 0;
289
+ }
290
+ };
291
+
292
+ // src/capture/index.ts
293
+ function installCapture(options = {}) {
294
+ const { maxConsole = 50, maxNetwork = 50, maxErrors = 20 } = options;
295
+ const sanitize = options.sanitizeUrl ?? sanitizeUrl;
296
+ const consoleBuf = new RingBuffer(maxConsole);
297
+ const networkBuf = new RingBuffer(maxNetwork);
298
+ const errorBuf = new RingBuffer(maxErrors);
299
+ const uninstallConsole = installConsolePatch(consoleBuf);
300
+ const uninstallFetch = installFetchPatch(networkBuf, sanitize);
301
+ const uninstallXhr = installXhrPatch(networkBuf, sanitize);
302
+ const uninstallErrors = installErrorHandlers(errorBuf);
303
+ const perf = createPerformanceCollector();
304
+ return {
305
+ snapshot() {
306
+ return {
307
+ consoleLogs: consoleBuf.snapshot(),
308
+ networkRequests: networkBuf.snapshot(),
309
+ errors: errorBuf.snapshot(),
310
+ device: collectDevice(),
311
+ capturedAt: Date.now()
312
+ };
313
+ },
314
+ clear() {
315
+ consoleBuf.clear();
316
+ networkBuf.clear();
317
+ errorBuf.clear();
318
+ },
319
+ dispose() {
320
+ uninstallConsole();
321
+ uninstallFetch();
322
+ uninstallXhr();
323
+ uninstallErrors();
324
+ perf.dispose();
325
+ }
326
+ };
327
+ }
328
+
329
+ // src/screenshot/index.ts
330
+ function isMaskedElement(el) {
331
+ return el.hasAttribute("data-mfb-mask") || el.classList.contains("mfb-mask");
332
+ }
333
+ function prepareMaskMatcher(selectors) {
334
+ const joined = selectors.join(",").trim();
335
+ if (!joined) return isMaskedElement;
336
+ return (el) => isMaskedElement(el) || el.matches(joined);
337
+ }
338
+ async function takeScreenshot(target, options = {}) {
339
+ if (typeof document === "undefined") return null;
340
+ try {
341
+ const html2canvas = (await import("html2canvas-pro")).default;
342
+ const matcher = prepareMaskMatcher(options.mask ?? []);
343
+ const canvas = await html2canvas(target, {
344
+ ignoreElements: (el) => matcher(el),
345
+ useCORS: true,
346
+ logging: false,
347
+ backgroundColor: null
348
+ });
349
+ return await new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
350
+ } catch {
351
+ return null;
352
+ }
353
+ }
354
+
355
+ // src/widget/i18n.ts
356
+ var DEFAULT_STRINGS = {
357
+ "fab.label": "Send feedback",
358
+ "form.title": "Send feedback",
359
+ "form.description.label": "What happened?",
360
+ "form.description.placeholder": "Describe the issue or idea in one or two sentences.",
361
+ "form.type.label": "Type",
362
+ "form.severity.label": "Severity",
363
+ "form.submit": "Send",
364
+ "form.cancel": "Cancel",
365
+ "form.close": "Close",
366
+ "form.submitting": "Sending\u2026",
367
+ "form.success": "Thanks \u2014 your feedback was sent.",
368
+ "form.error": "Could not send. Please try again.",
369
+ "form.screenshot.label": "Screenshot",
370
+ "form.screenshot.cta_click": "Click",
371
+ "form.screenshot.cta_rest": "drop, or paste an image",
372
+ "form.screenshot.formats": "PNG, JPEG or WebP \u2014 up to 10 MB",
373
+ "form.screenshot.remove": "Remove screenshot",
374
+ "form.screenshot.annotate": "Annotate",
375
+ "form.screenshot.error_type": "Unsupported file type. Use PNG, JPEG or WebP.",
376
+ "form.screenshot.error_size": "File too large (max {max} MB).",
377
+ "form.context.label": "Page",
378
+ "type.bug": "Bug",
379
+ "type.feature": "Feature request",
380
+ "type.question": "Question",
381
+ "type.praise": "Praise",
382
+ "type.typo": "Typo",
383
+ "severity.blocker": "Blocker",
384
+ "severity.high": "High",
385
+ "severity.medium": "Medium",
386
+ "severity.low": "Low",
387
+ "annotator.title": "Annotate screenshot",
388
+ "annotator.tool.rectangle": "Rectangle",
389
+ "annotator.tool.arrow": "Arrow",
390
+ "annotator.tool.freehand": "Freehand",
391
+ "annotator.tool.text": "Text",
392
+ "annotator.text_prompt": "Enter text:",
393
+ "annotator.undo": "Undo",
394
+ "annotator.clear": "Clear all",
395
+ "annotator.count_suffix": "annotations",
396
+ "annotator.loading": "Loading\u2026",
397
+ "annotator.apply": "Apply",
398
+ "annotator.applying": "Applying\u2026"
399
+ };
400
+ var FRENCH_STRINGS = {
401
+ "fab.label": "Envoyer un commentaire",
402
+ "form.title": "Envoyer un commentaire",
403
+ "form.description.label": "Qu\u2019est-il arriv\xE9 ?",
404
+ "form.description.placeholder": "D\xE9crivez le probl\xE8me ou l\u2019id\xE9e en une ou deux phrases.",
405
+ "form.type.label": "Type",
406
+ "form.severity.label": "S\xE9v\xE9rit\xE9",
407
+ "form.submit": "Envoyer",
408
+ "form.cancel": "Annuler",
409
+ "form.close": "Fermer",
410
+ "form.submitting": "Envoi\u2026",
411
+ "form.success": "Merci \u2014 votre commentaire a \xE9t\xE9 envoy\xE9.",
412
+ "form.error": "\xC9chec d\u2019envoi. Veuillez r\xE9essayer.",
413
+ "form.screenshot.label": "Capture d\u2019\xE9cran",
414
+ "form.screenshot.cta_click": "Cliquez",
415
+ "form.screenshot.cta_rest": "d\xE9posez ou collez une image",
416
+ "form.screenshot.formats": "PNG, JPEG ou WebP \u2014 jusqu\u2019\xE0 10 Mo",
417
+ "form.screenshot.remove": "Retirer la capture",
418
+ "form.screenshot.annotate": "Annoter",
419
+ "form.screenshot.error_type": "Format non support\xE9. Utilisez PNG, JPEG ou WebP.",
420
+ "form.screenshot.error_size": "Fichier trop volumineux (max {max} Mo).",
421
+ "form.context.label": "Page",
422
+ "type.bug": "Bogue",
423
+ "type.feature": "Suggestion",
424
+ "type.question": "Question",
425
+ "type.praise": "Compliment",
426
+ "type.typo": "Coquille",
427
+ "severity.blocker": "Bloquant",
428
+ "severity.high": "\xC9lev\xE9e",
429
+ "severity.medium": "Moyenne",
430
+ "severity.low": "Faible",
431
+ "annotator.title": "Annoter la capture",
432
+ "annotator.tool.rectangle": "Rectangle",
433
+ "annotator.tool.arrow": "Fl\xE8che",
434
+ "annotator.tool.freehand": "Dessin libre",
435
+ "annotator.tool.text": "Texte",
436
+ "annotator.text_prompt": "Entrez le texte :",
437
+ "annotator.undo": "Annuler",
438
+ "annotator.clear": "Tout effacer",
439
+ "annotator.count_suffix": "annotations",
440
+ "annotator.loading": "Chargement\u2026",
441
+ "annotator.apply": "Appliquer",
442
+ "annotator.applying": "Application\u2026"
443
+ };
444
+ var LOCALE_PACKS = {
445
+ fr: FRENCH_STRINGS
446
+ };
447
+ function packFor(locale) {
448
+ if (!locale) return null;
449
+ const tag = locale.toLowerCase().split(/[-_]/)[0];
450
+ return LOCALE_PACKS[tag] ?? null;
451
+ }
452
+ function resolveStrings(overrides, options = {}) {
453
+ const localePack = packFor(options.locale) ?? {};
454
+ return {
455
+ ...DEFAULT_STRINGS,
456
+ ...localePack,
457
+ ...overrides
458
+ };
459
+ }
460
+
461
+ // src/widget/mount.tsx
462
+ import { h, render } from "preact";
463
+ import { useCallback } from "preact/hooks";
464
+
465
+ // src/widget/Fab.tsx
466
+ import { jsx } from "preact/jsx-runtime";
467
+ function Fab({ label, onClick }) {
468
+ return /* @__PURE__ */ jsx("button", { type: "button", class: "fab", "aria-label": label, onClick, children: "\u{1F4AC}" });
469
+ }
470
+
471
+ // src/widget/Form.tsx
472
+ import { useEffect as useEffect2, useRef as useRef2, useState as useState2 } from "preact/hooks";
473
+
474
+ // src/widget/Annotator.tsx
475
+ import { useEffect, useRef, useState } from "preact/hooks";
476
+ import { jsx as jsx2, jsxs } from "preact/jsx-runtime";
477
+ var COLORS = ["#ef4444", "#f59e0b", "#10b981", "#3b82f6", "#ffffff"];
478
+ function drawShape(ctx, shape) {
479
+ ctx.save();
480
+ ctx.strokeStyle = shape.color;
481
+ ctx.fillStyle = shape.color;
482
+ ctx.lineWidth = shape.lineWidth;
483
+ ctx.lineCap = "round";
484
+ ctx.lineJoin = "round";
485
+ if (shape.kind === "rectangle") {
486
+ ctx.strokeRect(shape.x, shape.y, shape.w, shape.h);
487
+ } else if (shape.kind === "arrow") {
488
+ drawArrow(ctx, shape.x1, shape.y1, shape.x2, shape.y2);
489
+ } else if (shape.kind === "freehand") {
490
+ if (shape.points.length < 2) {
491
+ ctx.restore();
492
+ return;
493
+ }
494
+ ctx.beginPath();
495
+ ctx.moveTo(shape.points[0].x, shape.points[0].y);
496
+ for (let i = 1; i < shape.points.length; i++) {
497
+ ctx.lineTo(shape.points[i].x, shape.points[i].y);
498
+ }
499
+ ctx.stroke();
500
+ } else if (shape.kind === "text") {
501
+ ctx.font = `bold ${shape.fontSize}px -apple-system, BlinkMacSystemFont, sans-serif`;
502
+ ctx.textBaseline = "top";
503
+ const metrics = ctx.measureText(shape.text);
504
+ const padding = 4;
505
+ const w = metrics.width + padding * 2;
506
+ const hh = shape.fontSize + padding * 2;
507
+ ctx.fillStyle = "rgba(0, 0, 0, 0.6)";
508
+ ctx.fillRect(shape.x - padding, shape.y - padding, w, hh);
509
+ ctx.fillStyle = shape.color;
510
+ ctx.fillText(shape.text, shape.x, shape.y);
511
+ }
512
+ ctx.restore();
513
+ }
514
+ function drawArrow(ctx, x1, y1, x2, y2) {
515
+ const headLen = 14;
516
+ const angle = Math.atan2(y2 - y1, x2 - x1);
517
+ ctx.beginPath();
518
+ ctx.moveTo(x1, y1);
519
+ ctx.lineTo(x2, y2);
520
+ ctx.stroke();
521
+ ctx.beginPath();
522
+ ctx.moveTo(x2, y2);
523
+ ctx.lineTo(
524
+ x2 - headLen * Math.cos(angle - Math.PI / 6),
525
+ y2 - headLen * Math.sin(angle - Math.PI / 6)
526
+ );
527
+ ctx.lineTo(
528
+ x2 - headLen * Math.cos(angle + Math.PI / 6),
529
+ y2 - headLen * Math.sin(angle + Math.PI / 6)
530
+ );
531
+ ctx.closePath();
532
+ ctx.fill();
533
+ }
534
+ var Icon = {
535
+ rect: /* @__PURE__ */ jsx2("svg", { width: "16", height: "16", viewBox: "0 0 16 16", "aria-hidden": "true", children: /* @__PURE__ */ jsx2("rect", { x: "2", y: "3", width: "12", height: "10", fill: "none", stroke: "currentColor", "stroke-width": "1.5" }) }),
536
+ arrow: /* @__PURE__ */ jsx2("svg", { width: "16", height: "16", viewBox: "0 0 16 16", "aria-hidden": "true", children: /* @__PURE__ */ jsx2("path", { d: "M2 8h11M9 4l4 4-4 4", fill: "none", stroke: "currentColor", "stroke-width": "1.5", "stroke-linecap": "round", "stroke-linejoin": "round" }) }),
537
+ pencil: /* @__PURE__ */ jsx2("svg", { width: "16", height: "16", viewBox: "0 0 16 16", "aria-hidden": "true", children: /* @__PURE__ */ jsx2("path", { d: "M11.5 2.5l2 2L5 13H3v-2l8.5-8.5z", fill: "none", stroke: "currentColor", "stroke-width": "1.5", "stroke-linejoin": "round" }) }),
538
+ text: /* @__PURE__ */ jsx2("svg", { width: "16", height: "16", viewBox: "0 0 16 16", "aria-hidden": "true", children: /* @__PURE__ */ jsx2("path", { d: "M3 3h10M8 3v10M5 13h6", fill: "none", stroke: "currentColor", "stroke-width": "1.5", "stroke-linecap": "round" }) }),
539
+ undo: /* @__PURE__ */ jsx2("svg", { width: "14", height: "14", viewBox: "0 0 16 16", "aria-hidden": "true", children: /* @__PURE__ */ jsx2("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" }) }),
540
+ trash: /* @__PURE__ */ jsx2("svg", { width: "14", height: "14", viewBox: "0 0 16 16", "aria-hidden": "true", children: /* @__PURE__ */ jsx2("path", { d: "M3 4h10M6 4V2.5h4V4M5 4l.5 9h5L11 4", fill: "none", stroke: "currentColor", "stroke-width": "1.5", "stroke-linecap": "round", "stroke-linejoin": "round" }) }),
541
+ close: /* @__PURE__ */ jsx2("svg", { width: "16", height: "16", viewBox: "0 0 16 16", "aria-hidden": "true", children: /* @__PURE__ */ jsx2("path", { d: "M4 4l8 8M12 4l-8 8", fill: "none", stroke: "currentColor", "stroke-width": "1.5", "stroke-linecap": "round" }) })
542
+ };
543
+ function Annotator({ imageBlob, strings, onSave, onCancel }) {
544
+ const canvasRef = useRef(null);
545
+ const containerRef = useRef(null);
546
+ const imageRef = useRef(null);
547
+ const [tool, setTool] = useState("rectangle");
548
+ const [color, setColor] = useState(COLORS[0]);
549
+ const [shapes, setShapes] = useState([]);
550
+ const isDrawingRef = useRef(false);
551
+ const [draftShape, setDraftShape] = useState(null);
552
+ const [imageLoaded, setImageLoaded] = useState(false);
553
+ const [saving, setSaving] = useState(false);
554
+ const scaleRef = useRef(1);
555
+ useEffect(() => {
556
+ const onKey = (e) => {
557
+ if (e.key === "Escape") {
558
+ e.stopPropagation();
559
+ onCancel();
560
+ }
561
+ };
562
+ window.addEventListener("keydown", onKey);
563
+ return () => window.removeEventListener("keydown", onKey);
564
+ }, [onCancel]);
565
+ useEffect(() => {
566
+ const url = URL.createObjectURL(imageBlob);
567
+ const img = new Image();
568
+ img.onload = () => {
569
+ imageRef.current = img;
570
+ setImageLoaded(true);
571
+ };
572
+ img.src = url;
573
+ return () => URL.revokeObjectURL(url);
574
+ }, [imageBlob]);
575
+ useEffect(() => {
576
+ if (!imageLoaded || !canvasRef.current || !imageRef.current || !containerRef.current) {
577
+ return;
578
+ }
579
+ const img = imageRef.current;
580
+ const container = containerRef.current;
581
+ const maxW = container.clientWidth - 16;
582
+ const maxH = window.innerHeight * 0.6;
583
+ const fitScale = Math.min(maxW / img.width, maxH / img.height, 1);
584
+ scaleRef.current = fitScale;
585
+ const canvas = canvasRef.current;
586
+ canvas.width = img.width;
587
+ canvas.height = img.height;
588
+ canvas.style.width = `${img.width * fitScale}px`;
589
+ canvas.style.height = `${img.height * fitScale}px`;
590
+ redraw();
591
+ }, [imageLoaded]);
592
+ useEffect(() => {
593
+ redraw();
594
+ }, [shapes, draftShape]);
595
+ function redraw() {
596
+ const canvas = canvasRef.current;
597
+ const img = imageRef.current;
598
+ if (!canvas || !img) return;
599
+ const ctx = canvas.getContext("2d");
600
+ if (!ctx) return;
601
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
602
+ ctx.drawImage(img, 0, 0);
603
+ for (const s of shapes) drawShape(ctx, s);
604
+ if (draftShape) drawShape(ctx, draftShape);
605
+ }
606
+ function getCanvasCoords(e) {
607
+ const canvas = canvasRef.current;
608
+ const rect = canvas.getBoundingClientRect();
609
+ return {
610
+ x: (e.clientX - rect.left) / scaleRef.current,
611
+ y: (e.clientY - rect.top) / scaleRef.current
612
+ };
613
+ }
614
+ const handleMouseDown = (e) => {
615
+ if (!imageLoaded) return;
616
+ const { x, y } = getCanvasCoords(e);
617
+ const lineWidth = Math.max(3, Math.round(canvasRef.current.width / 400));
618
+ if (tool === "text") {
619
+ const text = window.prompt(strings["annotator.text_prompt"]);
620
+ if (text && text.trim()) {
621
+ setShapes((prev) => [
622
+ ...prev,
623
+ {
624
+ kind: "text",
625
+ x,
626
+ y,
627
+ text: text.trim(),
628
+ color,
629
+ fontSize: Math.max(16, Math.round(canvasRef.current.width / 50)),
630
+ lineWidth: 1
631
+ }
632
+ ]);
633
+ }
634
+ return;
635
+ }
636
+ isDrawingRef.current = true;
637
+ if (tool === "rectangle") {
638
+ setDraftShape({ kind: "rectangle", x, y, w: 0, h: 0, color, lineWidth });
639
+ } else if (tool === "arrow") {
640
+ setDraftShape({ kind: "arrow", x1: x, y1: y, x2: x, y2: y, color, lineWidth });
641
+ } else if (tool === "freehand") {
642
+ setDraftShape({ kind: "freehand", points: [{ x, y }], color, lineWidth });
643
+ }
644
+ };
645
+ const handleMouseMove = (e) => {
646
+ if (!isDrawingRef.current || !draftShape) return;
647
+ const { x, y } = getCanvasCoords(e);
648
+ if (draftShape.kind === "rectangle") {
649
+ setDraftShape({ ...draftShape, w: x - draftShape.x, h: y - draftShape.y });
650
+ } else if (draftShape.kind === "arrow") {
651
+ setDraftShape({ ...draftShape, x2: x, y2: y });
652
+ } else if (draftShape.kind === "freehand") {
653
+ setDraftShape({ ...draftShape, points: [...draftShape.points, { x, y }] });
654
+ }
655
+ };
656
+ const handleMouseUp = () => {
657
+ if (isDrawingRef.current && draftShape) {
658
+ const isTiny = draftShape.kind === "rectangle" && Math.abs(draftShape.w) < 4 && Math.abs(draftShape.h) < 4 || draftShape.kind === "arrow" && Math.hypot(
659
+ draftShape.x2 - draftShape.x1,
660
+ draftShape.y2 - draftShape.y1
661
+ ) < 4 || draftShape.kind === "freehand" && draftShape.points.length < 3;
662
+ if (!isTiny) {
663
+ setShapes((prev) => [...prev, draftShape]);
664
+ }
665
+ }
666
+ isDrawingRef.current = false;
667
+ setDraftShape(null);
668
+ };
669
+ const handleSave = async () => {
670
+ const canvas = canvasRef.current;
671
+ if (!canvas) return;
672
+ setSaving(true);
673
+ try {
674
+ const blob = await new Promise(
675
+ (resolve) => canvas.toBlob(resolve, "image/png", 0.92)
676
+ );
677
+ if (blob) onSave(blob);
678
+ } finally {
679
+ setSaving(false);
680
+ }
681
+ };
682
+ const tools = [
683
+ { id: "rectangle", icon: Icon.rect, label: strings["annotator.tool.rectangle"] },
684
+ { id: "arrow", icon: Icon.arrow, label: strings["annotator.tool.arrow"] },
685
+ { id: "freehand", icon: Icon.pencil, label: strings["annotator.tool.freehand"] },
686
+ { id: "text", icon: Icon.text, label: strings["annotator.tool.text"] }
687
+ ];
688
+ return /* @__PURE__ */ jsx2(
689
+ "div",
690
+ {
691
+ class: "annotator-backdrop",
692
+ role: "presentation",
693
+ onClick: (e) => {
694
+ if (e.target === e.currentTarget) onCancel();
695
+ },
696
+ children: /* @__PURE__ */ jsxs("div", { class: "annotator", role: "dialog", "aria-modal": "true", "aria-label": strings["annotator.title"], children: [
697
+ /* @__PURE__ */ jsxs("div", { class: "annotator-header", children: [
698
+ /* @__PURE__ */ jsx2("span", { children: strings["annotator.title"] }),
699
+ /* @__PURE__ */ jsx2(
700
+ "button",
701
+ {
702
+ type: "button",
703
+ class: "modal-close",
704
+ "aria-label": strings["form.close"],
705
+ onClick: onCancel,
706
+ children: Icon.close
707
+ }
708
+ )
709
+ ] }),
710
+ /* @__PURE__ */ jsxs("div", { class: "annotator-toolbar", children: [
711
+ /* @__PURE__ */ jsx2("div", { class: "annotator-tools", children: tools.map((t) => /* @__PURE__ */ jsx2(
712
+ "button",
713
+ {
714
+ type: "button",
715
+ onClick: () => setTool(t.id),
716
+ title: t.label,
717
+ "aria-label": t.label,
718
+ "aria-pressed": tool === t.id,
719
+ class: `annotator-tool ${tool === t.id ? "is-active" : ""}`,
720
+ children: t.icon
721
+ },
722
+ t.id
723
+ )) }),
724
+ /* @__PURE__ */ jsx2("span", { class: "annotator-sep" }),
725
+ /* @__PURE__ */ jsx2("div", { class: "annotator-colors", children: COLORS.map((c) => /* @__PURE__ */ jsx2(
726
+ "button",
727
+ {
728
+ type: "button",
729
+ onClick: () => setColor(c),
730
+ "aria-label": c,
731
+ "aria-pressed": color === c,
732
+ class: `annotator-color ${color === c ? "is-active" : ""}`,
733
+ style: { backgroundColor: c }
734
+ },
735
+ c
736
+ )) }),
737
+ /* @__PURE__ */ jsx2("span", { class: "annotator-sep" }),
738
+ /* @__PURE__ */ jsxs(
739
+ "button",
740
+ {
741
+ type: "button",
742
+ class: "annotator-btn",
743
+ onClick: () => setShapes((prev) => prev.slice(0, -1)),
744
+ disabled: shapes.length === 0,
745
+ children: [
746
+ Icon.undo,
747
+ /* @__PURE__ */ jsx2("span", { children: strings["annotator.undo"] })
748
+ ]
749
+ }
750
+ ),
751
+ /* @__PURE__ */ jsxs(
752
+ "button",
753
+ {
754
+ type: "button",
755
+ class: "annotator-btn",
756
+ onClick: () => setShapes([]),
757
+ disabled: shapes.length === 0,
758
+ children: [
759
+ Icon.trash,
760
+ /* @__PURE__ */ jsx2("span", { children: strings["annotator.clear"] })
761
+ ]
762
+ }
763
+ ),
764
+ /* @__PURE__ */ jsx2("span", { class: "annotator-spacer" }),
765
+ /* @__PURE__ */ jsxs("span", { class: "annotator-count", children: [
766
+ shapes.length,
767
+ " ",
768
+ strings["annotator.count_suffix"]
769
+ ] })
770
+ ] }),
771
+ /* @__PURE__ */ jsx2("div", { ref: containerRef, class: "annotator-canvas-wrap", children: !imageLoaded ? /* @__PURE__ */ jsx2("span", { class: "annotator-loading", children: strings["annotator.loading"] }) : /* @__PURE__ */ jsx2(
772
+ "canvas",
773
+ {
774
+ ref: canvasRef,
775
+ onMouseDown: handleMouseDown,
776
+ onMouseMove: handleMouseMove,
777
+ onMouseUp: handleMouseUp,
778
+ onMouseLeave: handleMouseUp,
779
+ class: "annotator-canvas"
780
+ }
781
+ ) }),
782
+ /* @__PURE__ */ jsxs("div", { class: "annotator-footer", children: [
783
+ /* @__PURE__ */ jsx2("button", { type: "button", class: "btn", onClick: onCancel, children: strings["form.cancel"] }),
784
+ /* @__PURE__ */ jsx2(
785
+ "button",
786
+ {
787
+ type: "button",
788
+ class: "btn btn--primary",
789
+ onClick: handleSave,
790
+ disabled: saving || !imageLoaded,
791
+ children: saving ? strings["annotator.applying"] : strings["annotator.apply"]
792
+ }
793
+ )
794
+ ] })
795
+ ] })
796
+ }
797
+ );
798
+ }
799
+
800
+ // src/widget/screenshot-utils.ts
801
+ var ALLOWED_IMAGE_TYPES = [
802
+ "image/png",
803
+ "image/jpeg",
804
+ "image/webp"
805
+ ];
806
+ var MAX_SCREENSHOT_BYTES = 10 * 1024 * 1024;
807
+ function validateScreenshotFile(file) {
808
+ if (!ALLOWED_IMAGE_TYPES.includes(
809
+ file.type
810
+ )) {
811
+ return { kind: "type" };
812
+ }
813
+ if (file.size > MAX_SCREENSHOT_BYTES) {
814
+ return { kind: "size", maxMb: MAX_SCREENSHOT_BYTES / (1024 * 1024) };
815
+ }
816
+ return null;
817
+ }
818
+ function truncateUrl(url, maxLength = 80) {
819
+ if (url.length <= maxLength) return url;
820
+ const keepStart = Math.floor((maxLength - 1) / 2);
821
+ const keepEnd = maxLength - 1 - keepStart;
822
+ return `${url.slice(0, keepStart)}\u2026${url.slice(url.length - keepEnd)}`;
823
+ }
824
+
825
+ // src/widget/Form.tsx
826
+ import { jsx as jsx3, jsxs as jsxs2 } from "preact/jsx-runtime";
827
+ var TYPES = ["bug", "feature", "question", "praise", "typo"];
828
+ var SEVERITIES = ["blocker", "high", "medium", "low"];
829
+ function Form({ strings, onSubmit, onCancel, status, errorMessage }) {
830
+ const [description, setDescription] = useState2("");
831
+ const [feedbackType, setFeedbackType] = useState2("bug");
832
+ const [severity, setSeverity] = useState2("medium");
833
+ const [localError, setLocalError] = useState2("");
834
+ const [screenshotBlob, setScreenshotBlob] = useState2(null);
835
+ const [screenshotPreview, setScreenshotPreview] = useState2(null);
836
+ const [isDragOver, setIsDragOver] = useState2(false);
837
+ const [annotatorOpen, setAnnotatorOpen] = useState2(false);
838
+ const fileInputRef = useRef2(null);
839
+ const dropZoneRef = useRef2(null);
840
+ const submitting = status === "submitting";
841
+ const submitLabel = submitting ? strings["form.submitting"] : strings["form.submit"];
842
+ const pageUrl = typeof window !== "undefined" ? window.location.href : "";
843
+ useEffect2(() => {
844
+ return () => {
845
+ if (screenshotPreview) URL.revokeObjectURL(screenshotPreview);
846
+ };
847
+ }, [screenshotPreview]);
848
+ const acceptFile = (file) => {
849
+ setLocalError("");
850
+ if (file instanceof File) {
851
+ const err = validateScreenshotFile(file);
852
+ if (err) {
853
+ setLocalError(
854
+ err.kind === "type" ? strings["form.screenshot.error_type"] : strings["form.screenshot.error_size"].replace("{max}", String(err.maxMb))
855
+ );
856
+ return;
857
+ }
858
+ }
859
+ if (screenshotPreview) URL.revokeObjectURL(screenshotPreview);
860
+ setScreenshotBlob(file);
861
+ setScreenshotPreview(URL.createObjectURL(file));
862
+ };
863
+ const clearScreenshot = () => {
864
+ if (screenshotPreview) URL.revokeObjectURL(screenshotPreview);
865
+ setScreenshotPreview(null);
866
+ setScreenshotBlob(null);
867
+ setLocalError("");
868
+ if (fileInputRef.current) fileInputRef.current.value = "";
869
+ };
870
+ const handleFileInputChange = (e) => {
871
+ const file = e.target.files?.[0];
872
+ if (file) acceptFile(file);
873
+ };
874
+ const handleDragOver = (e) => {
875
+ e.preventDefault();
876
+ e.stopPropagation();
877
+ setIsDragOver(true);
878
+ };
879
+ const handleDragLeave = (e) => {
880
+ e.preventDefault();
881
+ e.stopPropagation();
882
+ if (e.currentTarget === e.target) setIsDragOver(false);
883
+ };
884
+ const handleDrop = (e) => {
885
+ e.preventDefault();
886
+ e.stopPropagation();
887
+ setIsDragOver(false);
888
+ const file = e.dataTransfer?.files?.[0];
889
+ if (file) acceptFile(file);
890
+ };
891
+ useEffect2(() => {
892
+ const zone = dropZoneRef.current;
893
+ if (!zone) return;
894
+ const onPaste = (e) => {
895
+ const items = e.clipboardData?.items;
896
+ if (!items) return;
897
+ for (const item of Array.from(items)) {
898
+ if (item.kind === "file" && item.type.startsWith("image/")) {
899
+ const file = item.getAsFile();
900
+ if (file) {
901
+ e.preventDefault();
902
+ acceptFile(file);
903
+ return;
904
+ }
905
+ }
906
+ }
907
+ };
908
+ zone.addEventListener("paste", onPaste);
909
+ return () => zone.removeEventListener("paste", onPaste);
910
+ }, [screenshotPreview]);
911
+ const handleAnnotated = (annotated) => {
912
+ if (screenshotPreview) URL.revokeObjectURL(screenshotPreview);
913
+ setScreenshotBlob(annotated);
914
+ setScreenshotPreview(URL.createObjectURL(annotated));
915
+ setAnnotatorOpen(false);
916
+ };
917
+ const handleSubmit = (e) => {
918
+ e.preventDefault();
919
+ if (!description.trim()) {
920
+ setLocalError(strings["form.description.placeholder"]);
921
+ return;
922
+ }
923
+ setLocalError("");
924
+ const values = {
925
+ description: description.trim(),
926
+ feedback_type: feedbackType,
927
+ severity
928
+ };
929
+ if (screenshotBlob) values.screenshot = screenshotBlob;
930
+ onSubmit(values);
931
+ };
932
+ return /* @__PURE__ */ jsxs2("form", { onSubmit: handleSubmit, children: [
933
+ /* @__PURE__ */ jsx3("h2", { children: strings["form.title"] }),
934
+ /* @__PURE__ */ jsxs2("div", { class: "field", children: [
935
+ /* @__PURE__ */ jsx3("label", { for: "mfb-desc", children: strings["form.description.label"] }),
936
+ /* @__PURE__ */ jsx3(
937
+ "textarea",
938
+ {
939
+ id: "mfb-desc",
940
+ value: description,
941
+ placeholder: strings["form.description.placeholder"],
942
+ onInput: (e) => setDescription(e.target.value)
943
+ }
944
+ )
945
+ ] }),
946
+ /* @__PURE__ */ jsxs2("div", { class: "row", children: [
947
+ /* @__PURE__ */ jsxs2("div", { class: "field", children: [
948
+ /* @__PURE__ */ jsx3("label", { for: "mfb-type", children: strings["form.type.label"] }),
949
+ /* @__PURE__ */ jsx3(
950
+ "select",
951
+ {
952
+ id: "mfb-type",
953
+ value: feedbackType,
954
+ onChange: (e) => setFeedbackType(e.target.value),
955
+ children: TYPES.map((t) => /* @__PURE__ */ jsx3("option", { value: t, children: strings[`type.${t}`] }))
956
+ }
957
+ )
958
+ ] }),
959
+ /* @__PURE__ */ jsxs2("div", { class: "field", children: [
960
+ /* @__PURE__ */ jsx3("label", { for: "mfb-sev", children: strings["form.severity.label"] }),
961
+ /* @__PURE__ */ jsx3(
962
+ "select",
963
+ {
964
+ id: "mfb-sev",
965
+ value: severity,
966
+ onChange: (e) => setSeverity(e.target.value),
967
+ children: SEVERITIES.map((s) => /* @__PURE__ */ jsx3("option", { value: s, children: strings[`severity.${s}`] }))
968
+ }
969
+ )
970
+ ] })
971
+ ] }),
972
+ /* @__PURE__ */ jsxs2("div", { class: "field", children: [
973
+ /* @__PURE__ */ jsx3("label", { children: strings["form.screenshot.label"] }),
974
+ /* @__PURE__ */ jsx3(
975
+ "input",
976
+ {
977
+ ref: fileInputRef,
978
+ type: "file",
979
+ accept: "image/png,image/jpeg,image/webp",
980
+ class: "mfb-sr-only",
981
+ onChange: handleFileInputChange,
982
+ "aria-hidden": "true",
983
+ tabIndex: -1
984
+ }
985
+ ),
986
+ screenshotPreview ? /* @__PURE__ */ jsxs2("div", { class: "screenshot-preview", children: [
987
+ /* @__PURE__ */ jsx3("img", { src: screenshotPreview, alt: "" }),
988
+ /* @__PURE__ */ jsx3(
989
+ "button",
990
+ {
991
+ type: "button",
992
+ class: "screenshot-remove",
993
+ onClick: clearScreenshot,
994
+ "aria-label": strings["form.screenshot.remove"],
995
+ children: "\xD7"
996
+ }
997
+ ),
998
+ /* @__PURE__ */ jsx3(
999
+ "button",
1000
+ {
1001
+ type: "button",
1002
+ class: "btn screenshot-annotate",
1003
+ onClick: () => setAnnotatorOpen(true),
1004
+ children: strings["form.screenshot.annotate"]
1005
+ }
1006
+ )
1007
+ ] }) : /* @__PURE__ */ jsxs2(
1008
+ "div",
1009
+ {
1010
+ ref: dropZoneRef,
1011
+ class: `screenshot-dropzone ${isDragOver ? "is-dragover" : ""}`,
1012
+ tabIndex: 0,
1013
+ role: "button",
1014
+ "aria-label": strings["form.screenshot.label"],
1015
+ onClick: () => fileInputRef.current?.click(),
1016
+ onKeyDown: (e) => {
1017
+ if (e.key === "Enter" || e.key === " ") {
1018
+ e.preventDefault();
1019
+ fileInputRef.current?.click();
1020
+ }
1021
+ },
1022
+ onDragOver: handleDragOver,
1023
+ onDragLeave: handleDragLeave,
1024
+ onDrop: handleDrop,
1025
+ children: [
1026
+ /* @__PURE__ */ jsxs2("div", { class: "screenshot-cta", children: [
1027
+ /* @__PURE__ */ jsx3("strong", { children: strings["form.screenshot.cta_click"] }),
1028
+ ", ",
1029
+ strings["form.screenshot.cta_rest"]
1030
+ ] }),
1031
+ /* @__PURE__ */ jsx3("div", { class: "screenshot-formats", children: strings["form.screenshot.formats"] })
1032
+ ]
1033
+ }
1034
+ )
1035
+ ] }),
1036
+ pageUrl && /* @__PURE__ */ jsxs2("div", { class: "page-context", title: pageUrl, children: [
1037
+ /* @__PURE__ */ jsx3("span", { class: "page-context-label", children: strings["form.context.label"] }),
1038
+ /* @__PURE__ */ jsx3("span", { class: "page-context-url", children: truncateUrl(pageUrl, 90) })
1039
+ ] }),
1040
+ localError && /* @__PURE__ */ jsx3("div", { class: "error", children: localError }),
1041
+ status === "error" && errorMessage && /* @__PURE__ */ jsx3("div", { class: "error", children: errorMessage }),
1042
+ status === "success" && /* @__PURE__ */ jsx3("div", { class: "success", children: strings["form.success"] }),
1043
+ /* @__PURE__ */ jsxs2("div", { class: "actions", children: [
1044
+ /* @__PURE__ */ jsx3("button", { type: "button", class: "btn", onClick: onCancel, disabled: submitting, children: strings["form.cancel"] }),
1045
+ /* @__PURE__ */ jsx3("button", { type: "submit", class: "btn btn--primary", disabled: submitting, children: submitLabel })
1046
+ ] }),
1047
+ annotatorOpen && screenshotBlob && /* @__PURE__ */ jsx3(
1048
+ Annotator,
1049
+ {
1050
+ imageBlob: screenshotBlob,
1051
+ strings,
1052
+ onCancel: () => setAnnotatorOpen(false),
1053
+ onSave: handleAnnotated
1054
+ }
1055
+ )
1056
+ ] });
1057
+ }
1058
+
1059
+ // src/widget/Modal.tsx
1060
+ import { useEffect as useEffect3, useRef as useRef3 } from "preact/hooks";
1061
+ import { jsx as jsx4, jsxs as jsxs3 } from "preact/jsx-runtime";
1062
+ function Modal({ onDismiss, children, closeLabel = "Close" }) {
1063
+ const modalRef = useRef3(null);
1064
+ const previouslyFocused = useRef3(null);
1065
+ useEffect3(() => {
1066
+ previouslyFocused.current = document.activeElement;
1067
+ const onKey = (e) => {
1068
+ if (e.key === "Escape") {
1069
+ e.stopPropagation();
1070
+ onDismiss();
1071
+ }
1072
+ };
1073
+ window.addEventListener("keydown", onKey);
1074
+ const first = modalRef.current?.querySelector(
1075
+ "textarea, input, select, button"
1076
+ );
1077
+ first?.focus();
1078
+ return () => {
1079
+ window.removeEventListener("keydown", onKey);
1080
+ const prev = previouslyFocused.current;
1081
+ if (prev && typeof prev.focus === "function") prev.focus();
1082
+ };
1083
+ }, [onDismiss]);
1084
+ return /* @__PURE__ */ jsx4(
1085
+ "div",
1086
+ {
1087
+ class: "backdrop",
1088
+ role: "presentation",
1089
+ onClick: (e) => {
1090
+ if (e.target === e.currentTarget) onDismiss();
1091
+ },
1092
+ children: /* @__PURE__ */ jsxs3("div", { ref: modalRef, class: "modal", role: "dialog", "aria-modal": "true", children: [
1093
+ /* @__PURE__ */ jsx4(
1094
+ "button",
1095
+ {
1096
+ type: "button",
1097
+ class: "modal-close",
1098
+ "aria-label": closeLabel,
1099
+ onClick: onDismiss,
1100
+ children: "\xD7"
1101
+ }
1102
+ ),
1103
+ children
1104
+ ] })
1105
+ }
1106
+ );
1107
+ }
1108
+
1109
+ // src/widget/styles.ts
1110
+ var WIDGET_STYLES = `
1111
+ :host {
1112
+ --mfb-accent: #3b82f6;
1113
+ --mfb-accent-contrast: #ffffff;
1114
+ --mfb-bg: #ffffff;
1115
+ --mfb-surface: #f9fafb;
1116
+ --mfb-text: #0a0a0a;
1117
+ --mfb-text-muted: #6b7280;
1118
+ --mfb-border: #e5e7eb;
1119
+ --mfb-radius: 8px;
1120
+ --mfb-font: system-ui, -apple-system, sans-serif;
1121
+ --mfb-z-index: 2147483640;
1122
+
1123
+ all: initial;
1124
+ font-family: var(--mfb-font);
1125
+ color: var(--mfb-text);
1126
+ position: fixed;
1127
+ z-index: var(--mfb-z-index);
1128
+ }
1129
+
1130
+ @media (prefers-color-scheme: dark) {
1131
+ :host {
1132
+ --mfb-bg: #111827;
1133
+ --mfb-surface: #1f2937;
1134
+ --mfb-text: #f9fafb;
1135
+ --mfb-text-muted: #9ca3af;
1136
+ --mfb-border: #374151;
1137
+ }
1138
+ }
1139
+
1140
+ .fab {
1141
+ position: fixed;
1142
+ bottom: 24px;
1143
+ right: 24px;
1144
+ width: 52px;
1145
+ height: 52px;
1146
+ border-radius: 999px;
1147
+ background: var(--mfb-accent);
1148
+ color: var(--mfb-accent-contrast);
1149
+ border: none;
1150
+ cursor: pointer;
1151
+ box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
1152
+ font-size: 22px;
1153
+ display: grid;
1154
+ place-items: center;
1155
+ transition: transform 120ms ease, box-shadow 120ms ease;
1156
+ }
1157
+
1158
+ .fab:hover { transform: translateY(-1px); box-shadow: 0 6px 18px rgba(0, 0, 0, 0.24); }
1159
+ .fab:active { transform: translateY(0) scale(0.96); box-shadow: 0 3px 10px rgba(0, 0, 0, 0.22); }
1160
+ .fab:focus-visible { outline: 2px solid #fff; outline-offset: 3px; box-shadow: 0 0 0 4px var(--mfb-accent), 0 4px 14px rgba(0, 0, 0, 0.18); }
1161
+ @media (prefers-reduced-motion: reduce) { .fab { transition: none; } .fab:hover, .fab:active { transform: none; } }
1162
+
1163
+ .backdrop {
1164
+ position: fixed;
1165
+ inset: 0;
1166
+ background: rgba(0, 0, 0, 0.45);
1167
+ display: grid;
1168
+ place-items: center;
1169
+ }
1170
+
1171
+ .modal {
1172
+ background: var(--mfb-bg);
1173
+ border-radius: calc(var(--mfb-radius) * 1.5);
1174
+ box-shadow: 0 20px 48px rgba(0, 0, 0, 0.25);
1175
+ width: min(420px, 92vw);
1176
+ padding: 20px;
1177
+ display: flex;
1178
+ flex-direction: column;
1179
+ gap: 12px;
1180
+ position: relative;
1181
+ /* Cap modal height on short viewports (mobile landscape, tiny laptops)
1182
+ so the form scrolls inside the modal rather than overflowing the
1183
+ viewport with no way to reach the actions row. */
1184
+ max-height: calc(100vh - 32px);
1185
+ overflow-y: auto;
1186
+ }
1187
+
1188
+ .modal h2 { margin: 0 0 4px; font-size: 18px; font-weight: 600; padding-right: 28px; }
1189
+
1190
+ .modal-close {
1191
+ position: absolute;
1192
+ top: 8px;
1193
+ right: 8px;
1194
+ width: 32px;
1195
+ height: 32px;
1196
+ display: grid;
1197
+ place-items: center;
1198
+ background: transparent;
1199
+ border: none;
1200
+ border-radius: var(--mfb-radius);
1201
+ color: var(--mfb-text-muted);
1202
+ font: inherit;
1203
+ font-size: 22px;
1204
+ line-height: 1;
1205
+ cursor: pointer;
1206
+ }
1207
+ .modal-close:hover { background: var(--mfb-surface); color: var(--mfb-text); }
1208
+ .modal-close:focus-visible { outline: 2px solid var(--mfb-accent); outline-offset: 2px; }
1209
+
1210
+ .field { display: flex; flex-direction: column; gap: 4px; font-size: 13px; }
1211
+
1212
+ .field label { color: var(--mfb-text-muted); }
1213
+
1214
+ .field input, .field select, .field textarea {
1215
+ font-family: inherit;
1216
+ font-size: 14px;
1217
+ color: inherit;
1218
+ padding: 8px 10px;
1219
+ border: 1px solid var(--mfb-border);
1220
+ border-radius: var(--mfb-radius);
1221
+ background: var(--mfb-surface);
1222
+ transition: border-color 120ms ease, box-shadow 120ms ease;
1223
+ }
1224
+
1225
+ .field input:hover, .field select:hover, .field textarea:hover { border-color: var(--mfb-text-muted); }
1226
+ .field input:focus, .field select:focus, .field textarea:focus {
1227
+ outline: none;
1228
+ border-color: var(--mfb-accent);
1229
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--mfb-accent) 22%, transparent);
1230
+ }
1231
+
1232
+ .field select {
1233
+ -webkit-appearance: none;
1234
+ -moz-appearance: none;
1235
+ appearance: none;
1236
+ padding-right: 28px;
1237
+ background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='none' stroke='%236b7280' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M1 1l4 4 4-4'/></svg>");
1238
+ background-repeat: no-repeat;
1239
+ background-position: right 10px center;
1240
+ }
1241
+
1242
+ .field textarea { min-height: 88px; resize: vertical; }
1243
+
1244
+ .row { display: flex; gap: 8px; }
1245
+ .row > * { flex: 1; }
1246
+
1247
+ .actions { display: flex; gap: 8px; justify-content: flex-end; padding-top: 8px; }
1248
+
1249
+ .btn {
1250
+ padding: 8px 14px;
1251
+ border-radius: var(--mfb-radius);
1252
+ border: 1px solid var(--mfb-border);
1253
+ background: var(--mfb-bg);
1254
+ color: var(--mfb-text);
1255
+ font: inherit;
1256
+ cursor: pointer;
1257
+ }
1258
+
1259
+ .btn--primary {
1260
+ background: var(--mfb-accent);
1261
+ color: var(--mfb-accent-contrast);
1262
+ border-color: var(--mfb-accent);
1263
+ }
1264
+
1265
+ .btn[disabled] { opacity: 0.6; cursor: not-allowed; }
1266
+
1267
+ .error { color: #dc2626; font-size: 13px; }
1268
+ .success { color: #059669; font-size: 13px; }
1269
+
1270
+ /* ---- v0.6.0: manual screenshot upload + annotator -------------------- */
1271
+
1272
+ .mfb-sr-only {
1273
+ position: absolute;
1274
+ width: 1px;
1275
+ height: 1px;
1276
+ padding: 0;
1277
+ margin: -1px;
1278
+ overflow: hidden;
1279
+ clip: rect(0, 0, 0, 0);
1280
+ white-space: nowrap;
1281
+ border: 0;
1282
+ }
1283
+
1284
+ .screenshot-dropzone {
1285
+ border: 1px dashed var(--mfb-border);
1286
+ border-radius: var(--mfb-radius);
1287
+ padding: 14px 12px;
1288
+ text-align: center;
1289
+ cursor: pointer;
1290
+ background: var(--mfb-surface);
1291
+ transition: border-color 120ms ease, background 120ms ease;
1292
+ }
1293
+ .screenshot-dropzone:hover { border-color: var(--mfb-text-muted); }
1294
+ .screenshot-dropzone.is-dragover {
1295
+ border-color: var(--mfb-accent);
1296
+ border-style: solid;
1297
+ background: color-mix(in srgb, var(--mfb-accent) 8%, var(--mfb-surface));
1298
+ }
1299
+ .screenshot-dropzone:focus-visible {
1300
+ outline: 2px solid var(--mfb-accent);
1301
+ outline-offset: 2px;
1302
+ }
1303
+ .screenshot-cta { font-size: 13px; color: var(--mfb-text); }
1304
+ .screenshot-cta strong { color: var(--mfb-accent); font-weight: 600; }
1305
+ .screenshot-formats { font-size: 11px; color: var(--mfb-text-muted); margin-top: 4px; }
1306
+
1307
+ .screenshot-preview {
1308
+ position: relative;
1309
+ border: 1px solid var(--mfb-border);
1310
+ border-radius: var(--mfb-radius);
1311
+ overflow: hidden;
1312
+ background: var(--mfb-surface);
1313
+ display: flex;
1314
+ flex-direction: column;
1315
+ }
1316
+ .screenshot-preview img {
1317
+ display: block;
1318
+ width: 100%;
1319
+ height: auto;
1320
+ max-height: 180px;
1321
+ object-fit: contain;
1322
+ background: #1a1a1a;
1323
+ }
1324
+ .screenshot-remove {
1325
+ position: absolute;
1326
+ top: 6px;
1327
+ right: 6px;
1328
+ width: 26px;
1329
+ height: 26px;
1330
+ display: grid;
1331
+ place-items: center;
1332
+ background: rgba(255, 255, 255, 0.9);
1333
+ border: 1px solid var(--mfb-border);
1334
+ border-radius: 999px;
1335
+ font-size: 18px;
1336
+ line-height: 1;
1337
+ cursor: pointer;
1338
+ color: #111827;
1339
+ }
1340
+ .screenshot-remove:hover { background: #fff; }
1341
+ .screenshot-annotate {
1342
+ border-radius: 0;
1343
+ border-width: 0;
1344
+ border-top: 1px solid var(--mfb-border);
1345
+ background: var(--mfb-bg);
1346
+ }
1347
+ .screenshot-annotate:hover { background: var(--mfb-surface); }
1348
+
1349
+ .page-context {
1350
+ display: flex;
1351
+ align-items: center;
1352
+ gap: 8px;
1353
+ font-size: 11px;
1354
+ color: var(--mfb-text-muted);
1355
+ }
1356
+ .page-context-label {
1357
+ text-transform: uppercase;
1358
+ font-weight: 600;
1359
+ letter-spacing: 0.04em;
1360
+ }
1361
+ .page-context-url {
1362
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
1363
+ background: var(--mfb-surface);
1364
+ padding: 4px 6px;
1365
+ border-radius: 4px;
1366
+ flex: 1;
1367
+ overflow: hidden;
1368
+ text-overflow: ellipsis;
1369
+ white-space: nowrap;
1370
+ }
1371
+
1372
+ /* Annotator modal \u2014 sits above the feedback modal (z-index +1). */
1373
+
1374
+ .annotator-backdrop {
1375
+ position: fixed;
1376
+ inset: 0;
1377
+ background: rgba(0, 0, 0, 0.78);
1378
+ display: grid;
1379
+ place-items: center;
1380
+ z-index: 1;
1381
+ padding: 12px;
1382
+ }
1383
+ .annotator {
1384
+ position: relative;
1385
+ background: var(--mfb-bg);
1386
+ color: var(--mfb-text);
1387
+ border-radius: calc(var(--mfb-radius) * 1.5);
1388
+ width: min(960px, 96vw);
1389
+ max-height: calc(100vh - 24px);
1390
+ display: flex;
1391
+ flex-direction: column;
1392
+ box-shadow: 0 24px 60px rgba(0, 0, 0, 0.4);
1393
+ }
1394
+ .annotator-header {
1395
+ display: flex;
1396
+ align-items: center;
1397
+ justify-content: space-between;
1398
+ padding: 10px 14px;
1399
+ border-bottom: 1px solid var(--mfb-border);
1400
+ font-size: 13px;
1401
+ font-weight: 600;
1402
+ }
1403
+ .annotator-toolbar {
1404
+ display: flex;
1405
+ flex-wrap: wrap;
1406
+ align-items: center;
1407
+ gap: 8px;
1408
+ padding: 8px 14px;
1409
+ border-bottom: 1px solid var(--mfb-border);
1410
+ }
1411
+ .annotator-tools, .annotator-colors { display: flex; gap: 4px; }
1412
+ .annotator-sep {
1413
+ display: inline-block;
1414
+ width: 1px;
1415
+ height: 18px;
1416
+ background: var(--mfb-border);
1417
+ margin: 0 4px;
1418
+ }
1419
+ .annotator-spacer { flex: 1; }
1420
+ .annotator-tool {
1421
+ width: 30px;
1422
+ height: 30px;
1423
+ display: grid;
1424
+ place-items: center;
1425
+ background: var(--mfb-bg);
1426
+ color: var(--mfb-text);
1427
+ border: 1px solid var(--mfb-border);
1428
+ border-radius: var(--mfb-radius);
1429
+ cursor: pointer;
1430
+ }
1431
+ .annotator-tool:hover { background: var(--mfb-surface); }
1432
+ .annotator-tool.is-active {
1433
+ background: var(--mfb-text);
1434
+ color: var(--mfb-bg);
1435
+ border-color: var(--mfb-text);
1436
+ }
1437
+ .annotator-color {
1438
+ width: 24px;
1439
+ height: 24px;
1440
+ border-radius: 999px;
1441
+ border: 2px solid var(--mfb-border);
1442
+ cursor: pointer;
1443
+ padding: 0;
1444
+ transition: transform 120ms ease;
1445
+ }
1446
+ .annotator-color.is-active {
1447
+ transform: scale(1.12);
1448
+ border-color: var(--mfb-text);
1449
+ }
1450
+ .annotator-btn {
1451
+ display: inline-flex;
1452
+ align-items: center;
1453
+ gap: 4px;
1454
+ height: 30px;
1455
+ padding: 0 10px;
1456
+ font: inherit;
1457
+ font-size: 12px;
1458
+ background: var(--mfb-bg);
1459
+ color: var(--mfb-text);
1460
+ border: 1px solid var(--mfb-border);
1461
+ border-radius: var(--mfb-radius);
1462
+ cursor: pointer;
1463
+ }
1464
+ .annotator-btn:hover { background: var(--mfb-surface); }
1465
+ .annotator-btn[disabled] { opacity: 0.5; cursor: not-allowed; }
1466
+ .annotator-count { font-size: 11px; color: var(--mfb-text-muted); }
1467
+ .annotator-canvas-wrap {
1468
+ flex: 1;
1469
+ overflow: auto;
1470
+ background: #1a1a1a;
1471
+ display: flex;
1472
+ align-items: center;
1473
+ justify-content: center;
1474
+ padding: 12px;
1475
+ min-height: 200px;
1476
+ }
1477
+ .annotator-canvas {
1478
+ cursor: crosshair;
1479
+ box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45);
1480
+ background: #fff;
1481
+ }
1482
+ .annotator-loading {
1483
+ color: rgba(255, 255, 255, 0.7);
1484
+ font-size: 13px;
1485
+ }
1486
+ .annotator-footer {
1487
+ display: flex;
1488
+ align-items: center;
1489
+ justify-content: flex-end;
1490
+ gap: 8px;
1491
+ padding: 10px 14px;
1492
+ border-top: 1px solid var(--mfb-border);
1493
+ }
1494
+ `;
1495
+
1496
+ // src/widget/mount.tsx
1497
+ import { Fragment, jsx as jsx5, jsxs as jsxs4 } from "preact/jsx-runtime";
1498
+ function mountWidget(options) {
1499
+ const shadow = options.host.attachShadow({ mode: "open" });
1500
+ const style = document.createElement("style");
1501
+ style.textContent = WIDGET_STYLES;
1502
+ shadow.appendChild(style);
1503
+ const mountPoint = document.createElement("div");
1504
+ shadow.appendChild(mountPoint);
1505
+ let currentState = { open: false, status: "idle" };
1506
+ function rerender(state) {
1507
+ currentState = state;
1508
+ render(h(Root, { state }), mountPoint);
1509
+ }
1510
+ function Root({ state }) {
1511
+ const handleSubmit = useCallback(async (values) => {
1512
+ rerender({ open: true, status: "submitting" });
1513
+ try {
1514
+ await options.onSubmit(values);
1515
+ rerender({ open: true, status: "success" });
1516
+ setTimeout(() => rerender({ open: false, status: "idle" }), 1200);
1517
+ } catch (err) {
1518
+ rerender({ open: true, status: "error", error: err instanceof Error ? err.message : String(err) });
1519
+ }
1520
+ }, []);
1521
+ return /* @__PURE__ */ jsxs4(Fragment, { children: [
1522
+ options.showFAB && /* @__PURE__ */ jsx5(
1523
+ Fab,
1524
+ {
1525
+ label: options.strings["fab.label"],
1526
+ onClick: () => rerender({ ...currentState, open: true })
1527
+ }
1528
+ ),
1529
+ state.open && /* @__PURE__ */ jsx5(
1530
+ Modal,
1531
+ {
1532
+ onDismiss: () => rerender({ open: false, status: "idle" }),
1533
+ closeLabel: options.strings["form.close"],
1534
+ children: /* @__PURE__ */ jsx5(
1535
+ Form,
1536
+ {
1537
+ strings: options.strings,
1538
+ onSubmit: handleSubmit,
1539
+ onCancel: () => rerender({ open: false, status: "idle" }),
1540
+ status: state.status,
1541
+ ...state.error !== void 0 && { errorMessage: state.error }
1542
+ }
1543
+ )
1544
+ }
1545
+ )
1546
+ ] });
1547
+ }
1548
+ rerender(currentState);
1549
+ return {
1550
+ open() {
1551
+ rerender({ ...currentState, open: true });
1552
+ },
1553
+ close() {
1554
+ rerender({ ...currentState, open: false, status: "idle" });
1555
+ },
1556
+ dispose() {
1557
+ render(null, mountPoint);
1558
+ options.host.innerHTML = "";
1559
+ }
1560
+ };
1561
+ }
1562
+
1563
+ // src/core.ts
1564
+ function createFeedback(config) {
1565
+ const env = config.env ?? "prod";
1566
+ const locale = config.locale ?? (typeof navigator !== "undefined" ? navigator.language : void 0);
1567
+ const strings = resolveStrings(
1568
+ config.translations ?? {},
1569
+ locale !== void 0 ? { locale } : {}
1570
+ );
1571
+ const capture = installCapture({
1572
+ ...config.sanitizeUrl !== void 0 && { sanitizeUrl: config.sanitizeUrl }
1573
+ });
1574
+ const api = createApiClient({
1575
+ apiKey: config.apiKey,
1576
+ endpoint: config.endpoint,
1577
+ ...config.fetchImpl !== void 0 && { fetch: config.fetchImpl },
1578
+ ...config.beforeSend !== void 0 && { beforeSend: config.beforeSend }
1579
+ });
1580
+ let user = config.user;
1581
+ let metadata = config.metadata ?? {};
1582
+ const transformers = [];
1583
+ const host = document.createElement("div");
1584
+ host.className = "mhosaic-feedback";
1585
+ if (config.attachTo) {
1586
+ const attach = typeof config.attachTo === "string" ? document.querySelector(config.attachTo) : config.attachTo;
1587
+ attach?.appendChild(host);
1588
+ } else {
1589
+ document.body.appendChild(host);
1590
+ }
1591
+ async function buildAndSubmit(values) {
1592
+ const manualScreenshot = values.screenshot;
1593
+ const screenshot = values.synthetic ? void 0 : manualScreenshot ?? await takeScreenshot(document.body, {
1594
+ mask: [".mhosaic-feedback", "[data-mfb-mask]"]
1595
+ });
1596
+ const technical_context = capture.snapshot();
1597
+ if (user) technical_context.user = user;
1598
+ if (metadata && Object.keys(metadata).length > 0) {
1599
+ technical_context.metadata = { ...metadata };
1600
+ }
1601
+ const payload = {
1602
+ description: values.description,
1603
+ feedback_type: values.feedback_type ?? "bug",
1604
+ severity: values.severity ?? "medium",
1605
+ env,
1606
+ page_url: window.location.href,
1607
+ user_agent: navigator.userAgent,
1608
+ capture_method: screenshot ? manualScreenshot ? "manual" : "html2canvas" : "none",
1609
+ technical_context
1610
+ };
1611
+ if (screenshot) payload.screenshot = screenshot;
1612
+ if (values.synthetic) payload.synthetic = true;
1613
+ let finalPayload = payload;
1614
+ for (const t of transformers) finalPayload = await t(finalPayload);
1615
+ try {
1616
+ const result = await api.submitReport(finalPayload);
1617
+ config.onSubmitSuccess?.(result);
1618
+ capture.clear();
1619
+ return result;
1620
+ } catch (err) {
1621
+ const error = err instanceof Error ? err : new Error(String(err));
1622
+ config.onError?.(error);
1623
+ throw error;
1624
+ }
1625
+ }
1626
+ const handle = mountWidget({
1627
+ host,
1628
+ strings,
1629
+ showFAB: config.showFAB ?? true,
1630
+ onSubmit: async (values) => {
1631
+ await buildAndSubmit(values);
1632
+ }
1633
+ });
1634
+ const instance = {
1635
+ show() {
1636
+ handle.open();
1637
+ },
1638
+ hide() {
1639
+ handle.close();
1640
+ },
1641
+ open(opts) {
1642
+ handle.open();
1643
+ void opts;
1644
+ },
1645
+ async submit(partial) {
1646
+ return buildAndSubmit({
1647
+ description: partial.description,
1648
+ ...partial.feedback_type !== void 0 && { feedback_type: partial.feedback_type },
1649
+ ...partial.severity !== void 0 && { severity: partial.severity },
1650
+ ...partial.synthetic !== void 0 && { synthetic: partial.synthetic },
1651
+ ...partial.screenshot !== void 0 && { screenshot: partial.screenshot }
1652
+ });
1653
+ },
1654
+ identify(u) {
1655
+ user = u;
1656
+ },
1657
+ setMetadata(kv) {
1658
+ metadata = { ...metadata, ...kv };
1659
+ },
1660
+ shutdown() {
1661
+ handle.dispose();
1662
+ capture.dispose();
1663
+ host.remove();
1664
+ const w = window;
1665
+ if (w.mhosaicFeedback === instance) {
1666
+ delete w.mhosaicFeedback;
1667
+ }
1668
+ },
1669
+ _registerTransformer(fn) {
1670
+ transformers.push(fn);
1671
+ }
1672
+ };
1673
+ window.mhosaicFeedback = instance;
1674
+ return instance;
1675
+ }
1676
+
1677
+ export {
1678
+ createFeedback
1679
+ };
1680
+ //# sourceMappingURL=chunk-LGGKJPFH.mjs.map