@alliance-droid/status-feedback-system 1.3.0 → 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 +159 -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 +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,7 +1,8 @@
1
1
  <script lang="ts">
2
2
  import { onMount, untrack } from 'svelte';
3
3
  import type { System, Incident, StatusHistoryEntry, SystemStatus } from '../types.js';
4
- import { createStatusClient } from '../api.js';
4
+ import { createStatusClient, type StatusClient } from '../api.js';
5
+ import { deriveOverallStatus } from '../utils.js';
5
6
  import StatusIndicator from './shared/StatusIndicator.svelte';
6
7
  import SystemCard from './shared/SystemCard.svelte';
7
8
  import IncidentTimeline from './shared/IncidentTimeline.svelte';
@@ -20,6 +21,8 @@
20
21
  /** Map of systemId → StatusHistoryEntry[] for uptime bars (pass-through mode) */
21
22
  statusHistory?: Record<string, StatusHistoryEntry[]>;
22
23
  showUptime?: boolean;
24
+ /** Auto-refresh interval in seconds. 0 = disabled. Default: 0 */
25
+ refreshInterval?: number;
23
26
  }
24
27
 
25
28
  let {
@@ -30,6 +33,7 @@
30
33
  incidents: incidentsProp,
31
34
  statusHistory: statusHistoryProp,
32
35
  showUptime = true,
36
+ refreshInterval = 0,
33
37
  }: Props = $props();
34
38
 
35
39
  // ── State ──
@@ -41,12 +45,26 @@
41
45
 
42
46
  // ── Self-fetching mode ──
43
47
  const usesApi = $derived(!!apiUrl && !!apiKey && !systemsProp);
48
+ let refreshTimer: ReturnType<typeof setInterval> | null = null;
44
49
 
