@alliance-droid/status-feedback-system 2.3.2 → 3.1.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,585 +1,589 @@
1
- <script lang="ts">
2
- import { onMount, onDestroy } from 'svelte';
3
- import { createStatusClient, type StatusClient } from '../api.js';
4
- import PasteImageInput from './shared/PasteImageInput.svelte';
5
- import {
6
- Card,
7
- Button,
8
- Input,
9
- Textarea,
10
- Badge,
11
- Spinner,
12
- } from '@alliance-droid/svelte-component-library';
13
- import DevContextMenu from './dev/DevContextMenu.svelte';
14
- import DevOnboardingToast from './dev/DevOnboardingToast.svelte';
15
- import {
16
- detectEnvironment,
17
- getEnvironmentName,
18
- getEnvVar,
19
- getElementSelector,
20
- getElementHtml,
21
- } from './dev/environment.js';
22
-
23
- interface Props {
24
- apiUrl: string;
25
- apiKey: string;
26
- /** Explicit enable/disable — overrides auto-detection */
27
- enabled?: boolean | undefined;
28
- /** Max console errors to capture */
29
- maxConsoleErrors?: number;
30
- /** Show onboarding toast on first visit */
31
- showOnboarding?: boolean;
32
- /** URL to the feedback board page. If omitted, "View Open Issues" is hidden from the context menu. */
33
- boardUrl?: string;
34
- /**
35
- * Authenticated user email. Required.
36
- *
37
- * Pass an empty string when the user is not signed in — the widget will
38
- * render nothing (no floating bubble, no menu, no form). Anonymous
39
- * feedback is not supported.
40
- */
41
- userEmail: string;
42
- /**
43
- * Endpoint on the consumer app that issues render tokens.
44
- * Default: '/svc/internal/issue-render-token'
45
- */
46
- renderTokenEndpoint?: string;
47
- }
48
-
49
- let {
50
- apiUrl,
51
- apiKey,
52
- enabled = undefined,
53
- maxConsoleErrors = 20,
54
- showOnboarding = true,
55
- boardUrl,
56
- userEmail,
57
- renderTokenEndpoint = '/svc/internal/issue-render-token',
58
- }: Props = $props();
59
-
60
- // Dev-mode warning when userEmail is empty
61
- $effect(() => {
62
- if (!userEmail && typeof console !== 'undefined') {
63
- // eslint-disable-next-line no-restricted-globals -- safe typeof guard for universal code
64
- const proc = typeof globalThis !== 'undefined' ? (globalThis as Record<string, unknown>).process as { env?: Record<string, string> } | undefined : undefined;
65
- const env = proc?.env?.NODE_ENV || 'development';
66
- if (env !== 'production') {
67
- console.warn(
68
- '[status-feedback-system] <DevFeedback> was rendered with an empty userEmail. ' +
69
- "The widget will not appear. Pass the authenticated user's email, or do not render <DevFeedback> when no user is signed in.",
70
- );
71
- }
72
- }
73
- });
74
-
75
- // --- Client (lazy, stable) ---
76
- let client: StatusClient | null = $state(null);
77
- let lastApiUrl = '';
78
- let lastApiKey = '';
79
-
80
- function getClient(): StatusClient {
81
- if (!client || lastApiUrl !== apiUrl || lastApiKey !== apiKey) {
82
- client = createStatusClient({ apiUrl, apiKey });
83
- lastApiUrl = apiUrl;
84
- lastApiKey = apiKey;
85
- }
86
- return client;
87
- }
88
-
89
- // --- Environment gate ---
90
- let isEnabled = $state(false);
91
- let hasUserEmail = $derived(!!userEmail);
92
- const isMac = $derived(
93
- typeof navigator !== 'undefined' &&
94
- /Mac|iPhone|iPad|iPod/i.test(navigator.platform || navigator.userAgent || '')
95
- );
96
-
97
- // --- State ---
98
- let menuOpen = $state(false);
99
- let menuX = $state(0);
100
- let menuY = $state(0);
101
- let clickedElement = $state<HTMLElement | null>(null);
102
-
103
- let formOpen = $state(false);
104
- let formPopoverEl: HTMLDivElement | undefined = $state();
105
- let formOriginalParent: HTMLElement | null = null;
106
- let formPortalTarget: HTMLElement | null = null;
107
-
108
- /**
109
- * Find the topmost open modal dialog.
110
- * showModal() makes everything outside the dialog inert — including
111
- * top-layer popovers that aren't descendants of the dialog.
112
- */
113
- function findOpenModalDialog(): HTMLDialogElement | null {
114
- const dialogs = document.querySelectorAll<HTMLDialogElement>('dialog[open]');
115
- for (const d of dialogs) {
116
- if (d.open) return d;
117
- }
118
- return null;
119
- }
120
-
121
- // Promote form modal to the browser's top layer when opened
122
- // Portal into any open modal dialog to escape inertness
123
- $effect(() => {
124
- if (formOpen && formPopoverEl) {
125
- // Portal into modal dialog if one is open
126
- const modalDialog = findOpenModalDialog();
127
- if (modalDialog && formPopoverEl.parentElement !== modalDialog) {
128
- formOriginalParent = formPopoverEl.parentElement;
129
- formPortalTarget = modalDialog;
130
- modalDialog.appendChild(formPopoverEl);
131
- }
132
-
133
- if (typeof formPopoverEl.showPopover === 'function') {
134
- try { formPopoverEl.showPopover(); } catch { /* fallback to z-index */ }
135
- }
136
- } else if (!formOpen && formPopoverEl) {
137
- if (typeof formPopoverEl.hidePopover === 'function') {
138
- try { formPopoverEl.hidePopover(); } catch { /* already hidden */ }
139
- }
140
- // Move back to original parent
141
- if (formPortalTarget && formOriginalParent) {
142
- try { formOriginalParent.appendChild(formPopoverEl); } catch { /* already moved */ }
143
- formPortalTarget = null;
144
- }
145
- }
146
- });
147
- let formType = $state<'bug' | 'feature'>('bug');
148
- let formTitle = $state('');
149
- let formMessage = $state('');
150
- let pastedScreenshot = $state<string | null>(null);
151
- let pastedFilename = $state('');
152
- let messageTextareaWrapperEl: HTMLDivElement | undefined = $state();
153
- let messageTextareaEl: HTMLTextAreaElement | undefined = $state();
154
-
155
-
156
- let submitting = $state(false);
157
- let submitResult = $state<'success' | 'error' | null>(null);
158
-
159
- let renderContext = $state<{ url: string; scrollX: number; scrollY: number; renderToken: string; issuer: string } | null>(null);
160
- let fetchingRenderToken = $state(false);
161
- let renderTokenFailed = $state(false);
162
-
163
- // Drag state
164
- let isDragging = $state(false);
165
- let dragOffsetX = 0;
166
- let dragOffsetY = 0;
167
- let modalX = $state(0);
168
- let modalY = $state(0);
169
- let modalPositioned = $state(false);
170
-
171
- function handleDragStart(e: MouseEvent) {
172
- isDragging = true;
173
- const modalEl = (e.currentTarget as HTMLElement).closest('.sf-modal') as HTMLElement;
174
- if (!modalEl) return;
175
- const rect = modalEl.getBoundingClientRect();
176
- dragOffsetX = e.clientX - rect.left;
177
- dragOffsetY = e.clientY - rect.top;
178
- e.preventDefault();
179
- }
180
-
181
- function handleDragMove(e: MouseEvent) {
182
- if (!isDragging) return;
183
- modalX = e.clientX - dragOffsetX;
184
- modalY = e.clientY - dragOffsetY;
185
- modalPositioned = true;
186
- }
187
-
188
- function handleDragEnd() {
189
- isDragging = false;
190
- }
191
-
192
- // Console error capture
193
- let consoleErrors: string[] = [];
194
- let originalConsoleError: typeof console.error;
195
-
196
- // Onboarding
197
- let showToast = $state(false);
198
- const ONBOARDING_KEY = 'sf-devfeedback-onboarded';
199
-
200
- onMount(() => {
201
- isEnabled = detectEnvironment(enabled) && !!userEmail;
202
- if (!isEnabled) return;
203
-
204
- // Hook console.error
205
- originalConsoleError = console.error;
206
- console.error = (...args: unknown[]) => {
207
- consoleErrors.push(args.map(String).join(' '));
208
- if (consoleErrors.length > maxConsoleErrors) consoleErrors.shift();
209
- originalConsoleError.apply(console, args);
210
- };
211
-
212
- // Ctrl+Right-click handler
213
- document.addEventListener('contextmenu', handleContextMenu);
214
- document.addEventListener('click', closeMenu);
215
- document.addEventListener('keydown', handleKeydown);
216
- document.addEventListener('mousemove', handleDragMove);
217
- document.addEventListener('mouseup', handleDragEnd);
218
-
219
- // Onboarding toast
220
- if (showOnboarding && !localStorage.getItem(ONBOARDING_KEY)) {
221
- showToast = true;
222
- localStorage.setItem(ONBOARDING_KEY, 'true');
223
- setTimeout(() => (showToast = false), 6000);
224
- }
225
- });
226
-
227
- onDestroy(() => {
228
- if (!isEnabled) return;
229
- if (originalConsoleError) console.error = originalConsoleError;
230
- document.removeEventListener('contextmenu', handleContextMenu);
231
- document.removeEventListener('click', closeMenu);
232
- document.removeEventListener('keydown', handleKeydown);
233
- document.removeEventListener('mousemove', handleDragMove);
234
- document.removeEventListener('mouseup', handleDragEnd);
235
- });
236
-
237
- function handleContextMenu(e: MouseEvent) {
238
- if (!e.ctrlKey) return;
239
- e.preventDefault();
240
- e.stopPropagation();
241
-
242
- clickedElement = e.target as HTMLElement;
243
- menuX = e.clientX;
244
- menuY = e.clientY;
245
- menuOpen = true;
246
- formOpen = false;
247
- }
248
-
249
- function closeMenu() {
250
- if (menuOpen) menuOpen = false;
251
- }
252
-
253
- function handleKeydown(e: KeyboardEvent) {
254
- if (e.key === 'Escape') {
255
- menuOpen = false;
256
- formOpen = false;
257
- renderContext = null;
258
- pastedScreenshot = null;
259
- pastedFilename = '';
260
- }
261
- }
262
-
263
- async function fetchRenderToken() {
264
- fetchingRenderToken = true;
265
- renderTokenFailed = false;
266
- try {
267
- const url = window.location.href;
268
- const scrollX = Math.round(window.scrollX);
269
- const scrollY = Math.round(window.scrollY);
270
- const res = await fetch(renderTokenEndpoint, {
271
- method: 'POST',
272
- credentials: 'include',
273
- headers: { 'Content-Type': 'application/json' },
274
- body: JSON.stringify({ url, scrollX, scrollY }),
275
- });
276
- if (!res.ok) {
277
- renderTokenFailed = true;
278
- console.warn('[DevFeedback] Render token fetch failed:', res.status);
279
- return;
280
- }
281
- const { token } = (await res.json()) as { token: string };
282
- renderContext = { url, scrollX, scrollY, renderToken: token, issuer: new URL(url).origin };
283
- } catch (err) {
284
- renderTokenFailed = true;
285
- console.warn('[DevFeedback] Render token fetch error:', err);
286
- } finally {
287
- fetchingRenderToken = false;
288
- }
289
- }
290
-
291
- $effect(() => {
292
- if (messageTextareaWrapperEl) {
293
- messageTextareaEl = messageTextareaWrapperEl.querySelector('textarea') ?? undefined;
294
- }
295
- });
296
-
297
- function openForm(type: 'bug' | 'feature') {
298
- formType = type;
299
- formTitle = '';
300
- formMessage = '';
301
- pastedScreenshot = null;
302
- pastedFilename = '';
303
- submitResult = null;
304
- menuOpen = false;
305
- formOpen = true;
306
- modalPositioned = false;
307
- modalX = 0;
308
- modalY = 0;
309
-
310
- if (type === 'bug') {
311
- fetchRenderToken();
312
- } else {
313
- renderContext = null;
314
- }
315
- }
316
-
317
- async function handleSubmit() {
318
- if (!formMessage.trim() || !hasUserEmail) return;
319
- submitting = true;
320
- submitResult = null;
321
-
322
- try {
323
- const metadata: Record<string, unknown> = {
324
- type: formType,
325
- environment: getEnvironmentName(),
326
- route: window.location.pathname,
327
- viewport: { width: window.innerWidth, height: window.innerHeight },
328
- userAgent: navigator.userAgent,
329
- };
330
-
331
- if (clickedElement) {
332
- metadata.elementSelector = getElementSelector(clickedElement);
333
- metadata.elementHtml = getElementHtml(clickedElement);
334
- }
335
-
336
- if (consoleErrors.length > 0) {
337
- metadata.consoleErrors = [...consoleErrors];
338
- }
339
-
340
- if (pastedScreenshot) {
341
- metadata.pastedScreenshot = pastedScreenshot;
342
- metadata.pastedScreenshotFilename = pastedFilename;
343
- }
344
-
345
- if (renderContext) {
346
- metadata.renderToken = renderContext.renderToken;
347
- metadata.issuer = renderContext.issuer;
348
- metadata.scrollX = renderContext.scrollX;
349
- metadata.scrollY = renderContext.scrollY;
350
- }
351
-
352
- const gitCommit = getEnvVar('VERCEL_GIT_COMMIT_SHA');
353
- const gitBranch = getEnvVar('VERCEL_GIT_COMMIT_REF');
354
- const deployUrl = getEnvVar('VERCEL_URL');
355
- if (gitCommit) metadata.gitCommit = gitCommit;
356
- if (gitBranch) metadata.gitBranch = gitBranch;
357
- if (deployUrl) metadata.deploymentUrl = deployUrl;
358
-
359
- await getClient().submitFeedback({
360
- title: formTitle.trim() || undefined,
361
- message: formMessage.trim(),
362
- category: formType === 'bug' ? 'bug' : 'feature',
363
- email: userEmail,
364
- pageUrl: window.location.href,
365
- metadata,
366
- });
367
-
368
- submitResult = 'success';
369
- setTimeout(() => {
370
- formOpen = false;
371
- submitResult = null;
372
- renderContext = null;
373
- pastedScreenshot = null;
374
- pastedFilename = '';
375
- }, 2000);
376
- } catch {
377
- submitResult = 'error';
378
- } finally {
379
- submitting = false;
380
- }
381
- }
382
-
383
- function openBoard() {
384
- if (!boardUrl) return;
385
- menuOpen = false;
386
- window.open(boardUrl, '_blank');
387
- }
388
- </script>
389
-
390
- {#if isEnabled}
391
- <!-- Context menu -->
392
- {#if menuOpen}
393
- <DevContextMenu
394
- x={menuX}
395
- y={menuY}
396
- {boardUrl}
397
- onReportBug={() => openForm('bug')}
398
- onRequestFeature={() => openForm('feature')}
399
- onViewBoard={boardUrl ? openBoard : undefined}
400
- />
401
- {/if}
402
-
403
- <!-- Form modal -->
404
- {#if formOpen}
405
- <div
406
- bind:this={formPopoverEl}
407
- popover="manual"
408
- class="sf-modal sf-feedback-popover w-full max-w-lg flex flex-col"
409
- style={modalPositioned
410
- ? `left: ${modalX}px; top: ${modalY}px;`
411
- : 'left: 50%; top: 50%; transform: translate(-50%, -50%);'
412
- }
413
- >
414
- <Card variant="elevated" padding="none" class="flex flex-col max-h-[calc(100vh-2rem)] overflow-hidden">
415
- <!-- Header (draggable) -->
416
- <div
417
- role="presentation"
418
- class="flex items-center justify-between px-5 py-4 border-b border-border shrink-0 select-none"
419
- style="cursor: {isDragging ? 'grabbing' : 'grab'};"
420
- onmousedown={handleDragStart}
421
- >
422
- <div class="flex items-center gap-2">
423
- {#if formType === 'bug'}
424
- <i class="fa-solid fa-bug text-red-400"></i>
425
- <h3 class="text-base font-semibold">Report Bug</h3>
426
- {:else}
427
- <i class="fa-solid fa-lightbulb text-amber-400"></i>
428
- <h3 class="text-base font-semibold">Request Feature</h3>
429
- {/if}
430
- <Badge variant="neutral" size="sm">{getEnvironmentName()}</Badge>
431
- </div>
432
- <button
433
- class="text-foreground-tertiary hover:text-foreground transition-colors bg-transparent border-none cursor-pointer text-lg"
434
- onclick={() => (formOpen = false)}
435
- aria-label="Close feedback form"
436
- >
437
- <i class="fa-solid fa-xmark"></i>
438
- </button>
439
- </div>
440
-
441
- <!-- Body (scrollable) -->
442
- <div class="p-5 space-y-4 overflow-y-auto">
443
- {#if submitResult === 'success'}
444
- <div class="text-center py-6">
445
- <i class="fa-solid fa-check-circle text-3xl text-green-400"></i>
446
- <p class="mt-2 font-semibold">Submitted!</p>
447
- <p class="text-sm text-foreground-secondary">
448
- {formType === 'bug' ? "We'll look into this." : "Thanks for the suggestion!"}
449
- </p>
450
- </div>
451
- {:else}
452
- <div class="flex items-center gap-1.5 text-xs text-foreground-tertiary">
453
- <i class="fa-solid fa-user" aria-hidden="true"></i>
454
- <span>Submitting as <span class="font-medium text-foreground-secondary">{userEmail}</span></span>
455
- </div>
456
-
457
- <Input
458
- bind:value={formTitle}
459
- placeholder={formType === 'bug' ? 'Brief summary of the bug' : 'Feature title'}
460
- label="Title"
461
- />
462
-
463
- <div bind:this={messageTextareaWrapperEl}>
464
- <Textarea
465
- value={formMessage} oninput={(e) => (formMessage = (e.currentTarget as HTMLTextAreaElement).value)}
466
- placeholder={formType === 'bug'
467
- ? 'What happened? What did you expect?'
468
- : 'Describe the feature you\'d like...'}
469
- label={formType === 'bug' ? 'Description' : 'Details'}
470
- rows={4}
471
- />
472
- </div>
473
- <PasteImageInput
474
- textareaEl={messageTextareaEl}
475
- bind:image={pastedScreenshot}
476
- bind:filename={pastedFilename}
477
- previewClass="mt-2"
478
- />
479
-
480
- <!-- Auto-captured context (bugs only) -->
481
- {#if formType === 'bug'}
482
- <div class="space-y-2">
483
- <p class="text-xs font-semibold text-foreground-tertiary uppercase tracking-wide">Auto-captured</p>
484
- <div class="flex flex-wrap gap-1.5">
485
- <Badge variant="info" size="sm">
486
- <i class="fa-solid fa-route mr-1"></i>
487
- {window.location.pathname}
488
- </Badge>
489
- <Badge variant="info" size="sm">
490
- <i class="fa-solid fa-display mr-1"></i>
491
- {window.innerWidth}×{window.innerHeight}
492
- </Badge>
493
- {#if consoleErrors.length > 0}
494
- <Badge variant="danger" size="sm">
495
- <i class="fa-solid fa-triangle-exclamation mr-1"></i>
496
- {consoleErrors.length} console error{consoleErrors.length !== 1 ? 's' : ''}
497
- </Badge>
498
- {/if}
499
- {#if clickedElement}
500
- <Badge variant="neutral" size="sm">
501
- <i class="fa-solid fa-crosshairs mr-1"></i>
502
- {getElementSelector(clickedElement).slice(-40)}
503
- </Badge>
504
- {/if}
505
- </div>
506
-
507
- <!-- Render token status -->
508
- {#if fetchingRenderToken}
509
- <div class="flex items-center gap-2 text-sm text-foreground-tertiary">
510
- <Spinner /> Preparing screenshot...
511
- </div>
512
- {:else if renderTokenFailed && !pastedScreenshot}
513
- <div class="rounded-md border border-border bg-surface p-3">
514
- <div class="flex items-center gap-2 text-sm text-foreground-secondary mb-1">
515
- <i class="fa-solid fa-paste" aria-hidden="true"></i>
516
- <span>Paste a screenshot to attach one</span>
517
- </div>
518
- <p class="text-xs text-foreground-tertiary">
519
- Capture a screenshot (<kbd class="px-1 py-0.5 rounded bg-surface-secondary border border-border text-[10px] font-mono">{isMac ? '⌘⇧4' : 'Win+Shift+S'}</kbd>),
520
- then paste it into the description above with
521
- <kbd class="px-1 py-0.5 rounded bg-surface-secondary border border-border text-[10px] font-mono">{isMac ? '⌘V' : 'Ctrl+V'}</kbd>.
522
- </p>
523
- </div>
524
- {:else if renderContext && !pastedScreenshot}
525
- <div class="rounded-md border border-border bg-surface-secondary p-2 flex items-center gap-2 text-sm text-foreground-secondary">
526
- <i class="fa-solid fa-camera" aria-hidden="true"></i>
527
- <span>Screenshot will be captured server-side on submit</span>
528
- </div>
529
- {/if}
530
- </div>
531
- {/if}
532
-
533
- {#if submitResult === 'error'}
534
- <p class="text-sm text-red-400">
535
- <i class="fa-solid fa-exclamation-circle mr-1"></i>
536
- Failed to submit. Please try again.
537
- </p>
538
- {/if}
539
- {/if}
540
- </div>
541
-
542
- <!-- Footer -->
543
- {#if submitResult !== 'success' && hasUserEmail}
544
- <div class="flex justify-end gap-2 px-5 py-4 border-t border-border shrink-0">
545
- <Button variant="neutral" style="outline" onclick={() => (formOpen = false)}>
546
- Cancel
547
- </Button>
548
- <Button
549
- variant={formType === 'bug' ? 'danger' : 'primary'}
550
- disabled={submitting || !formMessage.trim()}
551
- loading={submitting}
552
- onclick={handleSubmit}
553
- >
554
- {formType === 'bug' ? 'Report Bug' : 'Submit Request'}
555
- </Button>
556
- </div>
557
- {/if}
558
- </Card>
559
- </div>
560
- {/if}
561
-
562
- <!-- Onboarding toast -->
563
- {#if showToast}
564
- <DevOnboardingToast onDismiss={() => (showToast = false)} />
565
- {/if}
566
- {/if}
567
-
568
- <style>
569
- /* Override popover defaults — remove browser default styles */
570
- .sf-feedback-popover {
571
- position: fixed;
572
- margin: 0;
573
- padding: 0;
574
- border: none;
575
- background: transparent;
576
- overflow: visible;
577
- /* Fallback for browsers without popover support */
578
- z-index: 99998;
579
- }
580
-
581
- /* Floating modal shadow — no backdrop so needs strong depth cue */
582
- .sf-modal :global(.card) {
583
- box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.05);
584
- }
585
- </style>
1
+ <script lang="ts">
2
+ import { createStatusClient, type StatusClient } from '../api.js';
3
+ import PasteImageInput from './shared/PasteImageInput.svelte';
4
+ import {
5
+ Card,
6
+ Button,
7
+ Input,
8
+ Textarea,
9
+ Badge,
10
+ Spinner,
11
+ } from '@alliance-droid/svelte-component-library';
12
+ import DevContextMenu from './dev/DevContextMenu.svelte';
13
+ import DevOnboardingToast from './dev/DevOnboardingToast.svelte';
14
+ import {
15
+ getEnvironmentName,
16
+ getEnvVar,
17
+ getElementSelector,
18
+ getElementHtml,
19
+ } from './dev/environment.js';
20
+
21
+ interface Props {
22
+ apiUrl: string;
23
+ apiKey: string;
24
+ /**
25
+ * The sole gate for the widget — wire it to your role/permission check
26
+ * (e.g. `enabled={userHasFeedbackRole}`). When false, nothing renders and
27
+ * no listeners attach. A non-empty `userEmail` is also required. There is
28
+ * no environment auto-detection.
29
+ */
30
+ enabled: boolean;
31
+ /** Max console errors to capture */
32
+ maxConsoleErrors?: number;
33
+ /** Show onboarding toast on first visit */
34
+ showOnboarding?: boolean;
35
+ /** URL to the feedback board page. If omitted, "View Open Issues" is hidden from the context menu. */
36
+ boardUrl?: string;
37
+ /**
38
+ * Authenticated user email. Required.
39
+ *
40
+ * Pass an empty string when the user is not signed in — the widget will
41
+ * render nothing (no floating bubble, no menu, no form). Anonymous
42
+ * feedback is not supported.
43
+ */
44
+ userEmail: string;
45
+ /**
46
+ * Endpoint on the consumer app that issues render tokens.
47
+ * Default: '/svc/internal/issue-render-token'
48
+ */
49
+ renderTokenEndpoint?: string;
50
+ }
51
+
52
+ let {
53
+ apiUrl,
54
+ apiKey,
55
+ enabled,
56
+ maxConsoleErrors = 20,
57
+ showOnboarding = true,
58
+ boardUrl,
59
+ userEmail,
60
+ renderTokenEndpoint = '/svc/internal/issue-render-token',
61
+ }: Props = $props();
62
+
63
+ // Dev-mode warning when userEmail is empty
64
+ $effect(() => {
65
+ if (!userEmail && typeof console !== 'undefined') {
66
+ // eslint-disable-next-line no-restricted-globals -- safe typeof guard for universal code
67
+ const proc = typeof globalThis !== 'undefined' ? (globalThis as Record<string, unknown>).process as { env?: Record<string, string> } | undefined : undefined;
68
+ const env = proc?.env?.NODE_ENV || 'development';
69
+ if (env !== 'production') {
70
+ console.warn(
71
+ '[status-feedback-system] <DevFeedback> was rendered with an empty userEmail. ' +
72
+ "The widget will not appear. Pass the authenticated user's email, or do not render <DevFeedback> when no user is signed in.",
73
+ );
74
+ }
75
+ }
76
+ });
77
+
78
+ // --- Client (lazy, stable) ---
79
+ let client: StatusClient | null = $state(null);
80
+ let lastApiUrl = '';
81
+ let lastApiKey = '';
82
+
83
+ function getClient(): StatusClient {
84
+ if (!client || lastApiUrl !== apiUrl || lastApiKey !== apiKey) {
85
+ client = createStatusClient({ apiUrl, apiKey });
86
+ lastApiUrl = apiUrl;
87
+ lastApiKey = apiKey;
88
+ }
89
+ return client;
90
+ }
91
+
92
+ // --- Activation gate ---
93
+ // `enabled` is the sole gate — wire it to your role/permission check.
94
+ // Combined with a signed-in user. No environment heuristics.
95
+ const hasUserEmail = $derived(!!userEmail);
96
+ const isEnabled = $derived(enabled && hasUserEmail);
97
+ const isMac = $derived(
98
+ typeof navigator !== 'undefined' &&
99
+ /Mac|iPhone|iPad|iPod/i.test(navigator.platform || navigator.userAgent || '')
100
+ );
101
+
102
+ // --- State ---
103
+ let menuOpen = $state(false);
104
+ let menuX = $state(0);
105
+ let menuY = $state(0);
106
+ let clickedElement = $state<HTMLElement | null>(null);
107
+
108
+ let formOpen = $state(false);
109
+ let formPopoverEl: HTMLDivElement | undefined = $state();
110
+ let formOriginalParent: HTMLElement | null = null;
111
+ let formPortalTarget: HTMLElement | null = null;
112
+
113
+ /**
114
+ * Find the topmost open modal dialog.
115
+ * showModal() makes everything outside the dialog inert — including
116
+ * top-layer popovers that aren't descendants of the dialog.
117
+ */
118
+ function findOpenModalDialog(): HTMLDialogElement | null {
119
+ const dialogs = document.querySelectorAll<HTMLDialogElement>('dialog[open]');
120
+ for (const d of dialogs) {
121
+ if (d.open) return d;
122
+ }
123
+ return null;
124
+ }
125
+
126
+ // Promote form modal to the browser's top layer when opened
127
+ // Portal into any open modal dialog to escape inertness
128
+ $effect(() => {
129
+ if (formOpen && formPopoverEl) {
130
+ // Portal into modal dialog if one is open
131
+ const modalDialog = findOpenModalDialog();
132
+ if (modalDialog && formPopoverEl.parentElement !== modalDialog) {
133
+ formOriginalParent = formPopoverEl.parentElement;
134
+ formPortalTarget = modalDialog;
135
+ modalDialog.appendChild(formPopoverEl);
136
+ }
137
+
138
+ if (typeof formPopoverEl.showPopover === 'function') {
139
+ try { formPopoverEl.showPopover(); } catch { /* fallback to z-index */ }
140
+ }
141
+ } else if (!formOpen && formPopoverEl) {
142
+ if (typeof formPopoverEl.hidePopover === 'function') {
143
+ try { formPopoverEl.hidePopover(); } catch { /* already hidden */ }
144
+ }
145
+ // Move back to original parent
146
+ if (formPortalTarget && formOriginalParent) {
147
+ try { formOriginalParent.appendChild(formPopoverEl); } catch { /* already moved */ }
148
+ formPortalTarget = null;
149
+ }
150
+ }
151
+ });
152
+ let formType = $state<'bug' | 'feature'>('bug');
153
+ let formTitle = $state('');
154
+ let formMessage = $state('');
155
+ let pastedScreenshot = $state<string | null>(null);
156
+ let pastedFilename = $state('');
157
+ let messageTextareaWrapperEl: HTMLDivElement | undefined = $state();
158
+ let messageTextareaEl: HTMLTextAreaElement | undefined = $state();
159
+
160
+
161
+ let submitting = $state(false);
162
+ let submitResult = $state<'success' | 'error' | null>(null);
163
+
164
+ let renderContext = $state<{ url: string; scrollX: number; scrollY: number; renderToken: string; issuer: string } | null>(null);
165
+ let fetchingRenderToken = $state(false);
166
+ let renderTokenFailed = $state(false);
167
+
168
+ // Drag state
169
+ let isDragging = $state(false);
170
+ let dragOffsetX = 0;
171
+ let dragOffsetY = 0;
172
+ let modalX = $state(0);
173
+ let modalY = $state(0);
174
+ let modalPositioned = $state(false);
175
+
176
+ function handleDragStart(e: MouseEvent) {
177
+ isDragging = true;
178
+ const modalEl = (e.currentTarget as HTMLElement).closest('.sf-modal') as HTMLElement;
179
+ if (!modalEl) return;
180
+ const rect = modalEl.getBoundingClientRect();
181
+ dragOffsetX = e.clientX - rect.left;
182
+ dragOffsetY = e.clientY - rect.top;
183
+ e.preventDefault();
184
+ }
185
+
186
+ function handleDragMove(e: MouseEvent) {
187
+ if (!isDragging) return;
188
+ modalX = e.clientX - dragOffsetX;
189
+ modalY = e.clientY - dragOffsetY;
190
+ modalPositioned = true;
191
+ }
192
+
193
+ function handleDragEnd() {
194
+ isDragging = false;
195
+ }
196
+
197
+ // Console error capture
198
+ let consoleErrors: string[] = [];
199
+
200
+ // Onboarding
201
+ let showToast = $state(false);
202
+ const ONBOARDING_KEY = 'sf-devfeedback-onboarded';
203
+
204
+ // Activate only when enabled — reactive, so a role check that resolves after
205
+ // mount arms the widget. The returned cleanup runs when disabled or destroyed.
206
+ $effect(() => {
207
+ if (!isEnabled) return;
208
+
209
+ // Capture recent console errors to attach to bug reports.
210
+ const originalConsoleError = console.error;
211
+ console.error = (...args: unknown[]) => {
212
+ consoleErrors.push(args.map(String).join(' '));
213
+ if (consoleErrors.length > maxConsoleErrors) consoleErrors.shift();
214
+ originalConsoleError.apply(console, args);
215
+ };
216
+
217
+ // Ctrl/Cmd + Right-click handler + menu/drag listeners.
218
+ document.addEventListener('contextmenu', handleContextMenu);
219
+ document.addEventListener('click', closeMenu);
220
+ document.addEventListener('keydown', handleKeydown);
221
+ document.addEventListener('mousemove', handleDragMove);
222
+ document.addEventListener('mouseup', handleDragEnd);
223
+
224
+ // Onboarding toast on first visit.
225
+ if (showOnboarding && !localStorage.getItem(ONBOARDING_KEY)) {
226
+ showToast = true;
227
+ localStorage.setItem(ONBOARDING_KEY, 'true');
228
+ setTimeout(() => (showToast = false), 6000);
229
+ }
230
+
231
+ return () => {
232
+ console.error = originalConsoleError;
233
+ document.removeEventListener('contextmenu', handleContextMenu);
234
+ document.removeEventListener('click', closeMenu);
235
+ document.removeEventListener('keydown', handleKeydown);
236
+ document.removeEventListener('mousemove', handleDragMove);
237
+ document.removeEventListener('mouseup', handleDragEnd);
238
+ };
239
+ });
240
+
241
+ function handleContextMenu(e: MouseEvent) {
242
+ if (!e.ctrlKey) return;
243
+ e.preventDefault();
244
+ e.stopPropagation();
245
+
246
+ clickedElement = e.target as HTMLElement;
247
+ menuX = e.clientX;
248
+ menuY = e.clientY;
249
+ menuOpen = true;
250
+ formOpen = false;
251
+ }
252
+
253
+ function closeMenu() {
254
+ if (menuOpen) menuOpen = false;
255
+ }
256
+
257
+ function handleKeydown(e: KeyboardEvent) {
258
+ if (e.key === 'Escape') {
259
+ menuOpen = false;
260
+ formOpen = false;
261
+ renderContext = null;
262
+ pastedScreenshot = null;
263
+ pastedFilename = '';
264
+ }
265
+ }
266
+
267
+ async function fetchRenderToken() {
268
+ fetchingRenderToken = true;
269
+ renderTokenFailed = false;
270
+ try {
271
+ const url = window.location.href;
272
+ const scrollX = Math.round(window.scrollX);
273
+ const scrollY = Math.round(window.scrollY);
274
+ const res = await fetch(renderTokenEndpoint, {
275
+ method: 'POST',
276
+ credentials: 'include',
277
+ headers: { 'Content-Type': 'application/json' },
278
+ body: JSON.stringify({ url, scrollX, scrollY }),
279
+ });
280
+ if (!res.ok) {
281
+ renderTokenFailed = true;
282
+ console.warn('[DevFeedback] Render token fetch failed:', res.status);
283
+ return;
284
+ }
285
+ const { token } = (await res.json()) as { token: string };
286
+ renderContext = { url, scrollX, scrollY, renderToken: token, issuer: new URL(url).origin };
287
+ } catch (err) {
288
+ renderTokenFailed = true;
289
+ console.warn('[DevFeedback] Render token fetch error:', err);
290
+ } finally {
291
+ fetchingRenderToken = false;
292
+ }
293
+ }
294
+
295
+ $effect(() => {
296
+ if (messageTextareaWrapperEl) {
297
+ messageTextareaEl = messageTextareaWrapperEl.querySelector('textarea') ?? undefined;
298
+ }
299
+ });
300
+
301
+ function openForm(type: 'bug' | 'feature') {
302
+ formType = type;
303
+ formTitle = '';
304
+ formMessage = '';
305
+ pastedScreenshot = null;
306
+ pastedFilename = '';
307
+ submitResult = null;
308
+ menuOpen = false;
309
+ formOpen = true;
310
+ modalPositioned = false;
311
+ modalX = 0;
312
+ modalY = 0;
313
+
314
+ if (type === 'bug') {
315
+ fetchRenderToken();
316
+ } else {
317
+ renderContext = null;
318
+ }
319
+ }
320
+
321
+ async function handleSubmit() {
322
+ if (!formMessage.trim() || !hasUserEmail) return;
323
+ submitting = true;
324
+ submitResult = null;
325
+
326
+ try {
327
+ const metadata: Record<string, unknown> = {
328
+ type: formType,
329
+ environment: getEnvironmentName(),
330
+ route: window.location.pathname,
331
+ viewport: { width: window.innerWidth, height: window.innerHeight },
332
+ userAgent: navigator.userAgent,
333
+ };
334
+
335
+ if (clickedElement) {
336
+ metadata.elementSelector = getElementSelector(clickedElement);
337
+ metadata.elementHtml = getElementHtml(clickedElement);
338
+ }
339
+
340
+ if (consoleErrors.length > 0) {
341
+ metadata.consoleErrors = [...consoleErrors];
342
+ }
343
+
344
+ if (pastedScreenshot) {
345
+ metadata.pastedScreenshot = pastedScreenshot;
346
+ metadata.pastedScreenshotFilename = pastedFilename;
347
+ }
348
+
349
+ if (renderContext) {
350
+ metadata.renderToken = renderContext.renderToken;
351
+ metadata.issuer = renderContext.issuer;
352
+ metadata.scrollX = renderContext.scrollX;
353
+ metadata.scrollY = renderContext.scrollY;
354
+ }
355
+
356
+ const gitCommit = getEnvVar('VERCEL_GIT_COMMIT_SHA');
357
+ const gitBranch = getEnvVar('VERCEL_GIT_COMMIT_REF');
358
+ const deployUrl = getEnvVar('VERCEL_URL');
359
+ if (gitCommit) metadata.gitCommit = gitCommit;
360
+ if (gitBranch) metadata.gitBranch = gitBranch;
361
+ if (deployUrl) metadata.deploymentUrl = deployUrl;
362
+
363
+ await getClient().submitFeedback({
364
+ title: formTitle.trim() || undefined,
365
+ message: formMessage.trim(),
366
+ category: formType === 'bug' ? 'bug' : 'feature',
367
+ email: userEmail,
368
+ pageUrl: window.location.href,
369
+ metadata,
370
+ });
371
+
372
+ submitResult = 'success';
373
+ setTimeout(() => {
374
+ formOpen = false;
375
+ submitResult = null;
376
+ renderContext = null;
377
+ pastedScreenshot = null;
378
+ pastedFilename = '';
379
+ }, 2000);
380
+ } catch {
381
+ submitResult = 'error';
382
+ } finally {
383
+ submitting = false;
384
+ }
385
+ }
386
+
387
+ function openBoard() {
388
+ if (!boardUrl) return;
389
+ menuOpen = false;
390
+ window.open(boardUrl, '_blank');
391
+ }
392
+ </script>
393
+
394
+ {#if isEnabled}
395
+ <!-- Context menu -->
396
+ {#if menuOpen}
397
+ <DevContextMenu
398
+ x={menuX}
399
+ y={menuY}
400
+ {boardUrl}
401
+ onReportBug={() => openForm('bug')}
402
+ onRequestFeature={() => openForm('feature')}
403
+ onViewBoard={boardUrl ? openBoard : undefined}
404
+ />
405
+ {/if}
406
+
407
+ <!-- Form modal -->
408
+ {#if formOpen}
409
+ <div
410
+ bind:this={formPopoverEl}
411
+ popover="manual"
412
+ class="sf-modal sf-feedback-popover w-full max-w-lg flex flex-col"
413
+ style={modalPositioned
414
+ ? `left: ${modalX}px; top: ${modalY}px;`
415
+ : 'left: 50%; top: 50%; transform: translate(-50%, -50%);'
416
+ }
417
+ >
418
+ <Card variant="elevated" padding="none" class="flex flex-col max-h-[calc(100vh-2rem)] overflow-hidden">
419
+ <!-- Header (draggable) -->
420
+ <div
421
+ role="presentation"
422
+ class="flex items-center justify-between px-5 py-4 border-b border-border shrink-0 select-none"
423
+ style="cursor: {isDragging ? 'grabbing' : 'grab'};"
424
+ onmousedown={handleDragStart}
425
+ >
426
+ <div class="flex items-center gap-2">
427
+ {#if formType === 'bug'}
428
+ <i class="fa-solid fa-bug text-red-400"></i>
429
+ <h3 class="text-base font-semibold">Report Bug</h3>
430
+ {:else}
431
+ <i class="fa-solid fa-lightbulb text-amber-400"></i>
432
+ <h3 class="text-base font-semibold">Request Feature</h3>
433
+ {/if}
434
+ <Badge variant="neutral" size="sm">{getEnvironmentName()}</Badge>
435
+ </div>
436
+ <button
437
+ class="text-foreground-tertiary hover:text-foreground transition-colors bg-transparent border-none cursor-pointer text-lg"
438
+ onclick={() => (formOpen = false)}
439
+ aria-label="Close feedback form"
440
+ >
441
+ <i class="fa-solid fa-xmark"></i>
442
+ </button>
443
+ </div>
444
+
445
+ <!-- Body (scrollable) -->
446
+ <div class="p-5 space-y-4 overflow-y-auto">
447
+ {#if submitResult === 'success'}
448
+ <div class="text-center py-6">
449
+ <i class="fa-solid fa-check-circle text-3xl text-green-400"></i>
450
+ <p class="mt-2 font-semibold">Submitted!</p>
451
+ <p class="text-sm text-foreground-secondary">
452
+ {formType === 'bug' ? "We'll look into this." : "Thanks for the suggestion!"}
453
+ </p>
454
+ </div>
455
+ {:else}
456
+ <div class="flex items-center gap-1.5 text-xs text-foreground-tertiary">
457
+ <i class="fa-solid fa-user" aria-hidden="true"></i>
458
+ <span>Submitting as <span class="font-medium text-foreground-secondary">{userEmail}</span></span>
459
+ </div>
460
+
461
+ <Input
462
+ bind:value={formTitle}
463
+ placeholder={formType === 'bug' ? 'Brief summary of the bug' : 'Feature title'}
464
+ label="Title"
465
+ />
466
+
467
+ <div bind:this={messageTextareaWrapperEl}>
468
+ <Textarea
469
+ value={formMessage} oninput={(e) => (formMessage = (e.currentTarget as HTMLTextAreaElement).value)}
470
+ placeholder={formType === 'bug'
471
+ ? 'What happened? What did you expect?'
472
+ : 'Describe the feature you\'d like...'}
473
+ label={formType === 'bug' ? 'Description' : 'Details'}
474
+ rows={4}
475
+ />
476
+ </div>
477
+ <PasteImageInput
478
+ textareaEl={messageTextareaEl}
479
+ bind:image={pastedScreenshot}
480
+ bind:filename={pastedFilename}
481
+ previewClass="mt-2"
482
+ />
483
+
484
+ <!-- Auto-captured context (bugs only) -->
485
+ {#if formType === 'bug'}
486
+ <div class="space-y-2">
487
+ <p class="text-xs font-semibold text-foreground-tertiary uppercase tracking-wide">Auto-captured</p>
488
+ <div class="flex flex-wrap gap-1.5">
489
+ <Badge variant="info" size="sm">
490
+ <i class="fa-solid fa-route mr-1"></i>
491
+ {window.location.pathname}
492
+ </Badge>
493
+ <Badge variant="info" size="sm">
494
+ <i class="fa-solid fa-display mr-1"></i>
495
+ {window.innerWidth}×{window.innerHeight}
496
+ </Badge>
497
+ {#if consoleErrors.length > 0}
498
+ <Badge variant="danger" size="sm">
499
+ <i class="fa-solid fa-triangle-exclamation mr-1"></i>
500
+ {consoleErrors.length} console error{consoleErrors.length !== 1 ? 's' : ''}
501
+ </Badge>
502
+ {/if}
503
+ {#if clickedElement}
504
+ <Badge variant="neutral" size="sm">
505
+ <i class="fa-solid fa-crosshairs mr-1"></i>
506
+ {getElementSelector(clickedElement).slice(-40)}
507
+ </Badge>
508
+ {/if}
509
+ </div>
510
+
511
+ <!-- Render token status -->
512
+ {#if fetchingRenderToken}
513
+ <div class="flex items-center gap-2 text-sm text-foreground-tertiary">
514
+ <Spinner /> Preparing screenshot...
515
+ </div>
516
+ {:else if renderTokenFailed && !pastedScreenshot}
517
+ <div class="rounded-md border border-border bg-surface p-3">
518
+ <div class="flex items-center gap-2 text-sm text-foreground-secondary mb-1">
519
+ <i class="fa-solid fa-paste" aria-hidden="true"></i>
520
+ <span>Paste a screenshot to attach one</span>
521
+ </div>
522
+ <p class="text-xs text-foreground-tertiary">
523
+ Capture a screenshot (<kbd class="px-1 py-0.5 rounded bg-surface-secondary border border-border text-[10px] font-mono">{isMac ? '⌘⇧4' : 'Win+Shift+S'}</kbd>),
524
+ then paste it into the description above with
525
+ <kbd class="px-1 py-0.5 rounded bg-surface-secondary border border-border text-[10px] font-mono">{isMac ? '⌘V' : 'Ctrl+V'}</kbd>.
526
+ </p>
527
+ </div>
528
+ {:else if renderContext && !pastedScreenshot}
529
+ <div class="rounded-md border border-border bg-surface-secondary p-2 flex items-center gap-2 text-sm text-foreground-secondary">
530
+ <i class="fa-solid fa-camera" aria-hidden="true"></i>
531
+ <span>Screenshot will be captured server-side on submit</span>
532
+ </div>
533
+ {/if}
534
+ </div>
535
+ {/if}
536
+
537
+ {#if submitResult === 'error'}
538
+ <p class="text-sm text-red-400">
539
+ <i class="fa-solid fa-exclamation-circle mr-1"></i>
540
+ Failed to submit. Please try again.
541
+ </p>
542
+ {/if}
543
+ {/if}
544
+ </div>
545
+
546
+ <!-- Footer -->
547
+ {#if submitResult !== 'success' && hasUserEmail}
548
+ <div class="flex justify-end gap-2 px-5 py-4 border-t border-border shrink-0">
549
+ <Button variant="neutral" style="outline" onclick={() => (formOpen = false)}>
550
+ Cancel
551
+ </Button>
552
+ <Button
553
+ variant={formType === 'bug' ? 'danger' : 'primary'}
554
+ disabled={submitting || !formMessage.trim()}
555
+ loading={submitting}
556
+ onclick={handleSubmit}
557
+ >
558
+ {formType === 'bug' ? 'Report Bug' : 'Submit Request'}
559
+ </Button>
560
+ </div>
561
+ {/if}
562
+ </Card>
563
+ </div>
564
+ {/if}
565
+
566
+ <!-- Onboarding toast -->
567
+ {#if showToast}
568
+ <DevOnboardingToast onDismiss={() => (showToast = false)} />
569
+ {/if}
570
+ {/if}
571
+
572
+ <style>
573
+ /* Override popover defaults — remove browser default styles */
574
+ .sf-feedback-popover {
575
+ position: fixed;
576
+ margin: 0;
577
+ padding: 0;
578
+ border: none;
579
+ background: transparent;
580
+ overflow: visible;
581
+ /* Fallback for browsers without popover support */
582
+ z-index: 99998;
583
+ }
584
+
585
+ /* Floating modal shadow — no backdrop so needs strong depth cue */
586
+ .sf-modal :global(.card) {
587
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.05);
588
+ }
589
+ </style>