@alliance-droid/status-feedback-system 1.2.1 → 1.3.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.
Files changed (32) hide show
  1. package/CHANGELOG.md +25 -45
  2. package/LICENSE +11 -0
  3. package/README.md +7 -3
  4. package/dist/components/DevFeedback.svelte +229 -337
  5. package/dist/components/FeedbackBoard.svelte +32 -368
  6. package/dist/components/FeedbackBoard.svelte.d.ts +2 -5
  7. package/dist/components/FeedbackForm.svelte +14 -3
  8. package/dist/components/StatusBanner.svelte +48 -14
  9. package/dist/components/StatusBanner.svelte.d.ts +2 -0
  10. package/dist/components/StatusPage.svelte +43 -10
  11. package/dist/components/StatusPage.svelte.d.ts +2 -0
  12. package/dist/components/board/FeedbackDetail.svelte +7 -7
  13. package/dist/components/board/FeedbackItem.svelte +26 -12
  14. package/dist/components/board/FeedbackList.svelte +21 -1
  15. package/dist/components/board/FeedbackSidebar.svelte +3 -3
  16. package/dist/components/board/state.svelte.d.ts +6 -0
  17. package/dist/components/board/state.svelte.js +42 -2
  18. package/dist/components/dev/DevContextMenu.svelte +99 -0
  19. package/dist/components/dev/DevContextMenu.svelte.d.ts +11 -0
  20. package/dist/components/dev/DevOnboardingToast.svelte +73 -0
  21. package/dist/components/dev/DevOnboardingToast.svelte.d.ts +6 -0
  22. package/dist/components/dev/environment.d.ts +24 -0
  23. package/dist/components/dev/environment.js +93 -0
  24. package/dist/components/dev/screenshot.d.ts +6 -0
  25. package/dist/components/dev/screenshot.js +24 -0
  26. package/dist/components/shared/IncidentTimeline.svelte +3 -3
  27. package/dist/components/shared/StatusIndicator.svelte +2 -2
  28. package/dist/index.d.ts +1 -0
  29. package/dist/index.js +2 -0
  30. package/dist/utils.d.ts +9 -0
  31. package/dist/utils.js +18 -0
  32. package/package.json +11 -6
@@ -1,15 +1,24 @@
1
1
  <script lang="ts">
2
2
  import { onMount, onDestroy } from 'svelte';
3
- import { createStatusClient } from '../api.js';
3
+ import { createStatusClient, type StatusClient } from '../api.js';
4
4
  import {
5
5
  Card,
6
6
  Button,
7
7
  Input,
8
8
  Textarea,
9
- Select,
10
9
  Badge,
11
10
  Spinner,
12
11
  } from '@alliance-droid/svelte-component-library';
12
+ import DevContextMenu from './dev/DevContextMenu.svelte';
13
+ import DevOnboardingToast from './dev/DevOnboardingToast.svelte';
14
+ import { captureScreenshot } from './dev/screenshot.js';
15
+ import {
16
+ detectEnvironment,
17
+ getEnvironmentName,
18
+ getEnvVar,
19
+ getElementSelector,
20
+ getElementHtml,
21
+ } from './dev/environment.js';
13
22
 
14
23
  interface Props {
15
24
  apiUrl: string;
@@ -36,44 +45,22 @@
36
45
  userEmail,
37
46
  }: Props = $props();
38
47
 
39
- let client = $derived(createStatusClient({ apiUrl, apiKey }));
40
-
41
- // --- Environment gate ---
42
- let isEnabled = $state(false);
48
+ // --- Client (lazy, stable) ---
49
+ let client: StatusClient | null = $state(null);
50
+ let lastApiUrl = '';
51
+ let lastApiKey = '';
43
52
 
