@realuptime/errors 0.4.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.
package/feedback.ts ADDED
@@ -0,0 +1,617 @@
1
+ import { scrubString } from "./scrub.ts";
2
+ import {
3
+ MAX_FEEDBACK_COMMENTS_LENGTH,
4
+ MAX_FEEDBACK_EMAIL_LENGTH,
5
+ MAX_FEEDBACK_NAME_LENGTH,
6
+ type WireFeedback,
7
+ } from "./types.ts";
8
+
9
+ /**
10
+ * The user feedback dialog (REA-257): a tiny, dependency-free "tell us what
11
+ * happened" form a browser app can show after an error, posting the user's
12
+ * words to `POST /api/errors/v1/feedback/<key>` where they attach to the
13
+ * exact occurrence they describe.
14
+ *
15
+ * ## What this is deliberately NOT
16
+ *
17
+ * - Not on by default. Nothing here runs unless the integrator passes
18
+ * `init({ feedback: {...} })` AND the project's feedback switch is on in
19
+ * realuptime Errors settings. Either one missing means no dialog, no
20
+ * ids on the wire, no endpoint accepting anything.
21
+ * - Not a bypass of the scrub. `comments`, `name` and `email` pass the
22
+ * same five pattern rules every other captured string passes, HERE,
23
+ * before serialization, and the server applies them again. A card
24
+ * number typed into "what happened" never lands in our store.
25
+ * - Not a tracker. No cookies, no storage, no fingerprinting; the
26
+ * request carries exactly the four fields in WireFeedback.
27
+ *
28
+ * ## CSP-friendly, on purpose
29
+ *
30
+ * Every style is applied through the CSSOM (`element.style.setProperty`),
31
+ * never through a `<style>` element or a `style="..."` attribute string,
32
+ * so a page running `style-src 'self'` (no 'unsafe-inline') renders the
33
+ * dialog exactly the same. No inline script, no eval, no external asset.
34
+ * Integrators who want their own look get stable class names
35
+ * (`ru-feedback-*`) and a `theme` object; both are honored together.
36
+ *
37
+ * ## Accessible, on purpose
38
+ *
39
+ * `role="dialog"` + `aria-modal`, labelled by its title, focus moves to
40
+ * the first field on open and back to the opener on close, Tab cycles
41
+ * inside the dialog, Escape closes, every field has a real `<label>`, and
42
+ * validation errors are announced through an `aria-live` region.
43
+ *
44
+ * Like the rest of the SDK: never throws. Every public function catches
45
+ * everything and fails quiet.
46
+ */
47
+
48
+ export interface ReportDialogField {
49
+ /** Visible label. */
50
+ label?: string;
51
+ /** Prefilled value (e.g. the signed-in user's email). */
52
+ value?: string;
53
+ /** Whether the field must be filled before submit. `comments` is always
54
+ * required; this flag is read for name and email only. */
55
+ required?: boolean;
56
+ /** Hide the field entirely. A hidden field is never sent. */
57
+ hidden?: boolean;
58
+ }
59
+
60
+ export interface ReportDialogTheme {
61
+ background: string;
62
+ foreground: string;
63
+ muted: string;
64
+ border: string;
65
+ accent: string;
66
+ accentForeground: string;
67
+ error: string;
68
+ fontFamily: string;
69
+ borderRadius: string;
70
+ overlay: string;
71
+ }
72
+
73
+ export interface ReportDialogOptions {
74
+ /** The event to attach to. Defaults to the last captured event's id. */
75
+ eventId?: string;
76
+ title?: string;
77
+ subtitle?: string;
78
+ submitLabel?: string;
79
+ cancelLabel?: string;
80
+ /** Shown (in place of the form) after a successful submit. */
81
+ successMessage?: string;
82
+ /** Shown when the report could not be delivered. */
83
+ errorMessage?: string;
84
+ fields?: {
85
+ name?: ReportDialogField;
86
+ email?: ReportDialogField;
87
+ comments?: ReportDialogField;
88
+ };
89
+ theme?: Partial<ReportDialogTheme>;
90
+ /** Called after a successful delivery. */
91
+ onSubmit?: (feedback: { eventId: string }) => void;
92
+ /** Called whenever the dialog closes, submitted or not. */
93
+ onClose?: () => void;
94
+ }
95
+
96
+ /** `init({ feedback })`: the integrator's standing configuration. Presence
97
+ * of the object is the opt-in; `autoShow` additionally opens the dialog on
98
+ * its own after an UNHANDLED browser exception. */
99
+ export interface FeedbackOptions extends Omit<ReportDialogOptions, "eventId"> {
100
+ /** Open the dialog automatically after an unhandled window error or
101
+ * rejection. Default false: even with feedback enabled, most apps want to
102
+ * decide when to ask. */
103
+ autoShow?: boolean;
104
+ }
105
+
106
+ export const DEFAULT_THEME: ReportDialogTheme = {
107
+ background: "#ffffff",
108
+ foreground: "#111827",
109
+ muted: "#6b7280",
110
+ border: "#d1d5db",
111
+ accent: "#5b4de6",
112
+ accentForeground: "#ffffff",
113
+ error: "#b91c1c",
114
+ fontFamily: "system-ui, -apple-system, Segoe UI, Roboto, sans-serif",
115
+ borderRadius: "10px",
116
+ overlay: "rgba(17, 24, 39, 0.55)",
117
+ };
118
+
119
+ export const DEFAULT_COPY = {
120
+ title: "It looks like something went wrong",
121
+ subtitle:
122
+ "Our team has been notified. If you would like to help, tell us what happened below. Anything you type is sent to this site's error tracking.",
123
+ nameLabel: "Name",
124
+ emailLabel: "Email",
125
+ commentsLabel: "What happened?",
126
+ submitLabel: "Send report",
127
+ cancelLabel: "Close",
128
+ successMessage: "Thank you. Your report has been sent.",
129
+ errorMessage: "Your report could not be sent. Please try again.",
130
+ requiredMessage: "Please fill in the required fields.",
131
+ } as const;
132
+
133
+ export const FEEDBACK_RETRY_DELAYS_MS = [1000, 2000, 4000];
134
+
135
+ export interface FeedbackTransport {
136
+ /** Resolves true on acceptance, false on a permanent refusal. Rejects
137
+ * (or resolves false) on a transient failure after retries. */
138
+ send(feedback: WireFeedback): Promise<boolean>;
139
+ }
140
+
141
+ export interface FeedbackDeliveryOptions {
142
+ endpoint: string;
143
+ sdk: string;
144
+ fetchImpl?: typeof fetch;
145
+ /** Test seam: resolves after a delay. */
146
+ wait?: (ms: number) => Promise<void>;
147
+ log?: (message: string) => void;
148
+ }
149
+
150
+ /** Turns an ingest DSN into its feedback sibling:
151
+ * `.../api/errors/v1/ingest/<key>` -> `.../api/errors/v1/feedback/<key>`.
152
+ * Returns null for anything else; the caller then declines to send rather
153
+ * than guessing a URL. */
154
+ export function feedbackEndpointFor(dsn: string): string | null {
155
+ const marker = "/api/errors/v1/ingest/";
156
+ const at = dsn.indexOf(marker);
157
+ if (at < 0) return null;
158
+ const key = dsn.slice(at + marker.length).replace(/[?#].*$/, "");
159
+ if (!key) return null;
160
+ return `${dsn.slice(0, at)}/api/errors/v1/feedback/${key}`;
161
+ }
162
+
163
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
164
+
165
+ /** Mints one client event id. crypto.randomUUID where it exists (every
166
+ * modern browser and Node 19+), a getRandomValues-based v4 otherwise. */
167
+ export function mintEventId(): string {
168
+ const c = (globalThis as { crypto?: Crypto }).crypto;
169
+ try {
170
+ if (c && typeof c.randomUUID === "function") return c.randomUUID();
171
+ if (c && typeof c.getRandomValues === "function") {
172
+ const bytes = new Uint8Array(16);
173
+ c.getRandomValues(bytes);
174
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
175
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
176
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
177
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
178
+ }
179
+ } catch {
180
+ // fall through
181
+ }
182
+ // No CSPRNG at all (an exotic embedded runtime): a weak id is still a
183
+ // usable correlation key, and this branch never runs in a browser.
184
+ let hex = "";
185
+ while (hex.length < 32) hex += Math.floor(Math.random() * 16).toString(16);
186
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
187
+ }
188
+
189
+ export function isEventId(value: unknown): value is string {
190
+ return typeof value === "string" && UUID_RE.test(value);
191
+ }
192
+
193
+ function clip(value: string, max: number): string {
194
+ return value.length > max ? value.slice(0, max) : value;
195
+ }
196
+
197
+ /** Builds the wire message: trims, clips, SCRUBS. Pure, exported so the
198
+ * scrub discipline on this path is testable without a DOM. Returns null
199
+ * when there is nothing to send (no comments). */
200
+ export function buildFeedback(
201
+ input: { eventId: string; comments: string; name?: string; email?: string },
202
+ sdk: string,
203
+ ): WireFeedback | null {
204
+ const comments = scrubString(clip(input.comments.trim(), MAX_FEEDBACK_COMMENTS_LENGTH));
205
+ if (!comments) return null;
206
+ const out: WireFeedback = { eventId: input.eventId, comments, sdk };
207
+ const name = input.name?.trim();
208
+ if (name) out.name = scrubString(clip(name, MAX_FEEDBACK_NAME_LENGTH));
209
+ const email = input.email?.trim();
210
+ if (email) out.email = scrubString(clip(email, MAX_FEEDBACK_EMAIL_LENGTH));
211
+ return out;
212
+ }
213
+
214
+ const defaultWait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
215
+
216
+ /**
217
+ * Delivery for one report. A report is a human's one-off, not a stream, so
218
+ * this is simpler than the event Transport: a few short retries on a
219
+ * transient failure (network, 5xx, and a 404 `event_not_found`, which is
220
+ * the event batch still in flight ahead of the report), then give up and
221
+ * tell the dialog so it can say so. Any other 4xx is final.
222
+ */
223
+ export function createFeedbackTransport(options: FeedbackDeliveryOptions): FeedbackTransport {
224
+ const fetchImpl = options.fetchImpl ?? fetch;
225
+ const wait = options.wait ?? defaultWait;
226
+ const log = options.log ?? ((message: string) => console.error(message));
227
+ let logged = false;
228
+ const logOnce = (message: string) => {
229
+ if (logged) return;
230
+ logged = true;
231
+ log(`[realuptime-errors] ${message}`);
232
+ };
233
+ return {
234
+ async send(feedback) {
235
+ for (let attempt = 0; ; attempt++) {
236
+ let response: Response | null = null;
237
+ try {
238
+ response = await fetchImpl(options.endpoint, {
239
+ method: "POST",
240
+ headers: { "content-type": "application/json" },
241
+ body: JSON.stringify(feedback),
242
+ signal: AbortSignal.timeout(10_000),
243
+ });
244
+ } catch {
245
+ response = null;
246
+ }
247
+ if (response?.ok) return true;
248
+ let reason: string | undefined;
249
+ if (response) {
250
+ try {
251
+ reason = ((await response.json()) as { reason?: string }).reason;
252
+ } catch {
253
+ reason = undefined;
254
+ }
255
+ }
256
+ const transient =
257
+ response === null ||
258
+ response.status >= 500 ||
259
+ response.status === 429 ||
260
+ (response.status === 404 && reason === "event_not_found");
261
+ if (!transient) {
262
+ if (reason === "feedback_disabled") {
263
+ logOnce(
264
+ "user feedback is switched off for this project in realuptime Errors settings; the report was not sent. Turn it on there, or remove init({ feedback }).",
265
+ );
266
+ } else if (reason === "key_revoked") {
267
+ logOnce("this project's ingest key was revoked; feedback was not sent.");
268
+ }
269
+ return false;
270
+ }
271
+ if (attempt >= FEEDBACK_RETRY_DELAYS_MS.length) return false;
272
+ await wait(FEEDBACK_RETRY_DELAYS_MS[attempt]);
273
+ }
274
+ },
275
+ };
276
+ }
277
+
278
+ // ── The dialog ──────────────────────────────────────────────────────────
279
+
280
+ export interface DialogDeps {
281
+ document: Document;
282
+ transport: FeedbackTransport;
283
+ sdk: string;
284
+ /** Resolves when the event batch carrying `eventId` has been attempted,
285
+ * so the report rarely arrives ahead of its event. */
286
+ beforeSend?: () => Promise<void>;
287
+ log?: (message: string) => void;
288
+ }
289
+
290
+ export interface ReportDialogHandle {
291
+ close(): void;
292
+ readonly element: HTMLElement;
293
+ }
294
+
295
+ let openDialog: ReportDialogHandle | null = null;
296
+
297
+ /** Whether a dialog is currently open (one at a time, by design). */
298
+ export function isReportDialogOpen(): boolean {
299
+ return openDialog !== null;
300
+ }
301
+
302
+ /** Closes the open dialog, if any. Called by the SDK's `close()`. */
303
+ export function closeReportDialog(): void {
304
+ openDialog?.close();
305
+ }
306
+
307
+ function styles(el: HTMLElement, decl: Record<string, string>): void {
308
+ for (const [prop, value] of Object.entries(decl)) el.style.setProperty(prop, value);
309
+ }
310
+
311
+ /**
312
+ * Renders the dialog into `document.body` and returns a handle. This is
313
+ * the DOM half; `showReportDialog` in index.ts wires it to the SDK state.
314
+ * Exported so the jsdom tests can drive it directly.
315
+ */
316
+ export function openReportDialog(
317
+ options: ReportDialogOptions & { eventId: string },
318
+ deps: DialogDeps,
319
+ ): ReportDialogHandle | null {
320
+ const doc = deps.document;
321
+ if (!doc?.body) return null;
322
+ if (openDialog) return openDialog;
323
+
324
+ const theme: ReportDialogTheme = { ...DEFAULT_THEME, ...(options.theme ?? {}) };
325
+ const fields = options.fields ?? {};
326
+ const opener = doc.activeElement as HTMLElement | null;
327
+ const uid = `ru-feedback-${Math.random().toString(36).slice(2, 8)}`;
328
+
329
+ const overlay = doc.createElement("div");
330
+ overlay.className = "ru-feedback-overlay";
331
+ styles(overlay, {
332
+ position: "fixed",
333
+ inset: "0",
334
+ background: theme.overlay,
335
+ display: "flex",
336
+ "align-items": "center",
337
+ "justify-content": "center",
338
+ "z-index": "2147483000",
339
+ padding: "16px",
340
+ "box-sizing": "border-box",
341
+ });
342
+
343
+ const dialog = doc.createElement("div");
344
+ dialog.className = "ru-feedback-dialog";
345
+ dialog.setAttribute("role", "dialog");
346
+ dialog.setAttribute("aria-modal", "true");
347
+ dialog.setAttribute("aria-labelledby", `${uid}-title`);
348
+ dialog.setAttribute("aria-describedby", `${uid}-subtitle`);
349
+ styles(dialog, {
350
+ background: theme.background,
351
+ color: theme.foreground,
352
+ "font-family": theme.fontFamily,
353
+ "font-size": "15px",
354
+ "line-height": "1.45",
355
+ "border-radius": theme.borderRadius,
356
+ border: `1px solid ${theme.border}`,
357
+ "box-shadow": "0 20px 50px rgba(0,0,0,0.25)",
358
+ width: "100%",
359
+ "max-width": "440px",
360
+ "max-height": "100%",
361
+ overflow: "auto",
362
+ padding: "22px",
363
+ "box-sizing": "border-box",
364
+ });
365
+
366
+ const title = doc.createElement("h2");
367
+ title.id = `${uid}-title`;
368
+ title.className = "ru-feedback-title";
369
+ title.textContent = options.title ?? DEFAULT_COPY.title;
370
+ styles(title, { margin: "0 0 6px", "font-size": "18px", "font-weight": "600" });
371
+
372
+ const subtitle = doc.createElement("p");
373
+ subtitle.id = `${uid}-subtitle`;
374
+ subtitle.className = "ru-feedback-subtitle";
375
+ subtitle.textContent = options.subtitle ?? DEFAULT_COPY.subtitle;
376
+ styles(subtitle, { margin: "0 0 16px", color: theme.muted, "font-size": "14px" });
377
+
378
+ const form = doc.createElement("form");
379
+ form.className = "ru-feedback-form";
380
+ form.setAttribute("novalidate", "");
381
+
382
+ const live = doc.createElement("p");
383
+ live.className = "ru-feedback-status";
384
+ live.setAttribute("role", "status");
385
+ live.setAttribute("aria-live", "polite");
386
+ styles(live, { margin: "0 0 10px", "min-height": "1.2em", color: theme.error, "font-size": "14px" });
387
+
388
+ const inputs: Partial<Record<"name" | "email" | "comments", HTMLInputElement | HTMLTextAreaElement>> = {};
389
+
390
+ function field(
391
+ key: "name" | "email" | "comments",
392
+ labelText: string,
393
+ spec: ReportDialogField | undefined,
394
+ kind: "input" | "textarea",
395
+ type = "text",
396
+ ): void {
397
+ if (spec?.hidden) return;
398
+ const wrap = doc.createElement("div");
399
+ wrap.className = `ru-feedback-field ru-feedback-field-${key}`;
400
+ styles(wrap, { margin: "0 0 12px" });
401
+ const label = doc.createElement("label");
402
+ label.htmlFor = `${uid}-${key}`;
403
+ label.className = "ru-feedback-label";
404
+ const required = key === "comments" || Boolean(spec?.required);
405
+ label.textContent = `${spec?.label ?? labelText}${required ? "" : " (optional)"}`;
406
+ styles(label, { display: "block", "font-size": "13px", "font-weight": "600", margin: "0 0 4px" });
407
+ const control = doc.createElement(kind) as HTMLInputElement | HTMLTextAreaElement;
408
+ control.id = `${uid}-${key}`;
409
+ control.name = key;
410
+ control.className = "ru-feedback-input";
411
+ if (kind === "input") (control as HTMLInputElement).type = type;
412
+ if (kind === "textarea") (control as HTMLTextAreaElement).rows = 4;
413
+ if (key === "email") control.setAttribute("autocomplete", "email");
414
+ if (key === "name") control.setAttribute("autocomplete", "name");
415
+ control.maxLength =
416
+ key === "comments" ? MAX_FEEDBACK_COMMENTS_LENGTH : key === "name" ? MAX_FEEDBACK_NAME_LENGTH : MAX_FEEDBACK_EMAIL_LENGTH;
417
+ if (required) control.setAttribute("aria-required", "true");
418
+ if (spec?.value) control.value = spec.value;
419
+ styles(control, {
420
+ display: "block",
421
+ width: "100%",
422
+ "box-sizing": "border-box",
423
+ padding: "8px 10px",
424
+ font: "inherit",
425
+ color: theme.foreground,
426
+ background: theme.background,
427
+ border: `1px solid ${theme.border}`,
428
+ "border-radius": "6px",
429
+ resize: kind === "textarea" ? "vertical" : "none",
430
+ });
431
+ wrap.appendChild(label);
432
+ wrap.appendChild(control);
433
+ form.appendChild(wrap);
434
+ inputs[key] = control;
435
+ }
436
+
437
+ field("name", DEFAULT_COPY.nameLabel, fields.name, "input", "text");
438
+ field("email", DEFAULT_COPY.emailLabel, fields.email, "input", "email");
439
+ field("comments", DEFAULT_COPY.commentsLabel, fields.comments, "textarea");
440
+
441
+ const actions = doc.createElement("div");
442
+ actions.className = "ru-feedback-actions";
443
+ styles(actions, { display: "flex", gap: "8px", "justify-content": "flex-end", "flex-wrap": "wrap" });
444
+
445
+ const cancel = doc.createElement("button");
446
+ cancel.type = "button";
447
+ cancel.className = "ru-feedback-cancel";
448
+ cancel.textContent = options.cancelLabel ?? DEFAULT_COPY.cancelLabel;
449
+ styles(cancel, {
450
+ font: "inherit",
451
+ padding: "8px 14px",
452
+ "border-radius": "6px",
453
+ border: `1px solid ${theme.border}`,
454
+ background: "transparent",
455
+ color: theme.foreground,
456
+ cursor: "pointer",
457
+ });
458
+
459
+ const submit = doc.createElement("button");
460
+ submit.type = "submit";
461
+ submit.className = "ru-feedback-submit";
462
+ submit.textContent = options.submitLabel ?? DEFAULT_COPY.submitLabel;
463
+ styles(submit, {
464
+ font: "inherit",
465
+ padding: "8px 14px",
466
+ "border-radius": "6px",
467
+ border: `1px solid ${theme.accent}`,
468
+ background: theme.accent,
469
+ color: theme.accentForeground,
470
+ cursor: "pointer",
471
+ });
472
+
473
+ actions.appendChild(cancel);
474
+ actions.appendChild(submit);
475
+ form.appendChild(live);
476
+ form.appendChild(actions);
477
+ dialog.appendChild(title);
478
+ dialog.appendChild(subtitle);
479
+ dialog.appendChild(form);
480
+ overlay.appendChild(dialog);
481
+
482
+ let closed = false;
483
+ const handle: ReportDialogHandle = {
484
+ element: overlay,
485
+ close() {
486
+ if (closed) return;
487
+ closed = true;
488
+ try {
489
+ doc.removeEventListener("keydown", onKeydown, true);
490
+ overlay.remove();
491
+ } catch {
492
+ // never throws
493
+ }
494
+ if (openDialog === handle) openDialog = null;
495
+ try {
496
+ opener?.focus?.();
497
+ } catch {
498
+ // never throws
499
+ }
500
+ try {
501
+ options.onClose?.();
502
+ } catch {
503
+ // integrator callback; never ours to throw
504
+ }
505
+ },
506
+ };
507
+
508
+ function focusables(): HTMLElement[] {
509
+ return Array.from(
510
+ dialog.querySelectorAll<HTMLElement>("input, textarea, button, [tabindex]:not([tabindex='-1'])"),
511
+ ).filter((el) => !el.hasAttribute("disabled"));
512
+ }
513
+
514
+ function onKeydown(event: KeyboardEvent): void {
515
+ if (event.key === "Escape") {
516
+ event.preventDefault();
517
+ handle.close();
518
+ return;
519
+ }
520
+ if (event.key === "Tab") {
521
+ const list = focusables();
522
+ if (list.length === 0) return;
523
+ const first = list[0];
524
+ const last = list[list.length - 1];
525
+ const active = doc.activeElement;
526
+ if (event.shiftKey && (active === first || !dialog.contains(active))) {
527
+ event.preventDefault();
528
+ last.focus();
529
+ } else if (!event.shiftKey && active === last) {
530
+ event.preventDefault();
531
+ first.focus();
532
+ }
533
+ }
534
+ }
535
+
536
+ cancel.addEventListener("click", () => handle.close());
537
+ overlay.addEventListener("click", (event) => {
538
+ if (event.target === overlay) handle.close();
539
+ });
540
+ doc.addEventListener("keydown", onKeydown, true);
541
+
542
+ form.addEventListener("submit", (event) => {
543
+ event.preventDefault();
544
+ void (async () => {
545
+ try {
546
+ const comments = inputs.comments?.value ?? "";
547
+ const name = inputs.name?.value ?? "";
548
+ const email = inputs.email?.value ?? "";
549
+ const missing =
550
+ !comments.trim() ||
551
+ (fields.name?.required && !name.trim()) ||
552
+ (fields.email?.required && !email.trim());
553
+ if (missing) {
554
+ live.textContent = DEFAULT_COPY.requiredMessage;
555
+ (inputs.comments ?? inputs.name ?? inputs.email)?.focus();
556
+ return;
557
+ }
558
+ const feedback = buildFeedback({ eventId: options.eventId, comments, name, email }, deps.sdk);
559
+ if (!feedback) {
560
+ live.textContent = DEFAULT_COPY.requiredMessage;
561
+ return;
562
+ }
563
+ submit.disabled = true;
564
+ cancel.disabled = true;
565
+ live.textContent = "";
566
+ try {
567
+ await deps.beforeSend?.();
568
+ } catch {
569
+ // the event flush's failure is its own problem; still try
570
+ }
571
+ const ok = await deps.transport.send(feedback);
572
+ if (ok) {
573
+ form.remove();
574
+ subtitle.textContent = options.successMessage ?? DEFAULT_COPY.successMessage;
575
+ subtitle.setAttribute("role", "status");
576
+ styles(subtitle, { color: theme.foreground });
577
+ const done = doc.createElement("button");
578
+ done.type = "button";
579
+ done.className = "ru-feedback-done";
580
+ done.textContent = options.cancelLabel ?? DEFAULT_COPY.cancelLabel;
581
+ styles(done, {
582
+ font: "inherit",
583
+ padding: "8px 14px",
584
+ "border-radius": "6px",
585
+ border: `1px solid ${theme.accent}`,
586
+ background: theme.accent,
587
+ color: theme.accentForeground,
588
+ cursor: "pointer",
589
+ });
590
+ done.addEventListener("click", () => handle.close());
591
+ dialog.appendChild(done);
592
+ done.focus();
593
+ try {
594
+ options.onSubmit?.({ eventId: options.eventId });
595
+ } catch {
596
+ // integrator callback
597
+ }
598
+ } else {
599
+ submit.disabled = false;
600
+ cancel.disabled = false;
601
+ live.textContent = options.errorMessage ?? DEFAULT_COPY.errorMessage;
602
+ }
603
+ } catch (failure) {
604
+ try {
605
+ deps.log?.(`[realuptime-errors] feedback dialog failed: ${String(failure)}`);
606
+ } catch {
607
+ // never throws
608
+ }
609
+ }
610
+ })();
611
+ });
612
+
613
+ doc.body.appendChild(overlay);
614
+ openDialog = handle;
615
+ (inputs.name ?? inputs.email ?? inputs.comments)?.focus();
616
+ return handle;
617
+ }