@alliance-droid/status-feedback-system 1.3.0 → 1.3.2

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 +162 -322
  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 +25 -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 }));
48
+ // --- Client (lazy, stable) ---
49
+ let client: StatusClient | null = $state(null);
50
+ let lastApiUrl = '';
51
+ let lastApiKey = '';
40
52
 
41
- // --- Environment gate ---
42
- let isEnabled = $state(false);
43
-
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,8 @@
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);
94
82
 
95
83
  // Drag state
96
84
  let isDragging = $state(false);
@@ -130,7 +118,7 @@
130
118
  const ONBOARDING_KEY = 'sf-devfeedback-onboarded';
131
119
 
132
120
  onMount(() => {
133
- isEnabled = detectEnvironment();
121
+ isEnabled = detectEnvironment(enabled);
134
122
  if (!isEnabled) return;
135
123
 
136
124
  // Hook console.error
@@ -143,11 +131,8 @@
143
131
 
144
132
  // Ctrl+Right-click handler
145
133
  document.addEventListener('contextmenu', handleContextMenu);
146
- // Close menu on any other click
147
134
  document.addEventListener('click', closeMenu);
148
- // Escape closes everything
149
135
  document.addEventListener('keydown', handleKeydown);
150
- // Drag listeners (on document so drag continues outside modal)
151
136
  document.addEventListener('mousemove', handleDragMove);
152
137
  document.addEventListener('mouseup', handleDragEnd);
153
138
 
@@ -170,7 +155,6 @@
170
155
  });
171
156
 
172
157
  function handleContextMenu(e: MouseEvent) {
173
- // Only trigger on Ctrl+Right-click
174
158
  if (!e.ctrlKey) return;
175
159
  e.preventDefault();
176
160
  e.stopPropagation();
@@ -194,54 +178,12 @@
194
178
  }
195
179
  }
196
180
 