44
- function getEnvVar(key: string): string | undefined {
45
- try {
46
- // Works in Vite/SvelteKit apps
47
- const env = (globalThis as Record<string, unknown>).__sveltekit_env as Record<string, string> | undefined;
48
- if (env?.[key]) return env[key];
49
- } catch { /* ignore */ }
50
- try {
51
- // Window-injected env (common pattern)
52
- const win = window as unknown as Record<string, unknown>;
53
- const env = win.__ENV as Record<string, string> | undefined;
54
- if (env?.[key]) return env[key];
55
- } catch { /* ignore */ }
56
- return undefined;
53
+ function getClient(): StatusClient {
54
+ if (!client || lastApiUrl !== apiUrl || lastApiKey !== apiKey) {
55
+ client = createStatusClient({ apiUrl, apiKey });
56
+ lastApiUrl = apiUrl;
57
+ lastApiKey = apiKey;
58
+ }
59
+ return client;
57
60
  }
58
61
 
59
- function detectEnvironment(): boolean {
60
- if (enabled !== undefined) return enabled;
61
- if (typeof window === 'undefined') return false;
62
-
63
- // Check explicit env var
64
- const feedbackEnabled = getEnvVar('PUBLIC_FEEDBACK_ENABLED');
65
- if (feedbackEnabled === 'true') return true;
66
- if (feedbackEnabled === 'false') return false;
67
-
68
- // Vercel environment
69
- const vercelEnv = getEnvVar('VERCEL_ENV');
70
- if (vercelEnv === 'production') return false;
71
- if (vercelEnv) return true;
72
-
73
- // Localhost fallback
74
- const host = window.location.hostname;
75
- return host === 'localhost' || host === '127.0.0.1' || host.endsWith('.local');
76
- }
62
+ // --- Environment gate ---
63
+ let isEnabled = $state(false);
77
64
 
78
65
  // --- State ---
79
66
  let menuOpen = $state(false);
@@ -90,7 +77,37 @@
90
77
  let submitResult = $state<'success' | 'error' | null>(null);
91
78
 
92
79
  let screenshotData = $state<string | null>(null);
93
- let capturingScreenshot = $state(false);
80
+ let capturingScreenshot_ = $state(false);
81
+ let screenshotFailed = $state(false);
82
+
83
+ // Drag state
84
+ let isDragging = $state(false);
85
+ let dragOffsetX = 0;
86
+ let dragOffsetY = 0;
87
+ let modalX = $state(0);
88
+ let modalY = $state(0);
89
+ let modalPositioned = $state(false);
90
+
91
+ function handleDragStart(e: MouseEvent) {
92
+ isDragging = true;
93
+ const modalEl = (e.currentTarget as HTMLElement).closest('.sf-modal') as HTMLElement;
94
+ if (!modalEl) return;
95
+ const rect = modalEl.getBoundingClientRect();
96
+ dragOffsetX = e.clientX - rect.left;
97
+ dragOffsetY = e.clientY - rect.top;
98
+ e.preventDefault();
99
+ }
100
+
101
+ function handleDragMove(e: MouseEvent) {
102
+ if (!isDragging) return;
103
+ modalX = e.clientX - dragOffsetX;
104
+ modalY = e.clientY - dragOffsetY;
105
+ modalPositioned = true;
106
+ }
107
+
108
+ function handleDragEnd() {
109
+ isDragging = false;
110
+ }
94
111
 
95
112
  // Console error capture
96
113
  let consoleErrors: string[] = [];
@@ -101,7 +118,7 @@
101
118
  const ONBOARDING_KEY = 'sf-devfeedback-onboarded';
102
119
 
103
120
  onMount(() => {
104
- isEnabled = detectEnvironment();
121
+ isEnabled = detectEnvironment(enabled);
105
122
  if (!isEnabled) return;
106
123
 
107
124
  // Hook console.error
@@ -114,10 +131,10 @@
114
131
 
115
132
  // Ctrl+Right-click handler
116
133
  document.addEventListener('contextmenu', handleContextMenu);
117
- // Close menu on any other click
118
134
  document.addEventListener('click', closeMenu);
119
- // Escape closes everything
120
135
  document.addEventListener('keydown', handleKeydown);
136
+ document.addEventListener('mousemove', handleDragMove);
137
+ document.addEventListener('mouseup', handleDragEnd);
121
138
 
122
139
  // Onboarding toast
123
140
  if (showOnboarding && !localStorage.getItem(ONBOARDING_KEY)) {
@@ -133,10 +150,11 @@
133
150
  document.removeEventListener('contextmenu', handleContextMenu);
134
151
  document.removeEventListener('click', closeMenu);
135
152
  document.removeEventListener('keydown', handleKeydown);
153
+ document.removeEventListener('mousemove', handleDragMove);
154
+ document.removeEventListener('mouseup', handleDragEnd);
136
155
  });
137
156
 
138
157
  function handleContextMenu(e: MouseEvent) {
139
- // Only trigger on Ctrl+Right-click
140
158
  if (!e.ctrlKey) return;
141
159
  e.preventDefault();
142
160
  e.stopPropagation();
@@ -160,54 +178,12 @@
160
178
  }
161
179
  }
162
180
 
163
- function getElementSelector(el: HTMLElement | null): string {
164
- if (!el) return '';
165
- const parts: string[] = [];
166
- let current: HTMLElement | null = el;
167
- while (current && current !== document.body) {
168
- if (!current.tagName) { current = current.parentElement; continue; }
169
- let selector = current.tagName.toLowerCase();
170
- if (current.id) {
171
- selector += `#${current.id}`;
172
- parts.unshift(selector);
173
- break;
174
- }
175
- if (current.className && typeof current.className === 'string') {
176
- const classes = current.className.trim().split(/\s+/).slice(0, 2).join('.');
177
- if (classes) selector += `.${classes}`;
178
- }
179
- parts.unshift(selector);
180
- current = current.parentElement;
181
- }
182
- return parts.join(' > ');
183
- }
184
-
185
- function getElementHtml(el: HTMLElement | null): string {
186
- if (!el) return '';
187
- const html = el.outerHTML;
188
- return html.length > 500 ? html.slice(0, 500) + '...' : html;
189
- }
190
-
191
- async function captureScreenshot() {
192
- capturingScreenshot = true;
193
- try {
194
- const html2canvas = (await import('html2canvas')).default;
195
- const canvas = await html2canvas(document.body, {
196
- logging: false,
197
- useCORS: true,
198
- scale: 1,
199
- width: window.innerWidth,
200
- height: window.innerHeight,
201
- windowWidth: window.innerWidth,
202
- windowHeight: window.innerHeight,
203
- });
204
- screenshotData = canvas.toDataURL('image/png');
205
- } catch {
206
- // Screenshot capture can fail in cross-origin iframes or restricted contexts
207
- screenshotData = null;
208
- } finally {
209
- capturingScreenshot = false;
210
- }
181
+ async function doScreenshot() {
182
+ capturingScreenshot_ = true;
183
+ screenshotFailed = false;
184
+ screenshotData = await captureScreenshot();
185
+ if (!screenshotData) screenshotFailed = true;
186
+ capturingScreenshot_ = false;
211
187
  }
212
188
 
213
189
  function openForm(type: 'bug' | 'feature') {
@@ -218,10 +194,12 @@
218
194
  submitResult = null;
219
195
  menuOpen = false;
220
196
  formOpen = true;
197
+ modalPositioned = false;
198
+ modalX = 0;
199
+ modalY = 0;
221
200
 
222
- // Auto-capture screenshot for bugs
223
201
  if (type === 'bug') {
224
- captureScreenshot();
202
+ doScreenshot();
225
203
  } else {
226
204
  screenshotData = null;
227
205
  }
@@ -254,7 +232,6 @@
254
232
  metadata.screenshot = screenshotData;
255
233
  }
256
234
 
257
- // Deployment info
258
235
  const gitCommit = getEnvVar('VERCEL_GIT_COMMIT_SHA');
259
236
  const gitBranch = getEnvVar('VERCEL_GIT_COMMIT_REF');
260
237
  const deployUrl = getEnvVar('VERCEL_URL');
@@ -262,10 +239,10 @@
262
239
  if (gitBranch) metadata.gitBranch = gitBranch;
263
240
  if (deployUrl) metadata.deploymentUrl = deployUrl;
264
241
 
265
- await client.submitFeedback({
242
+ await getClient().submitFeedback({
266
243
  title: formTitle.trim() || undefined,
267
244
  message: formMessage.trim(),
268
- category: formType === 'bug' ? 'Bug' : 'Feature Request',
245
+ category: formType === 'bug' ? 'bug' : 'feature',
269
246
  email: formEmail.trim() || undefined,
270
247
  pageUrl: window.location.href,
271
248
  metadata,
@@ -284,280 +261,195 @@
284
261
  }
285
262
  }
286
263
 
287
- function getEnvironmentName(): string {
288
- const vercelEnv = getEnvVar('VERCEL_ENV');
289
- if (vercelEnv) return vercelEnv;
290
- if (window.location.hostname === 'localhost') return 'development';
291
- return 'unknown';
292
- }
293
-
294
264
  function openBoard() {
295
265
  if (!boardUrl) return;
296
266
  menuOpen = false;
297
267
  window.open(boardUrl, '_blank');
298
268
  }
299
-
300
- // Ensure menu stays in viewport
301
- let adjustedX = $derived(Math.min(menuX, window.innerWidth - 200));
302
- let adjustedY = $derived(Math.min(menuY, window.innerHeight - 160));
303
269
  </script>
304
270
 
305
271
  {#if isEnabled}
306
272
  <!-- Context menu -->
307
273
  {#if menuOpen}
308
- <!-- svelte-ignore a11y_no_static_element_interactions — context menu needs mousedown to prevent close-on-click -->
309
- <div
310
- class="fixed z-[99999]"
311
- style="left: {adjustedX}px; top: {adjustedY}px;"
312
- onmousedown={(e) => e.stopPropagation()}
313
- >
314
- <div class="sf-ctx-menu">
315
- <button class="sf-ctx-item" onclick={() => openForm('bug')}>
316
- <i class="fa-solid fa-bug text-red-400"></i>
317
- Report Bug
318
- </button>
319
- <button class="sf-ctx-item" onclick={() => openForm('feature')}>
320
- <i class="fa-solid fa-lightbulb text-amber-400"></i>
321
- Request Feature
322
- </button>
323
- {#if boardUrl}
324
- <div class="sf-ctx-divider">
325
- <button class="sf-ctx-item sf-ctx-item-secondary" onclick={openBoard}>
326
- <i class="fa-solid fa-clipboard-list"></i>
327
- View Open Issues
328
- </button>
329
- </div>
330
- {/if}
331
- </div>
332
- </div>
274
+ <DevContextMenu
275
+ x={menuX}
276
+ y={menuY}
277
+ {boardUrl}
278
+ onReportBug={() => openForm('bug')}
279
+ onRequestFeature={() => openForm('feature')}
280
+ onViewBoard={boardUrl ? openBoard : undefined}
281
+ />
333
282
  {/if}
334
283
 
335
284
  <!-- Form modal -->
336
285
  {#if formOpen}
337
- <!-- svelte-ignore a11y_no_static_element_interactions — modal backdrop needs mousedown for click-outside-to-close -->
338
- <div class="fixed inset-0 z-[99998] flex items-center justify-center bg-black/50" onmousedown={(e) => { if (e.target === e.currentTarget) formOpen = false; }}>
339
- <!-- svelte-ignore a11y_no_static_element_interactions — prevents modal close when clicking inside form -->
340
- <div class="w-full max-w-lg mx-4" onmousedown={(e) => e.stopPropagation()}>
341
- <Card variant="elevated" padding="none">
342
- <!-- Header -->
343
- <div class="flex items-center justify-between px-5 py-4 border-b border-border">
344
- <div class="flex items-center gap-2">
345
- {#if formType === 'bug'}
346
- <i class="fa-solid fa-bug text-red-400"></i>
347
- <h3 class="text-base font-semibold">Report Bug</h3>
348
- {:else}
349
- <i class="fa-solid fa-lightbulb text-amber-400"></i>
350
- <h3 class="text-base font-semibold">Request Feature</h3>
351
- {/if}
352
- <Badge variant="neutral" size="sm">{getEnvironmentName()}</Badge>
353
- </div>
354
- <button
355
- class="text-foreground-tertiary hover:text-foreground transition-colors bg-transparent border-none cursor-pointer text-lg"
356
- onclick={() => (formOpen = false)}
357
- aria-label="Close feedback form"
358
- >
359
- <i class="fa-solid fa-xmark"></i>
360
- </button>
286
+ <div
287
+ class="sf-modal fixed z-[99998] w-full max-w-lg flex flex-col"
288
+ style={modalPositioned
289
+ ? `left: ${modalX}px; top: ${modalY}px;`
290
+ : 'left: 50%; top: 50%; transform: translate(-50%, -50%);'
291
+ }
292
+ >
293
+ <Card variant="elevated" padding="none" class="flex flex-col max-h-[calc(100vh-2rem)] overflow-hidden">
294
+ <!-- Header (draggable) -->
295
+ <!-- svelte-ignore a11y_no_static_element_interactions — drag handle needs mousedown -->
296
+ <div
297
+ class="flex items-center justify-between px-5 py-4 border-b border-border shrink-0 select-none"
298
+ style="cursor: {isDragging ? 'grabbing' : 'grab'};"
299
+ onmousedown={handleDragStart}
300
+ >
301
+ <div class="flex items-center gap-2">
302
+ {#if formType === 'bug'}
303
+ <i class="fa-solid fa-bug text-red-400"></i>
304
+ <h3 class="text-base font-semibold">Report Bug</h3>
305
+ {:else}
306
+ <i class="fa-solid fa-lightbulb text-amber-400"></i>
307
+ <h3 class="text-base font-semibold">Request Feature</h3>
308
+ {/if}
309
+ <Badge variant="neutral" size="sm">{getEnvironmentName()}</Badge>
361
310
  </div>
311
+ <button
312
+ class="text-foreground-tertiary hover:text-foreground transition-colors bg-transparent border-none cursor-pointer text-lg"
313
+ onclick={() => (formOpen = false)}
314
+ aria-label="Close feedback form"
315
+ >
316
+ <i class="fa-solid fa-xmark"></i>
317
+ </button>
318
+ </div>
362
319
 
363
- <!-- Body -->
364
- <div class="p-5 space-y-4">
365
- {#if submitResult === 'success'}
366
- <div class="text-center py-6">
367
- <i class="fa-solid fa-check-circle text-3xl text-green-400"></i>
368
- <p class="mt-2 font-semibold">Submitted!</p>
369
- <p class="text-sm text-foreground-secondary">
370
- {formType === 'bug' ? "We'll look into this." : "Thanks for the suggestion!"}
371
- </p>
372
- </div>
373
- {:else}
374
- <Input
375
- bind:value={formTitle}
376
- placeholder={formType === 'bug' ? 'Brief summary of the bug' : 'Feature title'}
377
- label="Title"
378
- />
379
-
380
- <Textarea
381
- value={formMessage} oninput={(e) => (formMessage = (e.currentTarget as HTMLTextAreaElement).value)}
382
- placeholder={formType === 'bug'
383
- ? 'What happened? What did you expect?'
384
- : 'Describe the feature you\'d like...'}
385
- label={formType === 'bug' ? 'Description' : 'Details'}
386
- rows={4}
387
- />
388
-
389
- <Input
390
- type="email"
391
- bind:value={formEmail}
392
- placeholder="your@email.com (optional)"
393
- label="Email"
394
- />
395
-
396
- <!-- Auto-captured context (bugs only) -->
397
- {#if formType === 'bug'}
398
- <div class="space-y-2">
399
- <p class="text-xs font-semibold text-foreground-tertiary uppercase tracking-wide">Auto-captured</p>
400
- <div class="flex flex-wrap gap-1.5">
401
- <Badge variant="info" size="sm">
402
- <i class="fa-solid fa-route mr-1"></i>
403
- {window.location.pathname}
320
+ <!-- Body (scrollable) -->
321
+ <div class="p-5 space-y-4 overflow-y-auto">
322
+ {#if submitResult === 'success'}
323
+ <div class="text-center py-6">
324
+ <i class="fa-solid fa-check-circle text-3xl text-green-400"></i>
325
+ <p class="mt-2 font-semibold">Submitted!</p>
326
+ <p class="text-sm text-foreground-secondary">
327
+ {formType === 'bug' ? "We'll look into this." : "Thanks for the suggestion!"}
328
+ </p>
329
+ </div>
330
+ {:else}
331
+ <Input
332
+ bind:value={formTitle}
333
+ placeholder={formType === 'bug' ? 'Brief summary of the bug' : 'Feature title'}
334
+ label="Title"
335
+ />
336
+
337
+ <Textarea
338
+ value={formMessage} oninput={(e) => (formMessage = (e.currentTarget as HTMLTextAreaElement).value)}
339
+ placeholder={formType === 'bug'
340
+ ? 'What happened? What did you expect?'
341
+ : 'Describe the feature you\'d like...'}
342
+ label={formType === 'bug' ? 'Description' : 'Details'}
343
+ rows={4}
344
+ />
345
+
346
+ <Input
347
+ type="email"
348
+ bind:value={formEmail}
349
+ placeholder="your@email.com (optional)"
350
+ label="Email"
351
+ />
352
+
353
+ <!-- Auto-captured context (bugs only) -->
354
+ {#if formType === 'bug'}
355
+ <div class="space-y-2">
356
+ <p class="text-xs font-semibold text-foreground-tertiary uppercase tracking-wide">Auto-captured</p>
357
+ <div class="flex flex-wrap gap-1.5">
358
+ <Badge variant="info" size="sm">
359
+ <i class="fa-solid fa-route mr-1"></i>
360
+ {window.location.pathname}
361
+ </Badge>
362
+ <Badge variant="info" size="sm">
363
+ <i class="fa-solid fa-display mr-1"></i>
364
+ {window.innerWidth}×{window.innerHeight}
365
+ </Badge>
366
+ {#if consoleErrors.length > 0}
367
+ <Badge variant="danger" size="sm">
368
+ <i class="fa-solid fa-triangle-exclamation mr-1"></i>
369
+ {consoleErrors.length} console error{consoleErrors.length !== 1 ? 's' : ''}
404
370
  </Badge>
405
- <Badge variant="info" size="sm">
406
- <i class="fa-solid fa-display mr-1"></i>
407
- {window.innerWidth}×{window.innerHeight}
371
+ {/if}
372
+ {#if clickedElement}
373
+ <Badge variant="neutral" size="sm">
374
+ <i class="fa-solid fa-crosshairs mr-1"></i>
375
+ {getElementSelector(clickedElement).slice(-40)}
408
376
  </Badge>
409
- {#if consoleErrors.length > 0}
410
- <Badge variant="danger" size="sm">
411
- <i class="fa-solid fa-triangle-exclamation mr-1"></i>
412
- {consoleErrors.length} console error{consoleErrors.length !== 1 ? 's' : ''}
413
- </Badge>
414
- {/if}
415
- {#if clickedElement}
416
- <Badge variant="neutral" size="sm">
417
- <i class="fa-solid fa-crosshairs mr-1"></i>
418
- {getElementSelector(clickedElement).slice(-40)}
419
- </Badge>
420
- {/if}
421
- </div>
422
-
423
- <!-- Screenshot preview -->
424
- {#if capturingScreenshot}
425
- <div class="flex items-center gap-2 text-sm text-foreground-tertiary">
426
- <Spinner /> Capturing screenshot...
427
- </div>
428
- {:else if screenshotData}
429
- <div class="relative">
430
- <img
431
- src={screenshotData}
432
- alt="Screenshot"
433
- class="w-full rounded-md border border-border max-h-40 object-cover object-top"
434
- />
435
- <button
436
- class="absolute top-1 right-1 bg-black/60 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs border-none cursor-pointer hover:bg-black/80"
437
- onclick={() => (screenshotData = null)}
438
- aria-label="Remove screenshot"
439
- >
440
- <i class="fa-solid fa-xmark"></i>
441
- </button>
442
- </div>
443
377
  {/if}
444
378
  </div>
445
- {/if}
446
-
447
- {#if submitResult === 'error'}
448
- <p class="text-sm text-red-400">
449
- <i class="fa-solid fa-exclamation-circle mr-1"></i>
450
- Failed to submit. Please try again.
451
- </p>
452
- {/if}
453
-
454
- <div class="flex justify-end gap-2 pt-1">
455
- <Button variant="neutral" style="outline" onclick={() => (formOpen = false)}>
456
- Cancel
457
- </Button>
458
- <Button
459
- variant={formType === 'bug' ? 'danger' : 'primary'}
460
- disabled={submitting || !formMessage.trim()}
461
- loading={submitting}
462
- onclick={handleSubmit}
379
+
380
+ <!-- Screenshot preview -->
381
+ {#if capturingScreenshot_}
382
+ <div class="flex items-center gap-2 text-sm text-foreground-tertiary">
383
+ <Spinner /> Capturing screenshot...
384
+ </div>
385
+ {:else if screenshotFailed}
386
+ <div class="flex items-center gap-2 text-sm text-foreground-tertiary">
387
+ <i class="fa-solid fa-image-slash" aria-hidden="true"></i>
388
+ <span>Screenshot unavailable</span>
389
+ <button
390
+ class="text-xs text-primary hover:underline bg-transparent border-none cursor-pointer p-0"
391
+ onclick={doScreenshot}
463
392
  >
464
- {formType === 'bug' ? 'Report Bug' : 'Submit Request'}
465
- </Button>
393
+ Retry
394
+ </button>
395
+ </div>
396
+ {:else if screenshotData}
397
+ <div class="relative">
398
+ <img
399
+ src={screenshotData}
400
+ alt="Screenshot"
401
+ class="w-full rounded-md border border-border max-h-40 object-cover object-top"
402
+ />
403
+ <button
404
+ class="absolute top-1 right-1 bg-black/60 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs border-none cursor-pointer hover:bg-black/80"
405
+ onclick={() => (screenshotData = null)}
406
+ aria-label="Remove screenshot"
407
+ >
408
+ <i class="fa-solid fa-xmark"></i>
409
+ </button>
410
+ </div>
411
+ {/if}
466
412
  </div>
467
413
  {/if}
414
+
415
+ {#if submitResult === 'error'}
416
+ <p class="text-sm text-red-400">
417
+ <i class="fa-solid fa-exclamation-circle mr-1"></i>
418
+ Failed to submit. Please try again.
419
+ </p>
420
+ {/if}
421
+ {/if}
422
+ </div>
423
+
424
+ <!-- Footer -->
425
+ {#if submitResult !== 'success'}
426
+ <div class="flex justify-end gap-2 px-5 py-4 border-t border-border shrink-0">
427
+ <Button variant="neutral" style="outline" onclick={() => (formOpen = false)}>
428
+ Cancel
429
+ </Button>
430
+ <Button
431
+ variant={formType === 'bug' ? 'danger' : 'primary'}
432
+ disabled={submitting || !formMessage.trim()}
433
+ loading={submitting}
434
+ onclick={handleSubmit}
435
+ >
436
+ {formType === 'bug' ? 'Report Bug' : 'Submit Request'}
437
+ </Button>
468
438
  </div>
469
- </Card>
470
- </div>
439
+ {/if}
440
+ </Card>
471
441
  </div>
472
442
  {/if}
473
443
 
474
444
  <!-- Onboarding toast -->
475
445
  {#if showToast}
476
- <div class="fixed bottom-4 right-4 z-[99997] animate-fade-in">
477
- <div class="sf-toast">
478
- <i class="fa-solid fa-computer-mouse" style="color: var(--color-primary, #3b82f6); font-size: 1.125rem;"></i>
479
- <div>
480
- <p style="font-size: 0.875rem; font-weight: 600; margin: 0;">Ctrl + Right-click anywhere</p>
481
- <p style="font-size: 0.75rem; color: #94a3b8; margin: 0;">to report bugs or request features</p>
482
- </div>
483
- <button class="sf-toast-close" onclick={() => (showToast = false)} aria-label="Dismiss">
484
- <i class="fa-solid fa-xmark"></i>
485
- </button>
486
- </div>
487
- </div>
446
+ <DevOnboardingToast onDismiss={() => (showToast = false)} />
488
447
  {/if}
489
448
  {/if}
490
449
 
491
450
  <style>
492
- @keyframes fade-in {
493
- from { opacity: 0; transform: translateY(8px); }
494
- to { opacity: 1; transform: translateY(0); }
495
- }
496
- .animate-fade-in {
497
- animation: fade-in 0.3s ease-out;
498
- }
499
-
500
- /* Context menu — hardcoded colors for guaranteed visibility */
501
- .sf-ctx-menu {
502
- background: #1e293b;
503
- border: 1px solid #334155;
504
- border-radius: 0.5rem;
505
- box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5);
506
- overflow: hidden;
507
- min-width: 180px;
508
- }
509
-
510
- .sf-ctx-item {
511
- display: flex;
512
- align-items: center;
513
- gap: 0.625rem;
514
- width: 100%;
515
- padding: 0.625rem 0.75rem;
516
- font-size: 0.875rem;
517
- color: #e2e8f0;
518
- background: transparent;
519
- border: none;
520
- cursor: pointer;
521
- text-align: left;
522
- transition: background 0.15s;
523
- }
524
-
525
- .sf-ctx-item:hover {
526
- background: #334155;
527
- }
528
-
529
- .sf-ctx-item-secondary {
530
- color: #94a3b8;
531
- }
532
-
533
- .sf-ctx-divider {
534
- border-top: 1px solid #334155;
535
- }
536
-
537
- /* Toast */
538
- .sf-toast {
539
- background: #1e293b;
540
- border: 1px solid #334155;
541
- border-radius: 0.5rem;
542
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
543
- padding: 0.75rem 1rem;
544
- display: flex;
545
- align-items: center;
546
- gap: 0.75rem;
547
- max-width: 20rem;
548
- color: #e2e8f0;
549
- }
550
-
551
- .sf-toast-close {
552
- color: #64748b;
553
- background: transparent;
554
- border: none;
555
- cursor: pointer;
556
- margin-left: 0.5rem;
557
- transition: color 0.15s;
558
- }
559
-
560
- .sf-toast-close:hover {
561
- color: #e2e8f0;
451
+ /* Floating modal shadow — no backdrop so needs strong depth cue */
452
+ .sf-modal :global(.card) {
453
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.05);
562
454
  }
563
455
  </style>