45
- onMount(async () => {
50
+ let cachedClient: StatusClient | null = null;
51
+ let cachedApiUrl = '';
52
+ let cachedApiKey = '';
53
+
54
+ function getClient(): StatusClient {
55
+ if (!cachedClient || cachedApiUrl !== apiUrl || cachedApiKey !== apiKey) {
56
+ cachedClient = createStatusClient({ apiUrl: apiUrl!, apiKey: apiKey! });
57
+ cachedApiUrl = apiUrl!;
58
+ cachedApiKey = apiKey!;
59
+ }
60
+ return cachedClient;
61
+ }
62
+
63
+ async function fetchData() {
46
64
  if (!usesApi) return;
47
65
 
48
66
  try {
49
- const client = createStatusClient({ apiUrl: apiUrl!, apiKey: apiKey! });
67
+ const client = getClient();
50
68
 
51
69
  const [statusData, incidentData, uptimeData] = await Promise.all([
52
70
  client.getStatus(),
@@ -57,11 +75,24 @@
57
75
  systems = statusData.systems;
58
76
  incidents = incidentData.incidents;
59
77
  statusHistory = uptimeData.history;
78
+ error = '';
60
79
  } catch (err) {
61
80
  error = err instanceof Error ? err.message : 'Failed to load status data';
62
81
  } finally {
63
82
  loading = false;
64
83
  }
84
+ }
85
+
86
+ onMount(() => {
87
+ fetchData();
88
+
89
+ if (refreshInterval > 0) {
90
+ refreshTimer = setInterval(fetchData, refreshInterval * 1000);
91
+ }
92
+
93
+ return () => {
94
+ if (refreshTimer) clearInterval(refreshTimer);
95
+ };
65
96
  });
66
97
 
67
98
  // ── Sync props when in pass-through mode ──
@@ -77,13 +108,7 @@
77
108
 
78
109
  // ── Derived state ──
79
110
 
80
- let overallStatus = $derived.by((): SystemStatus => {
81
- if (systems.length === 0) return 'operational';
82
- if (systems.some((s) => s.status === 'outage')) return 'outage';
83
- if (systems.some((s) => s.status === 'degraded')) return 'degraded';
84
- if (systems.some((s) => s.status === 'maintenance')) return 'maintenance';
85
- return 'operational';
86
- });
111
+ let overallStatus = $derived(deriveOverallStatus(systems));
87
112
 
88
113
  const overallMessages: Record<SystemStatus, string> = {
89
114
  operational: 'All Systems Operational',
@@ -128,6 +153,14 @@
128
153
  <div class="rounded-lg border border-red-500/30 bg-red-500/10 p-6 text-center">
129
154
  <i class="fa-solid fa-circle-exclamation mb-2 text-2xl text-red-500"></i>
130
155
  <p class="text-sm text-red-400">{error}</p>
156
+ {#if usesApi}
157
+ <button
158
+ onclick={() => { loading = true; fetchData(); }}
159
+ class="mt-3 rounded-md bg-surface-hover px-4 py-2 text-sm text-foreground-secondary transition-colors hover:bg-border"
160
+ >
161
+ <i class="fa-solid fa-rotate-right mr-1"></i> Retry
162
+ </button>
163
+ {/if}
131
164
  </div>
132
165
  {:else}
133
166
  <!-- Overall Status Banner -->
@@ -13,6 +13,8 @@ interface Props {
13
13
  /** Map of systemId → StatusHistoryEntry[] for uptime bars (pass-through mode) */
14
14
  statusHistory?: Record<string, StatusHistoryEntry[]>;
15
15
  showUptime?: boolean;
16
+ /** Auto-refresh interval in seconds. 0 = disabled. Default: 0 */
17
+ refreshInterval?: number;
16
18
  }
17
19
  declare const StatusPage: import("svelte").Component<Props, {}, "">;
18
20
  type StatusPage = ReturnType<typeof StatusPage>;
@@ -25,7 +25,7 @@
25
25
  <!-- Header -->
26
26
  <div class="flex items-center gap-2 flex-wrap">
27
27
  {#if item.category}
28
- <i class="{board.getCategoryIcon(item.category)} text-foreground-secondary"></i>
28
+ <i class="{board.getCategoryIcon(item.category)} text-foreground-secondary" aria-hidden="true"></i>
29
29
  {/if}
30
30
  <Badge variant={getStatusBadgeVariant(item.status)} size="sm">
31
31
  {item.status === 'new' ? 'Open' : item.status.charAt(0).toUpperCase() + item.status.slice(1)}
@@ -33,7 +33,7 @@
33
33
  <span class="text-xs text-foreground-tertiary">{timeAgo(item.createdAt)}</span>
34
34
  {#if item.responseCount > 0}
35
35
  <span class="text-xs text-foreground-tertiary flex items-center gap-1">
36
- <i class="fa-solid fa-comment"></i> {item.responseCount}
36
+ <i class="fa-solid fa-comment" aria-hidden="true"></i> {item.responseCount}
37
37
  </span>
38
38
  {/if}
39
39
  </div>
@@ -45,8 +45,8 @@
45
45
  <p class="text-sm text-foreground-secondary leading-relaxed whitespace-pre-wrap">{item.message}</p>
46
46
 
47
47
  <!-- Votes -->
48
- <div class="flex items-center gap-2 text-sm text-foreground-tertiary">
49
- <i class="fa-solid fa-arrow-up"></i>
48
+ <div class="flex items-center gap-2 text-sm text-foreground-tertiary" aria-label="{item.votes} votes">
49
+ <i class="fa-solid fa-arrow-up" aria-hidden="true"></i>
50
50
  <span class="font-semibold">{item.votes}</span>
51
51
  <span>votes</span>
52
52
  </div>
@@ -55,7 +55,7 @@
55
55
  {#if item.adminResponse}
56
56
  <div class="rounded-lg bg-primary/5 border border-primary/20 p-3">
57
57
  <p class="text-xs font-semibold text-primary mb-1">
58
- <i class="fa-solid fa-shield mr-1"></i> Official Response
58
+ <i class="fa-solid fa-shield mr-1" aria-hidden="true"></i> Official Response
59
59
  </p>
60
60
  <p class="text-sm text-foreground-secondary">{item.adminResponse}</p>
61
61
  </div>
@@ -69,13 +69,13 @@
69
69
  {:else if responses.length > 0}
70
70
  <div class="space-y-3">
71
71
  <h4 class="text-sm font-semibold text-foreground">
72
- <i class="fa-solid fa-comments mr-1"></i> Responses
72
+ <i class="fa-solid fa-comments mr-1" aria-hidden="true"></i> Responses
73
73
  </h4>
74
74
  {#each responses as resp}
75
75
  <div class="rounded-lg bg-surface-secondary p-3">
76
76
  <div class="flex items-center justify-between mb-1">
77
77
  <span class="text-xs font-semibold text-primary">
78
- <i class="fa-solid fa-shield mr-1"></i>{resp.author || 'Team'}
78
+ <i class="fa-solid fa-shield mr-1" aria-hidden="true"></i>{resp.author || 'Team'}
79
79
  </span>
80
80
  <span class="text-[0.6875rem] text-foreground-tertiary">{timeAgo(resp.createdAt)}</span>
81
81
  </div>
@@ -13,17 +13,26 @@
13
13
  let { board, item, onclick }: Props = $props();
14
14
 
15
15
  let isClickable = $derived(!!onclick);
16
+ let voteLabel = $derived.by(() => {
17
+ const current = board.votedItems[item.id];
18
+ if (current === 'up') return 'Remove upvote';
19
+ if (current === 'down') return 'Remove downvote';
20
+ return undefined;
21
+ });
16
22
  </script>
17
23
 
18
- <!-- svelte-ignore a11y_no_static_element_interactions — clickable variant uses onclick for item selection, keyboard handled manually -->
19
- <div
24
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
25
+ <article
20
26
  class="rounded-xl border border-border bg-surface transition-colors {isClickable ? 'cursor-pointer hover:bg-surface-hover' : ''}"
27
+ role={isClickable ? 'button' : undefined}
28
+ tabindex={isClickable ? 0 : undefined}
29
+ aria-label={isClickable ? (item.title || item.message.slice(0, 80)) : undefined}
21
30
  onclick={() => onclick?.(item)}
22
31
  onkeydown={(e) => { if (isClickable && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); onclick?.(item); } }}
23
32
  >
24
33
  <div class="flex gap-3 p-4">
25
34
  <!-- Vote column -->
26
- <div class="flex flex-col items-center gap-0.5 min-w-[2.5rem]">
35
+ <div class="flex flex-col items-center gap-0.5 min-w-[2.5rem]" role="group" aria-label="Vote on this feedback">
27
36
  <!-- svelte-ignore a11y_click_events_have_key_events — IconButton handles keyboard -->
28
37
  <!-- svelte-ignore a11y_no_static_element_interactions -->
29
38
  <div onclick={(e) => e.stopPropagation()}>
@@ -31,12 +40,17 @@
31
40
  size="sm"
32
41
  variant={board.votedItems[item.id] === 'up' ? 'primary' : 'neutral'}
33
42
  onclick={() => board.vote(item.id, 'up')}
34
- label="Upvote"
43
+ label={board.votedItems[item.id] === 'up' ? 'Remove upvote' : 'Upvote'}
35
44
  >
36
45
  <i class="fa-solid fa-chevron-up text-xs"></i>
37
46
  </IconButton>
38
47
  </div>
39
- <span class="text-sm font-bold tabular-nums">{item.votes}</span>
48
+ <span class="text-sm font-bold tabular-nums" aria-label="{item.votes} votes">{item.votes}</span>
49
+ {#if board.voteErrors[item.id]}
50
+ <span class="text-[0.625rem] text-red-400" role="alert">
51
+ <i class="fa-solid fa-exclamation-circle" aria-hidden="true"></i>
52
+ </span>
53
+ {/if}
40
54
  <!-- svelte-ignore a11y_click_events_have_key_events -->
41
55
  <!-- svelte-ignore a11y_no_static_element_interactions -->
42
56
  <div onclick={(e) => e.stopPropagation()}>
@@ -44,7 +58,7 @@
44
58
  size="sm"
45
59
  variant={board.votedItems[item.id] === 'down' ? 'primary' : 'neutral'}
46
60
  onclick={() => board.vote(item.id, 'down')}
47
- label="Downvote"
61
+ label={board.votedItems[item.id] === 'down' ? 'Remove downvote' : 'Downvote'}
48
62
  >
49
63
  <i class="fa-solid fa-chevron-down text-xs"></i>
50
64
  </IconButton>
@@ -55,7 +69,7 @@
55
69
  <div class="flex-1 min-w-0">
56
70
  <div class="flex items-center gap-2 flex-wrap">
57
71
  {#if item.category}
58
- <i class="{board.getCategoryIcon(item.category)} text-sm text-foreground-secondary"></i>
72
+ <i class="{board.getCategoryIcon(item.category)} text-sm text-foreground-secondary" aria-hidden="true"></i>
59
73
  {/if}
60
74
  <h3 class="text-[0.9375rem] font-semibold flex-1 leading-snug">
61
75
  {item.title || item.message.slice(0, 80)}
@@ -72,20 +86,20 @@
72
86
  <div class="flex items-center gap-3 mt-2 text-[0.8125rem] text-foreground-tertiary">
73
87
  <span>{timeAgo(item.createdAt)}</span>
74
88
  {#if item.responseCount > 0}
75
- <span class="flex items-center gap-1">
76
- <i class="fa-solid fa-comment"></i>
77
- {item.responseCount}
89
+ <span class="flex items-center gap-1" aria-label="{item.responseCount} {item.responseCount === 1 ? 'response' : 'responses'}">
90
+ <i class="fa-solid fa-comment" aria-hidden="true"></i>
91
+ {item.responseCount} {item.responseCount === 1 ? 'response' : 'responses'}
78
92
  </span>
79
93
  {/if}
80
94
  {#if item.adminResponse}
81
95
  <span class="text-xs text-primary">
82
- <i class="fa-solid fa-shield"></i> Official response
96
+ <i class="fa-solid fa-shield" aria-hidden="true"></i> Official response
83
97
  </span>
84
98
  {/if}
85
99
  </div>
86
100
  </div>
87
101
  </div>
88
- </div>
102
+ </article>
89
103
 
90
104
  <style>
91
105
  .line-clamp-2 {
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { Spinner } from '@alliance-droid/svelte-component-library';
2
+ import { Button, Spinner } from '@alliance-droid/svelte-component-library';
3
3
  import type { BoardItem } from '../../api.js';
4
4
  import type { FeedbackBoardState } from './state.svelte.js';
5
5
  import FeedbackItem from './FeedbackItem.svelte';
@@ -22,6 +22,12 @@
22
22
  <div class="text-center py-12 text-foreground-tertiary">
23
23
  <i class="fa-solid fa-exclamation-triangle text-2xl"></i>
24
24
  <p class="mt-2">{board.error}</p>
25
+ <button
26
+ onclick={() => board.reload()}
27
+ class="mt-3 rounded-md bg-surface-hover px-4 py-2 text-sm text-foreground-secondary transition-colors hover:bg-border"
28
+ >
29
+ <i class="fa-solid fa-rotate-right mr-1"></i> Retry
30
+ </button>
25
31
  </div>
26
32
  {:else if board.items.length === 0}
27
33
  <div class="text-center py-12 text-foreground-tertiary">
@@ -35,4 +41,18 @@
35
41
  <FeedbackItem {board} {item} onclick={onSelect ? () => onSelect(item) : undefined} />
36
42
  {/each}
37
43
  </div>
44
+
45
+ {#if board.hasMore}
46
+ <div class="mt-4 text-center">
47
+ <Button
48
+ variant="neutral"
49
+ style="outline"
50
+ disabled={board.loadingMore}
51
+ loading={board.loadingMore}
52
+ onclick={() => board.loadMore()}
53
+ >
54
+ Load More ({board.items.length} of {board.total})
55
+ </Button>
56
+ </div>
57
+ {/if}
38
58
  {/if}
@@ -20,13 +20,13 @@
20
20
  ];
21
21
  </script>
22
22
 
23
- <aside class="flex flex-col gap-1">
23
+ <aside class="flex flex-col gap-1" aria-label="Filter feedback">
24
24
  <button
25
25
  class="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left w-full transition-colors
26
26
  {board.activeCategory === null ? 'bg-surface-secondary text-foreground font-semibold' : 'text-foreground-secondary hover:bg-surface-hover hover:text-foreground'}"
27
27
  onclick={() => (board.activeCategory = null)}
28
28
  >
29
- <i class="fa-solid fa-layer-group"></i>
29
+ <i class="fa-solid fa-layer-group" aria-hidden="true"></i>
30
30
  <span>All</span>
31
31
  <span class="ml-auto bg-surface-hover px-2 py-0.5 rounded-full text-xs">{board.total}</span>
32
32
  </button>
@@ -37,7 +37,7 @@
37
37
  {board.activeCategory === cat.name ? 'bg-surface-secondary text-foreground font-semibold' : 'text-foreground-secondary hover:bg-surface-hover hover:text-foreground'}"
38
38
  onclick={() => (board.activeCategory = board.activeCategory === cat.name ? null : cat.name)}
39
39
  >
40
- <i class={board.getCategoryIcon(cat.name)}></i>
40
+ <i class={board.getCategoryIcon(cat.name)} aria-hidden="true"></i>
41
41
  <span>{board.getCategoryLabel(cat.name)}</span>
42
42
  <span class="ml-auto bg-surface-hover px-2 py-0.5 rounded-full text-xs">{cat.count}</span>
43
43
  </button>
@@ -16,6 +16,8 @@ export interface FeedbackBoardOptions {
16
16
  apiUrl: string;
17
17
  apiKey: string;
18
18
  categoryConfig?: Record<string, CategoryConfig>;
19
+ /** Items per page. Default: 50 */
20
+ pageSize?: number;
19
21
  }
20
22
  export interface FeedbackBoardState {
21
23
  readonly client: StatusClient;
@@ -24,12 +26,16 @@ export interface FeedbackBoardState {
24
26
  readonly categories: BoardCategory[];
25
27
  readonly total: number;
26
28
  readonly loading: boolean;
29
+ readonly loadingMore: boolean;
27
30
  readonly error: string;
31
+ readonly hasMore: boolean;
28
32
  activeCategory: string | null;
29
33
  statusFilter: string;
30
34
  sortBy: string;
31
35
  readonly votedItems: Record<string, 'up' | 'down'>;
36
+ readonly voteErrors: Record<string, string>;
32
37
  reload(): Promise<void>;
38
+ loadMore(): Promise<void>;
33
39
  vote(itemId: string, direction: 'up' | 'down'): Promise<void>;
34
40
  getResponses(itemId: string): Promise<{
35
41
  message: string;
@@ -8,18 +8,20 @@
8
8
  */
9
9
  import { createStatusClient } from '../../api.js';
10
10
  export function createFeedbackBoard(options) {
11
- const { apiUrl, apiKey, categoryConfig = {} } = options;
11
+ const { apiUrl, apiKey, categoryConfig = {}, pageSize = 50 } = options;
12
12
  const client = createStatusClient({ apiUrl, apiKey });
13
13
  // Reactive state
14
14
  let items = $state([]);
15
15
  let categories = $state([]);
16
16
  let total = $state(0);
17
17
  let loading = $state(true);
18
+ let loadingMore = $state(false);
18
19
  let error = $state('');
19
20
  let activeCategory = $state(null);
20
21
  let statusFilter = $state('open');
21
22
  let sortBy = $state('votes');
22
23
  let votedItems = $state({});
24
+ let voteErrors = $state({});
23
25
  // Response cache
24
26
  const responseCache = {};
25
27
  async function load() {
@@ -30,6 +32,8 @@ export function createFeedbackBoard(options) {
30
32
  category: activeCategory || undefined,
31
33
  status: statusFilter,
32
34
  sort: sortBy,
35
+ limit: pageSize,
36
+ offset: 0,
33
37
  });
34
38
  items = data.items;
35
39
  total = data.total;
@@ -42,6 +46,28 @@ export function createFeedbackBoard(options) {
42
46
  loading = false;
43
47
  }
44
48
  }
49
+ async function loadMore() {
50
+ if (loadingMore || items.length >= total)
51
+ return;
52
+ loadingMore = true;
53
+ try {
54
+ const data = await client.getBoard({
55
+ category: activeCategory || undefined,
56
+ status: statusFilter,
57
+ sort: sortBy,
58
+ limit: pageSize,
59
+ offset: items.length,
60
+ });
61
+ items = [...items, ...data.items];
62
+ total = data.total;
63
+ }
64
+ catch {
65
+ // Silently fail — user can retry
66
+ }
67
+ finally {
68
+ loadingMore = false;
69
+ }
70
+ }
45
71
  // Auto-reload when filters change
46
72
  $effect(() => {
47
73
  void activeCategory;
@@ -51,6 +77,11 @@ export function createFeedbackBoard(options) {
51
77
  });
52
78
  async function vote(itemId, direction) {
53
79
  const prev = votedItems[itemId];
80
+ // Clear any prior error for this item
81
+ if (voteErrors[itemId]) {
82
+ const { [itemId]: _, ...rest } = voteErrors;
83
+ voteErrors = rest;
84
+ }
54
85
  try {
55
86
  const result = await client.vote(itemId, direction);
56
87
  const item = items.find((i) => i.id === itemId);
@@ -65,7 +96,12 @@ export function createFeedbackBoard(options) {
65
96
  }
66
97
  }
67
98
  catch {
68
- // Vote failures are silent — optimistic UI
99
+ voteErrors = { ...voteErrors, [itemId]: 'Vote failed' };
100
+ // Auto-dismiss after 3 seconds
101
+ setTimeout(() => {
102
+ const { [itemId]: _, ...rest } = voteErrors;
103
+ voteErrors = rest;
104
+ }, 3000);
69
105
  }
70
106
  }
71
107
  async function getResponses(itemId) {
@@ -94,7 +130,9 @@ export function createFeedbackBoard(options) {
94
130
  get categories() { return categories; },
95
131
  get total() { return total; },
96
132
  get loading() { return loading; },
133
+ get loadingMore() { return loadingMore; },
97
134
  get error() { return error; },
135
+ get hasMore() { return items.length < total; },
98
136
  get activeCategory() { return activeCategory; },
99
137
  set activeCategory(v) { activeCategory = v; },
100
138
  get statusFilter() { return statusFilter; },
@@ -102,7 +140,9 @@ export function createFeedbackBoard(options) {
102
140
  get sortBy() { return sortBy; },
103
141
  set sortBy(v) { sortBy = v; },
104
142
  get votedItems() { return votedItems; },
143
+ get voteErrors() { return voteErrors; },
105
144
  reload: load,
145
+ loadMore,
106
146
  vote,
107
147
  getResponses,
108
148
  submitFeedback,
@@ -0,0 +1,99 @@
1
+ <script lang="ts">
2
+ interface Props {
3
+ x: number;
4
+ y: number;
5
+ boardUrl?: string;
6
+ onReportBug: () => void;
7
+ onRequestFeature: () => void;
8
+ onViewBoard?: () => void;
9
+ }
10
+
11
+ let { x, y, boardUrl, onReportBug, onRequestFeature, onViewBoard }: Props = $props();
12
+
13
+ let adjustedX = $derived(Math.min(x, (typeof window !== 'undefined' ? window.innerWidth : 800) - 200));
14
+ let adjustedY = $derived(Math.min(y, (typeof window !== 'undefined' ? window.innerHeight : 600) - 160));
15
+
16
+ function handleKeydown(e: KeyboardEvent) {
17
+ const menu = (e.currentTarget as HTMLElement);
18
+ const items = menu.querySelectorAll<HTMLElement>('[role="menuitem"]');
19
+ const currentIndex = Array.from(items).indexOf(e.target as HTMLElement);
20
+
21
+ if (e.key === 'ArrowDown') {
22
+ e.preventDefault();
23
+ const next = currentIndex < items.length - 1 ? currentIndex + 1 : 0;
24
+ items[next]?.focus();
25
+ } else if (e.key === 'ArrowUp') {
26
+ e.preventDefault();
27
+ const prev = currentIndex > 0 ? currentIndex - 1 : items.length - 1;
28
+ items[prev]?.focus();
29
+ }
30
+ }
31
+ </script>
32
+
33
+ <!-- svelte-ignore a11y_no_static_element_interactions — context menu positioned at cursor -->
34
+ <div
35
+ class="fixed z-[99999]"
36
+ style="left: {adjustedX}px; top: {adjustedY}px;"
37
+ onmousedown={(e) => e.stopPropagation()}
38
+ >
39
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
40
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
41
+ <div class="sf-ctx-menu" role="menu" tabindex="-1" aria-label="Feedback actions" onkeydown={handleKeydown}>
42
+ <button class="sf-ctx-item" role="menuitem" onclick={onReportBug}>
43
+ <i class="fa-solid fa-bug text-red-400" aria-hidden="true"></i>
44
+ Report Bug
45
+ </button>
46
+ <button class="sf-ctx-item" role="menuitem" onclick={onRequestFeature}>
47
+ <i class="fa-solid fa-lightbulb text-amber-400" aria-hidden="true"></i>
48
+ Request Feature
49
+ </button>
50
+ {#if boardUrl && onViewBoard}
51
+ <div class="sf-ctx-divider" role="separator">
52
+ <button class="sf-ctx-item sf-ctx-item-secondary" role="menuitem" onclick={onViewBoard}>
53
+ <i class="fa-solid fa-clipboard-list" aria-hidden="true"></i>
54
+ View Open Issues
55
+ </button>
56
+ </div>
57
+ {/if}
58
+ </div>
59
+ </div>
60
+
61
+ <style>
62
+ .sf-ctx-menu {
63
+ background: var(--sf-surface, var(--color-surface, #1e293b));
64
+ border: 1px solid var(--sf-border, var(--color-border, #334155));
65
+ border-radius: 0.5rem;
66
+ box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5);
67
+ overflow: hidden;
68
+ min-width: 180px;
69
+ }
70
+
71
+ .sf-ctx-item {
72
+ display: flex;
73
+ align-items: center;
74
+ gap: 0.625rem;
75
+ width: 100%;
76
+ padding: 0.625rem 0.75rem;
77
+ font-size: 0.875rem;
78
+ color: var(--sf-foreground, var(--color-foreground, #e2e8f0));
79
+ background: transparent;
80
+ border: none;
81
+ cursor: pointer;
82
+ text-align: left;
83
+ transition: background 0.15s;
84
+ }
85
+
86
+ .sf-ctx-item:hover,
87
+ .sf-ctx-item:focus-visible {
88
+ background: var(--sf-surface-hover, var(--color-surface-hover, #334155));
89
+ outline: none;
90
+ }
91
+
92
+ .sf-ctx-item-secondary {
93
+ color: var(--sf-foreground-secondary, var(--color-foreground-secondary, #94a3b8));
94
+ }
95
+
96
+ .sf-ctx-divider {
97
+ border-top: 1px solid var(--sf-border, var(--color-border, #334155));
98
+ }
99
+ </style>
@@ -0,0 +1,11 @@
1
+ interface Props {
2
+ x: number;
3
+ y: number;
4
+ boardUrl?: string;
5
+ onReportBug: () => void;
6
+ onRequestFeature: () => void;
7
+ onViewBoard?: () => void;
8
+ }
9
+ declare const DevContextMenu: import("svelte").Component<Props, {}, "">;
10
+ type DevContextMenu = ReturnType<typeof DevContextMenu>;
11
+ export default DevContextMenu;
@@ -0,0 +1,73 @@
1
+ <script lang="ts">
2
+ interface Props {
3
+ onDismiss: () => void;
4
+ }
5
+
6
+ let { onDismiss }: Props = $props();
7
+ </script>
8
+
9
+ <div class="fixed bottom-4 right-4 z-[99997] animate-fade-in">
10
+ <div class="sf-toast">
11
+ <i class="fa-solid fa-computer-mouse sf-toast-icon"></i>
12
+ <div>
13
+ <p class="sf-toast-title">Ctrl + Right-click anywhere</p>
14
+ <p class="sf-toast-subtitle">to report bugs or request features</p>
15
+ </div>
16
+ <button class="sf-toast-close" onclick={onDismiss} aria-label="Dismiss">
17
+ <i class="fa-solid fa-xmark"></i>
18
+ </button>
19
+ </div>
20
+ </div>
21
+
22
+ <style>
23
+ @keyframes fade-in {
24
+ from { opacity: 0; transform: translateY(8px); }
25
+ to { opacity: 1; transform: translateY(0); }
26
+ }
27
+ .animate-fade-in {
28
+ animation: fade-in 0.3s ease-out;
29
+ }
30
+
31
+ .sf-toast {
32
+ background: var(--sf-surface, var(--color-surface, #1e293b));
33
+ border: 1px solid var(--sf-border, var(--color-border, #334155));
34
+ border-radius: 0.5rem;
35
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
36
+ padding: 0.75rem 1rem;
37
+ display: flex;
38
+ align-items: center;
39
+ gap: 0.75rem;
40
+ max-width: 20rem;
41
+ color: var(--sf-foreground, var(--color-foreground, #e2e8f0));
42
+ }
43
+
44
+ .sf-toast-icon {
45
+ color: var(--color-primary, #3b82f6);
46
+ font-size: 1.125rem;
47
+ }
48
+
49
+ .sf-toast-title {
50
+ font-size: 0.875rem;
51
+ font-weight: 600;
52
+ margin: 0;
53
+ }
54
+
55
+ .sf-toast-subtitle {
56
+ font-size: 0.75rem;
57
+ color: var(--sf-foreground-secondary, var(--color-foreground-secondary, #94a3b8));
58
+ margin: 0;
59
+ }
60
+
61
+ .sf-toast-close {
62
+ color: var(--sf-foreground-tertiary, var(--color-foreground-tertiary, #64748b));
63
+ background: transparent;
64
+ border: none;
65
+ cursor: pointer;
66
+ margin-left: 0.5rem;
67
+ transition: color 0.15s;
68
+ }
69
+
70
+ .sf-toast-close:hover {
71
+ color: var(--sf-foreground, var(--color-foreground, #e2e8f0));
72
+ }
73
+ </style>
@@ -0,0 +1,6 @@
1
+ interface Props {
2
+ onDismiss: () => void;
3
+ }
4
+ declare const DevOnboardingToast: import("svelte").Component<Props, {}, "">;
5
+ type DevOnboardingToast = ReturnType<typeof DevOnboardingToast>;
6
+ export default DevOnboardingToast;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Environment detection utilities for DevFeedback.
3
+ */
4
+ /**
5
+ * Read an environment variable from common SvelteKit/Vite injection patterns.
6
+ */
7
+ export declare function getEnvVar(key: string): string | undefined;
8
+ /**
9
+ * Detect whether DevFeedback should be enabled.
10
+ * Priority: explicit prop > env var > Vercel env > localhost.
11
+ */
12
+ export declare function detectEnvironment(explicitEnabled?: boolean): boolean;
13
+ /**
14
+ * Get a human-readable environment name.
15
+ */
16
+ export declare function getEnvironmentName(): string;
17
+ /**
18
+ * Get a CSS selector path for a DOM element (for bug context).
19
+ */
20
+ export declare function getElementSelector(el: HTMLElement | null): string;
21
+ /**
22
+ * Get a truncated outer HTML snippet for a DOM element (for bug context).
23
+ */
24
+ export declare function getElementHtml(el: HTMLElement | null): string;