197
- function getElementSelector(el: HTMLElement | null): string {
198
- if (!el) return '';
199
- const parts: string[] = [];
200
- let current: HTMLElement | null = el;
201
- while (current && current !== document.body) {
202
- if (!current.tagName) { current = current.parentElement; continue; }
203
- let selector = current.tagName.toLowerCase();
204
- if (current.id) {
205
- selector += `#${current.id}`;
206
- parts.unshift(selector);
207
- break;
208
- }
209
- if (current.className && typeof current.className === 'string') {
210
- const classes = current.className.trim().split(/\s+/).slice(0, 2).join('.');
211
- if (classes) selector += `.${classes}`;
212
- }
213
- parts.unshift(selector);
214
- current = current.parentElement;
215
- }
216
- return parts.join(' > ');
217
- }
218
-
219
- function getElementHtml(el: HTMLElement | null): string {
220
- if (!el) return '';
221
- const html = el.outerHTML;
222
- return html.length > 500 ? html.slice(0, 500) + '...' : html;
223
- }
224
-
225
- async function captureScreenshot() {
226
- capturingScreenshot = true;
227
- try {
228
- const html2canvas = (await import('html2canvas')).default;
229
- const canvas = await html2canvas(document.body, {
230
- logging: false,
231
- useCORS: true,
232
- scale: 1,
233
- width: window.innerWidth,
234
- height: window.innerHeight,
235
- windowWidth: window.innerWidth,
236
- windowHeight: window.innerHeight,
237
- });
238
- screenshotData = canvas.toDataURL('image/png');
239
- } catch {
240
- // Screenshot capture can fail in cross-origin iframes or restricted contexts
241
- screenshotData = null;
242
- } finally {
243
- capturingScreenshot = false;
244
- }
181
+ async function doScreenshot() {
182
+ capturingScreenshot_ = true;
183
+ screenshotFailed = false;
184
+ screenshotData = await captureScreenshot();
185
+ if (!screenshotData) screenshotFailed = true;
186
+ capturingScreenshot_ = false;
245
187
  }
246
188
 
247
189
  function openForm(type: 'bug' | 'feature') {
@@ -252,14 +194,12 @@
252
194
  submitResult = null;
253
195
  menuOpen = false;
254
196
  formOpen = true;
255
- // Reset drag position (center on screen)
256
197
  modalPositioned = false;
257
198
  modalX = 0;
258
199
  modalY = 0;
259
200
 
260
- // Auto-capture screenshot for bugs
261
201
  if (type === 'bug') {
262
- captureScreenshot();
202
+ doScreenshot();
263
203
  } else {
264
204
  screenshotData = null;
265
205
  }
@@ -292,7 +232,6 @@
292
232
  metadata.screenshot = screenshotData;
293
233
  }
294
234
 
295
- // Deployment info
296
235
  const gitCommit = getEnvVar('VERCEL_GIT_COMMIT_SHA');
297
236
  const gitBranch = getEnvVar('VERCEL_GIT_COMMIT_REF');
298
237
  const deployUrl = getEnvVar('VERCEL_URL');
@@ -300,10 +239,10 @@
300
239
  if (gitBranch) metadata.gitBranch = gitBranch;
301
240
  if (deployUrl) metadata.deploymentUrl = deployUrl;
302
241
 
303
- await client.submitFeedback({
242
+ await getClient().submitFeedback({
304
243
  title: formTitle.trim() || undefined,
305
244
  message: formMessage.trim(),
306
- category: formType === 'bug' ? 'Bug' : 'Feature Request',
245
+ category: formType === 'bug' ? 'bug' : 'feature',
307
246
  email: formEmail.trim() || undefined,
308
247
  pageUrl: window.location.href,
309
248
  metadata,
@@ -322,57 +261,28 @@
322
261
  }
323
262
  }
324
263
 
325
- function getEnvironmentName(): string {
326
- const vercelEnv = getEnvVar('VERCEL_ENV');
327
- if (vercelEnv) return vercelEnv;
328
- if (window.location.hostname === 'localhost') return 'development';
329
- return 'unknown';
330
- }
331
-
332
264
  function openBoard() {
333
265
  if (!boardUrl) return;
334
266
  menuOpen = false;
335
267
  window.open(boardUrl, '_blank');
336
268
  }
337
-
338
- // Ensure menu stays in viewport
339
- let adjustedX = $derived(Math.min(menuX, window.innerWidth - 200));
340
- let adjustedY = $derived(Math.min(menuY, window.innerHeight - 160));
341
269
  </script>
342
270
 
343
271
  {#if isEnabled}
344
272
  <!-- Context menu -->
345
273
  {#if menuOpen}
346
- <!-- svelte-ignore a11y_no_static_element_interactions — context menu needs mousedown to prevent close-on-click -->
347
- <div
348
- class="fixed z-[99999]"
349
- style="left: {adjustedX}px; top: {adjustedY}px;"
350
- onmousedown={(e) => e.stopPropagation()}
351
- >
352
- <div class="sf-ctx-menu">
353
- <button class="sf-ctx-item" onclick={() => openForm('bug')}>
354
- <i class="fa-solid fa-bug text-red-400"></i>
355
- Report Bug
356
- </button>
357
- <button class="sf-ctx-item" onclick={() => openForm('feature')}>
358
- <i class="fa-solid fa-lightbulb text-amber-400"></i>
359
- Request Feature
360
- </button>
361
- {#if boardUrl}
362
- <div class="sf-ctx-divider">
363
- <button class="sf-ctx-item sf-ctx-item-secondary" onclick={openBoard}>
364
- <i class="fa-solid fa-clipboard-list"></i>
365
- View Open Issues
366
- </button>
367
- </div>
368
- {/if}
369
- </div>
370
- </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
+ />
371
282
  {/if}
372
283
 
373
284
  <!-- Form modal -->
374
285
  {#if formOpen}
375
- <!-- No backdrop — modal floats freely over the page -->
376
286
  <div
377
287
  class="sf-modal fixed z-[99998] w-full max-w-lg flex flex-col"
378
288
  style={modalPositioned
@@ -381,7 +291,7 @@
381
291
  }
382
292
  >
383
293
  <Card variant="elevated" padding="none" class="flex flex-col max-h-[calc(100vh-2rem)] overflow-hidden">
384
- <!-- Header (fixed) -->
294
+ <!-- Header (draggable) -->
385
295
  <!-- svelte-ignore a11y_no_static_element_interactions — drag handle needs mousedown -->
386
296
  <div
387
297
  class="flex items-center justify-between px-5 py-4 border-b border-border shrink-0 select-none"
@@ -409,208 +319,138 @@
409
319
 
410
320
  <!-- Body (scrollable) -->
411
321
  <div class="p-5 space-y-4 overflow-y-auto">
412
- {#if submitResult === 'success'}
413
- <div class="text-center py-6">
414
- <i class="fa-solid fa-check-circle text-3xl text-green-400"></i>
415
- <p class="mt-2 font-semibold">Submitted!</p>
416
- <p class="text-sm text-foreground-secondary">
417
- {formType === 'bug' ? "We'll look into this." : "Thanks for the suggestion!"}
418
- </p>
419
- </div>
420
- {:else}
421
- <Input
422
- bind:value={formTitle}
423
- placeholder={formType === 'bug' ? 'Brief summary of the bug' : 'Feature title'}
424
- label="Title"
425
- />
426
-
427
- <Textarea
428
- value={formMessage} oninput={(e) => (formMessage = (e.currentTarget as HTMLTextAreaElement).value)}
429
- placeholder={formType === 'bug'
430
- ? 'What happened? What did you expect?'
431
- : 'Describe the feature you\'d like...'}
432
- label={formType === 'bug' ? 'Description' : 'Details'}
433
- rows={4}
434
- />
435
-
436
- <Input
437
- type="email"
438
- bind:value={formEmail}
439
- placeholder="your@email.com (optional)"
440
- label="Email"
441
- />
442
-
443
- <!-- Auto-captured context (bugs only) -->
444
- {#if formType === 'bug'}
445
- <div class="space-y-2">
446
- <p class="text-xs font-semibold text-foreground-tertiary uppercase tracking-wide">Auto-captured</p>
447
- <div class="flex flex-wrap gap-1.5">
448
- <Badge variant="info" size="sm">
449
- <i class="fa-solid fa-route mr-1"></i>
450
- {window.location.pathname}
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' : ''}
451
370
  </Badge>
452
- <Badge variant="info" size="sm">
453
- <i class="fa-solid fa-display mr-1"></i>
454
- {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)}
455
376
  </Badge>
456
- {#if consoleErrors.length > 0}
457
- <Badge variant="danger" size="sm">
458
- <i class="fa-solid fa-triangle-exclamation mr-1"></i>
459
- {consoleErrors.length} console error{consoleErrors.length !== 1 ? 's' : ''}
460
- </Badge>
461
- {/if}
462
- {#if clickedElement}
463
- <Badge variant="neutral" size="sm">
464
- <i class="fa-solid fa-crosshairs mr-1"></i>
465
- {getElementSelector(clickedElement).slice(-40)}
466
- </Badge>
467
- {/if}
468
- </div>
469
-
470
- <!-- Screenshot preview -->
471
- {#if capturingScreenshot}
472
- <div class="flex items-center gap-2 text-sm text-foreground-tertiary">
473
- <Spinner /> Capturing screenshot...
474
- </div>
475
- {:else if screenshotData}
476
- <div class="relative">
477
- <img
478
- src={screenshotData}
479
- alt="Screenshot"
480
- class="w-full rounded-md border border-border max-h-40 object-cover object-top"
481
- />
482
- <button
483
- 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"
484
- onclick={() => (screenshotData = null)}
485
- aria-label="Remove screenshot"
486
- >
487
- <i class="fa-solid fa-xmark"></i>
488
- </button>
489
- </div>
490
377
  {/if}
491
378
  </div>
492
- {/if}
493
-
494
- {#if submitResult === 'error'}
495
- <p class="text-sm text-red-400">
496
- <i class="fa-solid fa-exclamation-circle mr-1"></i>
497
- Failed to submit. Please try again.
498
- </p>
499
- {/if}
500
379
 
501
- {/if}
502
- </div>
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="rounded-md border border-border bg-surface p-3">
387
+ <div class="flex items-center gap-2 text-sm text-foreground-secondary mb-1">
388
+ <i class="fa-solid fa-image-slash" aria-hidden="true"></i>
389
+ <span>Screenshot capture failed</span>
390
+ </div>
391
+ <p class="text-xs text-foreground-tertiary mb-2">Check console for error. May need: <code class="bg-background px-1 rounded">npm install html2canvas</code></p>
392
+ <button
393
+ class="text-xs text-primary hover:underline bg-transparent border-none cursor-pointer p-0"
394
+ onclick={doScreenshot}
395
+ >
396
+ Retry
397
+ </button>
398
+ </div>
399
+ {:else if screenshotData}
400
+ <div class="relative">
401
+ <img
402
+ src={screenshotData}
403
+ alt="Screenshot"
404
+ class="w-full rounded-md border border-border max-h-40 object-cover object-top"
405
+ />
406
+ <button
407
+ 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"
408
+ onclick={() => (screenshotData = null)}
409
+ aria-label="Remove screenshot"
410
+ >
411
+ <i class="fa-solid fa-xmark"></i>
412
+ </button>
413
+ </div>
414
+ {/if}
415
+ </div>
416
+ {/if}
503
417
 
504
- <!-- Footer (fixed) -->
505
- {#if submitResult !== 'success'}
506
- <div class="flex justify-end gap-2 px-5 py-4 border-t border-border shrink-0">
507
- <Button variant="neutral" style="outline" onclick={() => (formOpen = false)}>
508
- Cancel
509
- </Button>
510
- <Button
511
- variant={formType === 'bug' ? 'danger' : 'primary'}
512
- disabled={submitting || !formMessage.trim()}
513
- loading={submitting}
514
- onclick={handleSubmit}
515
- >
516
- {formType === 'bug' ? 'Report Bug' : 'Submit Request'}
517
- </Button>
518
- </div>
418
+ {#if submitResult === 'error'}
419
+ <p class="text-sm text-red-400">
420
+ <i class="fa-solid fa-exclamation-circle mr-1"></i>
421
+ Failed to submit. Please try again.
422
+ </p>
423
+ {/if}
519
424
  {/if}
520
- </Card>
521
- </div>
425
+ </div>
426
+
427
+ <!-- Footer -->
428
+ {#if submitResult !== 'success'}
429
+ <div class="flex justify-end gap-2 px-5 py-4 border-t border-border shrink-0">
430
+ <Button variant="neutral" style="outline" onclick={() => (formOpen = false)}>
431
+ Cancel
432
+ </Button>
433
+ <Button
434
+ variant={formType === 'bug' ? 'danger' : 'primary'}
435
+ disabled={submitting || !formMessage.trim()}
436
+ loading={submitting}
437
+ onclick={handleSubmit}
438
+ >
439
+ {formType === 'bug' ? 'Report Bug' : 'Submit Request'}
440
+ </Button>
441
+ </div>
442
+ {/if}
443
+ </Card>
444
+ </div>
522
445
  {/if}
523
446
 
524
447
  <!-- Onboarding toast -->
525
448
  {#if showToast}
526
- <div class="fixed bottom-4 right-4 z-[99997] animate-fade-in">
527
- <div class="sf-toast">
528
- <i class="fa-solid fa-computer-mouse" style="color: var(--color-primary, #3b82f6); font-size: 1.125rem;"></i>
529
- <div>
530
- <p style="font-size: 0.875rem; font-weight: 600; margin: 0;">Ctrl + Right-click anywhere</p>
531
- <p style="font-size: 0.75rem; color: #94a3b8; margin: 0;">to report bugs or request features</p>
532
- </div>
533
- <button class="sf-toast-close" onclick={() => (showToast = false)} aria-label="Dismiss">
534
- <i class="fa-solid fa-xmark"></i>
535
- </button>
536
- </div>
537
- </div>
449
+ <DevOnboardingToast onDismiss={() => (showToast = false)} />
538
450
  {/if}
539
451
  {/if}
540
452
 
541
453
  <style>
542
- @keyframes fade-in {
543
- from { opacity: 0; transform: translateY(8px); }
544
- to { opacity: 1; transform: translateY(0); }
545
- }
546
- .animate-fade-in {
547
- animation: fade-in 0.3s ease-out;
548
- }
549
-
550
- /* Context menu — hardcoded colors for guaranteed visibility */
551
- .sf-ctx-menu {
552
- background: #1e293b;
553
- border: 1px solid #334155;
554
- border-radius: 0.5rem;
555
- box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5);
556
- overflow: hidden;
557
- min-width: 180px;
558
- }
559
-
560
- .sf-ctx-item {
561
- display: flex;
562
- align-items: center;
563
- gap: 0.625rem;
564
- width: 100%;
565
- padding: 0.625rem 0.75rem;
566
- font-size: 0.875rem;
567
- color: #e2e8f0;
568
- background: transparent;
569
- border: none;
570
- cursor: pointer;
571
- text-align: left;
572
- transition: background 0.15s;
573
- }
574
-
575
- .sf-ctx-item:hover {
576
- background: #334155;
577
- }
578
-
579
- .sf-ctx-item-secondary {
580
- color: #94a3b8;
581
- }
582
-
583
- .sf-ctx-divider {
584
- border-top: 1px solid #334155;
585
- }
586
-
587
- /* Toast */
588
- .sf-toast {
589
- background: #1e293b;
590
- border: 1px solid #334155;
591
- border-radius: 0.5rem;
592
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
593
- padding: 0.75rem 1rem;
594
- display: flex;
595
- align-items: center;
596
- gap: 0.75rem;
597
- max-width: 20rem;
598
- color: #e2e8f0;
599
- }
600
-
601
- .sf-toast-close {
602
- color: #64748b;
603
- background: transparent;
604
- border: none;
605
- cursor: pointer;
606
- margin-left: 0.5rem;
607
- transition: color 0.15s;
608
- }
609
-
610
- .sf-toast-close:hover {
611
- color: #e2e8f0;
612
- }
613
-
614
454
  /* Floating modal shadow — no backdrop so needs strong depth cue */
615
455
  .sf-modal :global(.card) {
616
456
  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.05);