@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,299 +1,299 @@
1
- <script lang="ts">
2
- import { onMount, onDestroy } from 'svelte';
3
- import { Badge, Spinner, Button, Textarea } from '@alliance-droid/svelte-component-library';
4
- import type { BoardItem } from '../../api.js';
5
- import type { FeedbackResponse } from '../../types.js';
6
- import type { FeedbackBoardState } from './state.svelte.js';
7
- import { timeAgo, getStatusBadgeVariant } from './utils.js';
8
-
9
- interface Props {
10
- board: FeedbackBoardState;
11
- item: BoardItem;
12
- }
13
-
14
- let { board, item }: Props = $props();
15
-
16
- let responses = $state<FeedbackResponse[]>([]);
17
- let loadingResponses = $state(true);
18
-
19
- // Response form state
20
- let replyMessage = $state('');
21
- let submittingReply = $state(false);
22
- let replyError = $state('');
23
- let replySuccess = $state(false);
24
- let replyTextareaWrapperEl: HTMLDivElement | undefined = $state();
25
- let replyTextareaEl: HTMLTextAreaElement | undefined = $state();
26
- let pastedImage = $state<string | null>(null);
27
- let pastedFilename = $state('');
28
- let pasteError = $state('');
29
-
30
- const MAX_BYTES = 5 * 1024 * 1024;
31
- const ALLOWED_MIME = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
32
-
33
- $effect(() => {
34
- if (replyTextareaWrapperEl) {
35
- replyTextareaEl = replyTextareaWrapperEl.querySelector('textarea') ?? undefined;
36
- }
37
- });
38
-
39
- $effect(() => {
40
- if (!replyTextareaEl) return;
41
- const handler = (e: ClipboardEvent) => void handlePaste(e);
42
- replyTextareaEl.addEventListener('paste', handler);
43
- return () => replyTextareaEl?.removeEventListener('paste', handler);
44
- });
45
-
46
- async function handlePaste(e: ClipboardEvent) {
47
- const clipItems = Array.from(e.clipboardData?.items ?? []);
48
- const imageItem = clipItems.find((i) => i.kind === 'file' && i.type.startsWith('image/'));
49
- if (!imageItem) return;
50
-
51
- e.preventDefault();
52
- const file = imageItem.getAsFile();
53
- if (!file) return;
54
-
55
- if (!ALLOWED_MIME.includes(file.type)) {
56
- pasteError = 'Unsupported image type \u2014 PNG, JPEG, GIF, or WebP only';
57
- return;
58
- }
59
- if (file.size > MAX_BYTES) {
60
- pasteError = 'Image too large \u2014 max 5 MB';
61
- return;
62
- }
63
-
64
- const reader = new FileReader();
65
- reader.onload = () => {
66
- pastedImage = reader.result as string;
67
- const ext = file.type.split('/')[1] || 'png';
68
- const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14);
69
- pastedFilename = `pasted-image-${stamp}.${ext}`;
70
- pasteError = '';
71
- };
72
- reader.readAsDataURL(file);
73
- }
74
-
75
- function formatSize(dataUrl: string): string {
76
- const comma = dataUrl.indexOf(',');
77
- if (comma === -1) return '';
78
- const bytes = Math.round((dataUrl.length - comma - 1) * 0.75);
79
- if (bytes < 1024) return `${bytes} B`;
80
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
81
- return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
82
- }
83
-
84
- let isAuthenticated = $derived(!!board.userEmail);
85
-
86
- onMount(async () => {
87
- responses = await board.getResponses(item.id);
88
- loadingResponses = false;
89
- });
90
-
91
- async function handleSubmitReply() {
92
- if (!replyMessage.trim() || !isAuthenticated) return;
93
- submittingReply = true;
94
- replyError = '';
95
- try {
96
- await board.submitResponse(item.id, replyMessage.trim(), pastedImage);
97
- responses = await board.getResponses(item.id);
98
- replyMessage = '';
99
- pastedImage = null;
100
- pastedFilename = '';
101
- replySuccess = true;
102
- setTimeout(() => { replySuccess = false; }, 2000);
103
- } catch (err) {
104
- replyError = err instanceof Error ? err.message : 'Failed to submit';
105
- } finally {
106
- submittingReply = false;
107
- }
108
- }
109
- </script>
110
-
111
- <div class="space-y-4">
112
- <!-- Header -->
113
- <div class="flex items-center gap-2 flex-wrap">
114
- {#if item.category}
115
- <i class="{board.getCategoryIcon(item.category)} text-foreground-secondary" aria-hidden="true"></i>
116
- {/if}
117
- <Badge variant={getStatusBadgeVariant(item.status)} size="sm">
118
- {item.status === 'new' ? 'Open' : item.status.charAt(0).toUpperCase() + item.status.slice(1)}
119
- </Badge>
120
- <span class="text-xs text-foreground-tertiary">{timeAgo(item.createdAt)}</span>
121
- {#if item.responseCount > 0}
122
- <span class="text-xs text-foreground-tertiary flex items-center gap-1">
123
- <i class="fa-solid fa-comment" aria-hidden="true"></i> {item.responseCount}
124
- </span>
125
- {/if}
126
- </div>
127
-
128
- <!-- Submitter email -->
129
- {#if item.email}
130
- <p class="text-sm text-foreground-tertiary flex items-center gap-1">
131
- <i class="fa-solid fa-envelope" aria-hidden="true"></i>
132
- {item.email}
133
- </p>
134
- {/if}
135
-
136
- <!-- Title + Message -->
137
- {#if item.title}
138
- <h3 class="text-lg font-semibold text-foreground">{item.title}</h3>
139
- {/if}
140
- <p class="text-sm text-foreground-secondary leading-relaxed whitespace-pre-wrap">{item.message}</p>
141
-
142
- <!-- Votes -->
143
- <div class="flex items-center gap-2 text-sm text-foreground-tertiary" aria-label="{item.votes} votes">
144
- <i class="fa-solid fa-arrow-up" aria-hidden="true"></i>
145
- <span class="font-semibold">{item.votes}</span>
146
- <span>votes</span>
147
- </div>
148
-
149
- <!-- Admin response -->
150
- {#if item.adminResponse}
151
- <div class="rounded-lg bg-primary/5 border border-primary/20 p-3">
152
- <p class="text-xs font-semibold text-primary mb-1">
153
- <i class="fa-solid fa-shield mr-1" aria-hidden="true"></i> Official Response
154
- </p>
155
- <p class="text-sm text-foreground-secondary">{item.adminResponse}</p>
156
- </div>
157
- {/if}
158
-
159
- <!-- Responses -->
160
- {#if loadingResponses}
161
- <div class="text-foreground-tertiary text-sm">
162
- <Spinner /> Loading responses...
163
- </div>
164
- {:else if responses.length > 0}
165
- <div class="space-y-3">
166
- <h4 class="text-sm font-semibold text-foreground">
167
- <i class="fa-solid fa-comments mr-1" aria-hidden="true"></i> Responses
168
- </h4>
169
- {#each responses as resp (resp.id)}
170
- <div class="rounded-lg bg-surface-secondary p-3">
171
- <div class="flex items-center justify-between mb-1">
172
- {#if !resp.author}
173
- <span class="text-xs font-semibold text-primary">
174
- <i class="fa-solid fa-shield mr-1" aria-hidden="true"></i>Team
175
- </span>
176
- {:else}
177
- <span class="text-xs font-semibold text-foreground-secondary">
178
- <i class="fa-solid fa-user mr-1" aria-hidden="true"></i>{resp.author}
179
- </span>
180
- {/if}
181
- <span class="text-[0.6875rem] text-foreground-tertiary">{timeAgo(resp.createdAt)}</span>
182
- </div>
183
- <p class="text-sm text-foreground-secondary leading-relaxed whitespace-pre-wrap">{resp.message}</p>
184
- {#if resp.imageUrl}
185
- <img src={resp.imageUrl} alt="Pasted screenshot" class="mt-2 max-w-full rounded border border-border" />
186
- {/if}
187
- </div>
188
- {/each}
189
- </div>
190
- {/if}
191
-
192
- <!-- Reply form -->
193
- <div class="border-t border-border pt-4 space-y-2">
194
- <h4 class="text-sm font-semibold text-foreground">
195
- <i class="fa-solid fa-reply mr-1" aria-hidden="true"></i> Add a Response
196
- </h4>
197
- {#if !isAuthenticated}
198
- <p class="text-sm text-foreground-tertiary">
199
- <i class="fa-solid fa-lock mr-1" aria-hidden="true"></i>
200
- Sign in to respond
201
- </p>
202
- {:else if replySuccess}
203
- <p class="text-sm text-green-400 font-semibold">
204
- <i class="fa-solid fa-check-circle mr-1" aria-hidden="true"></i> Response submitted!
205
- </p>
206
- {:else}
207
- <div class="flex items-center gap-1.5 text-xs text-foreground-tertiary">
208
- <i class="fa-solid fa-user" aria-hidden="true"></i>
209
- <span>Replying as <span class="font-medium text-foreground-secondary">{board.userEmail}</span></span>
210
- </div>
211
- <div bind:this={replyTextareaWrapperEl}>
212
- <Textarea
213
- value={replyMessage}
214
- oninput={(e) => (replyMessage = (e.currentTarget as HTMLTextAreaElement).value)}
215
- placeholder="Share your thoughts (paste an image to attach it)\u2026"
216
- rows={3}
217
- />
218
- </div>
219
-
220
- {#if pastedImage}
221
- <div class="sf-paste-preview">
222
- <img src={pastedImage} alt={pastedFilename} class="sf-paste-thumb" />
223
- <div class="sf-paste-meta">
224
- <span class="sf-paste-name">{pastedFilename}</span>
225
- <span class="sf-paste-size">{formatSize(pastedImage)}</span>
226
- </div>
227
- <button type="button" class="sf-paste-remove" onclick={() => { pastedImage = null; pastedFilename = ''; }} aria-label="Remove image">&times;</button>
228
- </div>
229
- {/if}
230
-
231
- {#if pasteError}
232
- <p class="text-sm" style="color: var(--color-danger, #ef4444);">{pasteError}</p>
233
- {/if}
234
-
235
- <div class="flex gap-2 items-center justify-end">
236
- <Button
237
- variant="primary"
238
- size="sm"
239
- disabled={submittingReply || !replyMessage.trim()}
240
- loading={submittingReply}
241
- onclick={handleSubmitReply}
242
- >
243
- Submit
244
- </Button>
245
- </div>
246
- {#if replyError}
247
- <p class="text-sm text-red-400">
248
- <i class="fa-solid fa-exclamation-circle mr-1" aria-hidden="true"></i>
249
- {replyError}
250
- </p>
251
- {/if}
252
- {/if}
253
- </div>
254
- </div>
255
-
256
- <style>
257
- .sf-paste-preview {
258
- display: flex;
259
- align-items: center;
260
- gap: 0.75rem;
261
- padding: 0.5rem;
262
- border: 1px solid var(--color-border, #2a2f3a);
263
- border-radius: 0.5rem;
264
- background: var(--color-surface-secondary, rgba(255, 255, 255, 0.04));
265
- }
266
- .sf-paste-thumb {
267
- width: 120px;
268
- height: 80px;
269
- object-fit: cover;
270
- border-radius: 0.25rem;
271
- flex-shrink: 0;
272
- }
273
- .sf-paste-meta {
274
- flex: 1;
275
- display: flex;
276
- flex-direction: column;
277
- min-width: 0;
278
- }
279
- .sf-paste-name {
280
- font-size: 0.8125rem;
281
- color: var(--color-foreground, #e5e7eb);
282
- white-space: nowrap;
283
- overflow: hidden;
284
- text-overflow: ellipsis;
285
- }
286
- .sf-paste-size {
287
- font-size: 0.75rem;
288
- color: var(--color-foreground-tertiary, #9ca3af);
289
- }
290
- .sf-paste-remove {
291
- background: transparent;
292
- border: none;
293
- color: var(--color-foreground-tertiary, #9ca3af);
294
- font-size: 1.25rem;
295
- cursor: pointer;
296
- padding: 0.25rem 0.5rem;
297
- }
298
- .sf-paste-remove:hover { color: var(--color-danger, #ef4444); }
299
- </style>
1
+ <script lang="ts">
2
+ import { onMount, onDestroy } from 'svelte';
3
+ import { Badge, Spinner, Button, Textarea } from '@alliance-droid/svelte-component-library';
4
+ import type { BoardItem } from '../../api.js';
5
+ import type { FeedbackResponse } from '../../types.js';
6
+ import type { FeedbackBoardState } from './state.svelte.js';
7
+ import { timeAgo, getStatusBadgeVariant, publicStatusLabel } from './utils.js';
8
+
9
+ interface Props {
10
+ board: FeedbackBoardState;
11
+ item: BoardItem;
12
+ }
13
+
14
+ let { board, item }: Props = $props();
15
+
16
+ let responses = $state<FeedbackResponse[]>([]);
17
+ let loadingResponses = $state(true);
18
+
19
+ // Response form state
20
+ let replyMessage = $state('');
21
+ let submittingReply = $state(false);
22
+ let replyError = $state('');
23
+ let replySuccess = $state(false);
24
+ let replyTextareaWrapperEl: HTMLDivElement | undefined = $state();
25
+ let replyTextareaEl: HTMLTextAreaElement | undefined = $state();
26
+ let pastedImage = $state<string | null>(null);
27
+ let pastedFilename = $state('');
28
+ let pasteError = $state('');
29
+
30
+ const MAX_BYTES = 5 * 1024 * 1024;
31
+ const ALLOWED_MIME = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
32
+
33
+ $effect(() => {
34
+ if (replyTextareaWrapperEl) {
35
+ replyTextareaEl = replyTextareaWrapperEl.querySelector('textarea') ?? undefined;
36
+ }
37
+ });
38
+
39
+ $effect(() => {
40
+ if (!replyTextareaEl) return;
41
+ const handler = (e: ClipboardEvent) => void handlePaste(e);
42
+ replyTextareaEl.addEventListener('paste', handler);
43
+ return () => replyTextareaEl?.removeEventListener('paste', handler);
44
+ });
45
+
46
+ async function handlePaste(e: ClipboardEvent) {
47
+ const clipItems = Array.from(e.clipboardData?.items ?? []);
48
+ const imageItem = clipItems.find((i) => i.kind === 'file' && i.type.startsWith('image/'));
49
+ if (!imageItem) return;
50
+
51
+ e.preventDefault();
52
+ const file = imageItem.getAsFile();
53
+ if (!file) return;
54
+
55
+ if (!ALLOWED_MIME.includes(file.type)) {
56
+ pasteError = 'Unsupported image type \u2014 PNG, JPEG, GIF, or WebP only';
57
+ return;
58
+ }
59
+ if (file.size > MAX_BYTES) {
60
+ pasteError = 'Image too large \u2014 max 5 MB';
61
+ return;
62
+ }
63
+
64
+ const reader = new FileReader();
65
+ reader.onload = () => {
66
+ pastedImage = reader.result as string;
67
+ const ext = file.type.split('/')[1] || 'png';
68
+ const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14);
69
+ pastedFilename = `pasted-image-${stamp}.${ext}`;
70
+ pasteError = '';
71
+ };
72
+ reader.readAsDataURL(file);
73
+ }
74
+
75
+ function formatSize(dataUrl: string): string {
76
+ const comma = dataUrl.indexOf(',');
77
+ if (comma === -1) return '';
78
+ const bytes = Math.round((dataUrl.length - comma - 1) * 0.75);
79
+ if (bytes < 1024) return `${bytes} B`;
80
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
81
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
82
+ }
83
+
84
+ let isAuthenticated = $derived(!!board.userEmail);
85
+
86
+ onMount(async () => {
87
+ responses = await board.getResponses(item.id);
88
+ loadingResponses = false;
89
+ });
90
+
91
+ async function handleSubmitReply() {
92
+ if (!replyMessage.trim() || !isAuthenticated) return;
93
+ submittingReply = true;
94
+ replyError = '';
95
+ try {
96
+ await board.submitResponse(item.id, replyMessage.trim(), pastedImage);
97
+ responses = await board.getResponses(item.id);
98
+ replyMessage = '';
99
+ pastedImage = null;
100
+ pastedFilename = '';
101
+ replySuccess = true;
102
+ setTimeout(() => { replySuccess = false; }, 2000);
103
+ } catch (err) {
104
+ replyError = err instanceof Error ? err.message : 'Failed to submit';
105
+ } finally {
106
+ submittingReply = false;
107
+ }
108
+ }
109
+ </script>
110
+
111
+ <div class="space-y-4">
112
+ <!-- Header -->
113
+ <div class="flex items-center gap-2 flex-wrap">
114
+ {#if item.category}
115
+ <i class="{board.getCategoryIcon(item.category)} text-foreground-secondary" aria-hidden="true"></i>
116
+ {/if}
117
+ <Badge variant={getStatusBadgeVariant(item.publicStatus)} size="sm">
118
+ {publicStatusLabel(item.publicStatus)}
119
+ </Badge>
120
+ <span class="text-xs text-foreground-tertiary">{timeAgo(item.createdAt)}</span>
121
+ {#if item.responseCount > 0}
122
+ <span class="text-xs text-foreground-tertiary flex items-center gap-1">
123
+ <i class="fa-solid fa-comment" aria-hidden="true"></i> {item.responseCount}
124
+ </span>
125
+ {/if}
126
+ </div>
127
+
128
+ <!-- Submitter email -->
129
+ {#if item.email}
130
+ <p class="text-sm text-foreground-tertiary flex items-center gap-1">
131
+ <i class="fa-solid fa-envelope" aria-hidden="true"></i>
132
+ {item.email}
133
+ </p>
134
+ {/if}
135
+
136
+ <!-- Title + Message -->
137
+ {#if item.title}
138
+ <h3 class="text-lg font-semibold text-foreground">{item.title}</h3>
139
+ {/if}
140
+ <p class="text-sm text-foreground-secondary leading-relaxed whitespace-pre-wrap">{item.message}</p>
141
+
142
+ <!-- Votes -->
143
+ <div class="flex items-center gap-2 text-sm text-foreground-tertiary" aria-label="{item.votes} votes">
144
+ <i class="fa-solid fa-arrow-up" aria-hidden="true"></i>
145
+ <span class="font-semibold">{item.votes}</span>
146
+ <span>votes</span>
147
+ </div>
148
+
149
+ <!-- Admin response -->
150
+ {#if item.adminResponse}
151
+ <div class="rounded-lg bg-primary/5 border border-primary/20 p-3">
152
+ <p class="text-xs font-semibold text-primary mb-1">
153
+ <i class="fa-solid fa-shield mr-1" aria-hidden="true"></i> Official Response
154
+ </p>
155
+ <p class="text-sm text-foreground-secondary">{item.adminResponse}</p>
156
+ </div>
157
+ {/if}
158
+
159
+ <!-- Responses -->
160
+ {#if loadingResponses}
161
+ <div class="text-foreground-tertiary text-sm">
162
+ <Spinner /> Loading responses...
163
+ </div>
164
+ {:else if responses.length > 0}
165
+ <div class="space-y-3">
166
+ <h4 class="text-sm font-semibold text-foreground">
167
+ <i class="fa-solid fa-comments mr-1" aria-hidden="true"></i> Responses
168
+ </h4>
169
+ {#each responses as resp (resp.id)}
170
+ <div class="rounded-lg bg-surface-secondary p-3">
171
+ <div class="flex items-center justify-between mb-1">
172
+ {#if !resp.author}
173
+ <span class="text-xs font-semibold text-primary">
174
+ <i class="fa-solid fa-shield mr-1" aria-hidden="true"></i>Team
175
+ </span>
176
+ {:else}
177
+ <span class="text-xs font-semibold text-foreground-secondary">
178
+ <i class="fa-solid fa-user mr-1" aria-hidden="true"></i>{resp.author}
179
+ </span>
180
+ {/if}
181
+ <span class="text-[0.6875rem] text-foreground-tertiary">{timeAgo(resp.createdAt)}</span>
182
+ </div>
183
+ <p class="text-sm text-foreground-secondary leading-relaxed whitespace-pre-wrap">{resp.message}</p>
184
+ {#if resp.imageUrl}
185
+ <img src={resp.imageUrl} alt="Pasted screenshot" class="mt-2 max-w-full rounded border border-border" />
186
+ {/if}
187
+ </div>
188
+ {/each}
189
+ </div>
190
+ {/if}
191
+
192
+ <!-- Reply form -->
193
+ <div class="border-t border-border pt-4 space-y-2">
194
+ <h4 class="text-sm font-semibold text-foreground">
195
+ <i class="fa-solid fa-reply mr-1" aria-hidden="true"></i> Add a Response
196
+ </h4>
197
+ {#if !isAuthenticated}
198
+ <p class="text-sm text-foreground-tertiary">
199
+ <i class="fa-solid fa-lock mr-1" aria-hidden="true"></i>
200
+ Sign in to respond
201
+ </p>
202
+ {:else if replySuccess}
203
+ <p class="text-sm text-green-400 font-semibold">
204
+ <i class="fa-solid fa-check-circle mr-1" aria-hidden="true"></i> Response submitted!
205
+ </p>
206
+ {:else}
207
+ <div class="flex items-center gap-1.5 text-xs text-foreground-tertiary">
208
+ <i class="fa-solid fa-user" aria-hidden="true"></i>
209
+ <span>Replying as <span class="font-medium text-foreground-secondary">{board.userEmail}</span></span>
210
+ </div>
211
+ <div bind:this={replyTextareaWrapperEl}>
212
+ <Textarea
213
+ value={replyMessage}
214
+ oninput={(e) => (replyMessage = (e.currentTarget as HTMLTextAreaElement).value)}
215
+ placeholder="Share your thoughts (paste an image to attach it)\u2026"
216
+ rows={3}
217
+ />
218
+ </div>
219
+
220
+ {#if pastedImage}
221
+ <div class="sf-paste-preview">
222
+ <img src={pastedImage} alt={pastedFilename} class="sf-paste-thumb" />
223
+ <div class="sf-paste-meta">
224
+ <span class="sf-paste-name">{pastedFilename}</span>
225
+ <span class="sf-paste-size">{formatSize(pastedImage)}</span>
226
+ </div>
227
+ <button type="button" class="sf-paste-remove" onclick={() => { pastedImage = null; pastedFilename = ''; }} aria-label="Remove image">&times;</button>
228
+ </div>
229
+ {/if}
230
+
231
+ {#if pasteError}
232
+ <p class="text-sm" style="color: var(--color-danger, #ef4444);">{pasteError}</p>
233
+ {/if}
234
+
235
+ <div class="flex gap-2 items-center justify-end">
236
+ <Button
237
+ variant="primary"
238
+ size="sm"
239
+ disabled={submittingReply || !replyMessage.trim()}
240
+ loading={submittingReply}
241
+ onclick={handleSubmitReply}
242
+ >
243
+ Submit
244
+ </Button>
245
+ </div>
246
+ {#if replyError}
247
+ <p class="text-sm text-red-400">
248
+ <i class="fa-solid fa-exclamation-circle mr-1" aria-hidden="true"></i>
249
+ {replyError}
250
+ </p>
251
+ {/if}
252
+ {/if}
253
+ </div>
254
+ </div>
255
+
256
+ <style>
257
+ .sf-paste-preview {
258
+ display: flex;
259
+ align-items: center;
260
+ gap: 0.75rem;
261
+ padding: 0.5rem;
262
+ border: 1px solid var(--color-border, #2a2f3a);
263
+ border-radius: 0.5rem;
264
+ background: var(--color-surface-secondary, rgba(255, 255, 255, 0.04));
265
+ }
266
+ .sf-paste-thumb {
267
+ width: 120px;
268
+ height: 80px;
269
+ object-fit: cover;
270
+ border-radius: 0.25rem;
271
+ flex-shrink: 0;
272
+ }
273
+ .sf-paste-meta {
274
+ flex: 1;
275
+ display: flex;
276
+ flex-direction: column;
277
+ min-width: 0;
278
+ }
279
+ .sf-paste-name {
280
+ font-size: 0.8125rem;
281
+ color: var(--color-foreground, #e5e7eb);
282
+ white-space: nowrap;
283
+ overflow: hidden;
284
+ text-overflow: ellipsis;
285
+ }
286
+ .sf-paste-size {
287
+ font-size: 0.75rem;
288
+ color: var(--color-foreground-tertiary, #9ca3af);
289
+ }
290
+ .sf-paste-remove {
291
+ background: transparent;
292
+ border: none;
293
+ color: var(--color-foreground-tertiary, #9ca3af);
294
+ font-size: 1.25rem;
295
+ cursor: pointer;
296
+ padding: 0.25rem 0.5rem;
297
+ }
298
+ .sf-paste-remove:hover { color: var(--color-danger, #ef4444); }
299
+ </style>