@picked/svelte 0.0.1-dev.1793923852884 → 0.0.1-dev.1793933802654

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 (30) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/plugins/devtools/DevToolsExperience.svelte +32 -2
  3. package/dist/plugins/devtools/derive/guide-sections/guide-sections.d.ts +1 -1
  4. package/dist/plugins/devtools/derive/guide-sections/guide-sections.js +1 -1
  5. package/dist/plugins/devtools/derive/index.d.ts +3 -1
  6. package/dist/plugins/devtools/derive/index.js +1 -1
  7. package/dist/plugins/devtools/derive/issue-report/index.d.ts +3 -0
  8. package/dist/plugins/devtools/derive/issue-report/index.js +1 -0
  9. package/dist/plugins/devtools/derive/issue-report/issue-report.d.ts +255 -0
  10. package/dist/plugins/devtools/derive/issue-report/issue-report.js +2 -0
  11. package/dist/plugins/devtools/derive/plain-language/index.d.ts +1 -1
  12. package/dist/plugins/devtools/derive/plain-language/index.js +1 -1
  13. package/dist/plugins/devtools/derive/plain-language/plain-language.d.ts +6 -0
  14. package/dist/plugins/devtools/derive/plain-language/plain-language.js +1 -1
  15. package/dist/plugins/devtools/index.d.ts +4 -1
  16. package/dist/plugins/devtools/index.js +1 -1
  17. package/dist/plugins/devtools/options.d.ts +30 -1
  18. package/dist/plugins/devtools/options.js +1 -1
  19. package/dist/plugins/devtools/review/index.d.ts +3 -2
  20. package/dist/plugins/devtools/review/index.js +1 -1
  21. package/dist/plugins/devtools/review/issues/ReportIssuePanel.svelte +805 -0
  22. package/dist/plugins/devtools/review/issues/capture-screenshot.d.ts +38 -0
  23. package/dist/plugins/devtools/review/issues/capture-screenshot.js +1 -0
  24. package/dist/plugins/devtools/review/issues/read-attachment.d.ts +27 -0
  25. package/dist/plugins/devtools/review/issues/read-attachment.js +1 -0
  26. package/dist/plugins/devtools/review/issues/site-endpoint.d.ts +17 -0
  27. package/dist/plugins/devtools/review/issues/site-endpoint.js +1 -0
  28. package/dist/plugins/devtools/sidebar/nav.d.ts +6 -3
  29. package/dist/plugins/devtools/sidebar/nav.js +1 -1
  30. package/package.json +2 -2
@@ -0,0 +1,805 @@
1
+ <script lang="ts">
2
+ /**
3
+ * @file The Report an issue tab: a title, a description, a kind and a priority, a screenshot or
4
+ * files, and the context the report carries, which the reviewer can trim before sending. Without
5
+ * a sink it says how to set reports up.
6
+ */
7
+ import type { Picked } from '@picked/client'
8
+ import { Banner, Button, Card, CopyButton, EmptyState, Select, Stack, TextField } from '../../ui'
9
+ import {
10
+ ATTACHMENT_TYPES,
11
+ ISSUE_KINDS,
12
+ ISSUE_PRIORITIES,
13
+ attachmentProblem,
14
+ buildIssueContext,
15
+ buildIssueReport,
16
+ contextRows,
17
+ draftProblem,
18
+ toggled,
19
+ withoutRows,
20
+ type ContextRowId,
21
+ type IssueAttachment,
22
+ type IssueContext,
23
+ type IssueKind,
24
+ type IssuePriority,
25
+ type IssueReceipt,
26
+ type RouteEntry,
27
+ } from '../../derive'
28
+ import type { ResolvedIssueOptions } from '../../options'
29
+ import type { JourneyView } from '../../session'
30
+ import { readStored, writeStored } from '../../sidebar/nav'
31
+ import { captureScreenshot } from './capture-screenshot'
32
+ import { previewUrl, readAttachment } from './read-attachment'
33
+
34
+ interface Props {
35
+ client: Picked
36
+ /** `null` while the site has not set reports up. */
37
+ issues: ResolvedIssueOptions | null
38
+ journey: JourneyView | null
39
+ route: readonly RouteEntry[]
40
+ /** Keys that code commits, which a report leaves out. */
41
+ codeOwned: ReadonlySet<string>
42
+ /** The persona the reviewer walks through as. */
43
+ persona: { id: string; name: string } | null
44
+ /** Re-reads the journey before the context is taken. */
45
+ refreshJourney: () => void
46
+ /** Hides the panel while a screenshot is taken, and shows it again. */
47
+ onCapturing: (capturing: boolean) => void
48
+ }
49
+
50
+ const { client, issues, journey, route, codeOwned, persona, refreshJourney, onCapturing }: Props =
51
+ $props()
52
+
53
+ const docsUrl = $derived(
54
+ `${client.documentationSiteUrl}/how-to/send-issue-reports-to-your-tracker`,
55
+ )
56
+ const workspace = $derived(client.searchParams['preview-token']?.[0] ? 'draft' : 'published')
57
+
58
+ /** `sessionStorage` key holding the unsent title, description, kind and priority. */
59
+ const DRAFT_KEY = 'picked-devtools-issue-draft'
60
+ const sessionStorageOf = (): Storage => window.sessionStorage
61
+
62
+ /** What the form keeps for this tab while it is unsent. */
63
+ interface StoredDraft {
64
+ title: string
65
+ description: string
66
+ kind: IssueKind
67
+ priority: IssuePriority
68
+ }
69
+
70
+ const EMPTY_DRAFT: StoredDraft = { title: '', description: '', kind: 'bug', priority: 'none' }
71
+
72
+ /**
73
+ * The draft this tab left unsent, read back from `sessionStorage`.
74
+ *
75
+ * @returns {StoredDraft} The draft, or an empty one.
76
+ * @example readDraft().title
77
+ */
78
+ function readDraft(): StoredDraft {
79
+ const stored = readStored(sessionStorageOf, DRAFT_KEY)
80
+ if (stored === null) return EMPTY_DRAFT
81
+ try {
82
+ const parsed: unknown = JSON.parse(stored)
83
+ if (typeof parsed !== 'object' || parsed === null) return EMPTY_DRAFT
84
+ const entries = Object.fromEntries(Object.entries(parsed))
85
+ return {
86
+ title: typeof entries.title === 'string' ? entries.title : '',
87
+ description: typeof entries.description === 'string' ? entries.description : '',
88
+ kind: ISSUE_KINDS.find((option) => option.value === entries.kind)?.value ?? 'bug',
89
+ priority:
90
+ ISSUE_PRIORITIES.find((option) => option.value === entries.priority)?.value ?? 'none',
91
+ }
92
+ } catch {
93
+ return EMPTY_DRAFT
94
+ }
95
+ }
96
+
97
+ const draft = readDraft()
98
+ let title = $state(draft.title)
99
+ let description = $state(draft.description)
100
+ let kind = $state<IssueKind>(draft.kind)
101
+ let priority = $state<IssuePriority>(draft.priority)
102
+ let attachments = $state.raw<IssueAttachment[]>([])
103
+ let removed = $state.raw<ReadonlySet<ContextRowId>>(new Set())
104
+ let droppedLabels = $state.raw<ReadonlySet<string>>(new Set())
105
+ let titleError = $state<string | undefined>(undefined)
106
+ let fileError = $state<string | null>(null)
107
+ let capturing = $state(false)
108
+ let status = $state<
109
+ | { kind: 'editing' }
110
+ | { kind: 'sending' }
111
+ | { kind: 'sent'; receipt: IssueReceipt }
112
+ | { kind: 'failed'; message: string }
113
+ >({ kind: 'editing' })
114
+ let fileInput = $state<HTMLInputElement | undefined>(undefined)
115
+
116
+ $effect(() => {
117
+ writeStored(sessionStorageOf, DRAFT_KEY, JSON.stringify({ title, description, kind, priority }))
118
+ })
119
+
120
+ /**
121
+ * Takes the context now: where the visitor is, how they got there, and their responses.
122
+ *
123
+ * @returns {IssueContext} The context.
124
+ * @example takeContext().step
125
+ */
126
+ function takeContext(): IssueContext {
127
+ refreshJourney()
128
+ return buildIssueContext({
129
+ capturedAt: new Date(),
130
+ pagePath: window.location.pathname,
131
+ workspace,
132
+ journey,
133
+ route,
134
+ responses: client.responses.getAllStore.getState(),
135
+ codeOwned,
136
+ persona,
137
+ site: issues?.context(client) ?? {},
138
+ redact: issues?.redact ?? null,
139
+ })
140
+ }
141
+
142
+ let context = $state.raw<IssueContext>(takeContext())
143
+ const rows = $derived(contextRows(context))
144
+ const suggestedLabels = $derived(issues?.labels(context) ?? [])
145
+ const labels = $derived(suggestedLabels.filter((label) => !droppedLabels.has(label)))
146
+ const sending = $derived(status.kind === 'sending')
147
+
148
+ /**
149
+ * Takes the context again, keeping the parts the reviewer removed out.
150
+ *
151
+ * @example refresh()
152
+ */
153
+ function refresh(): void {
154
+ context = takeContext()
155
+ }
156
+
157
+ /**
158
+ * Removes a part of the context from the report, or puts it back.
159
+ *
160
+ * @param {ContextRowId} id - The part.
161
+ * @example toggleRow('responses')
162
+ */
163
+ function toggleRow(id: ContextRowId): void {
164
+ removed = toggled(removed, id)
165
+ }
166
+
167
+ /**
168
+ * Drops a suggested label from the report, or puts it back.
169
+ *
170
+ * @param {string} label - The label.
171
+ * @example toggleLabel('exp/funnel')
172
+ */
173
+ function toggleLabel(label: string): void {
174
+ droppedLabels = toggled(droppedLabels, label)
175
+ }
176
+
177
+ /**
178
+ * Attaches files, refusing any a report cannot carry.
179
+ *
180
+ * @param {Iterable<File>} files - The files picked or dropped.
181
+ * @example await attachFiles(input.files)
182
+ */
183
+ async function attachFiles(files: Iterable<File>): Promise<void> {
184
+ fileError = null
185
+ for (const file of files) {
186
+ const problem = attachmentProblem(file, attachments)
187
+ if (problem !== null) {
188
+ fileError = problem
189
+ continue
190
+ }
191
+
192
+ try {
193
+ attachments = [...attachments, await readAttachment(file, file.name)]
194
+ } catch {
195
+ fileError = `"${file.name}" could not be read.`
196
+ }
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Takes a screenshot of the page, with the panel hidden for it.
202
+ *
203
+ * @example await takeScreenshot()
204
+ */
205
+ async function takeScreenshot(): Promise<void> {
206
+ fileError = null
207
+ capturing = true
208
+ try {
209
+ const shot = await captureScreenshot({
210
+ hide: () => onCapturing(true),
211
+ show: () => onCapturing(false),
212
+ })
213
+ const problem = attachmentProblem(
214
+ { name: shot.filename, type: shot.contentType, size: shot.size },
215
+ attachments,
216
+ )
217
+ if (problem === null) attachments = [...attachments, shot]
218
+ else fileError = problem
219
+ } catch (error) {
220
+ fileError = error instanceof Error ? error.message : 'No screenshot was taken.'
221
+ } finally {
222
+ capturing = false
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Removes an attachment.
228
+ *
229
+ * @param {IssueAttachment} attachment - The attachment.
230
+ * @example detach(attachments[0])
231
+ */
232
+ function detach(attachment: IssueAttachment): void {
233
+ attachments = attachments.filter((entry) => entry !== attachment)
234
+ }
235
+
236
+ /**
237
+ * Sends the report through the site's sink.
238
+ *
239
+ * @example await send()
240
+ */
241
+ async function send(): Promise<void> {
242
+ if (issues === null || sending) return
243
+ const problem = draftProblem({ title, description })
244
+ titleError = problem ?? undefined
245
+ if (problem !== null) return
246
+ status = { kind: 'sending' }
247
+ try {
248
+ const report = buildIssueReport(
249
+ { title, description, kind, priority, labels, attachments },
250
+ withoutRows(context, removed),
251
+ )
252
+ status = { kind: 'sent', receipt: await issues.sink(report) }
253
+ title = ''
254
+ description = ''
255
+ attachments = []
256
+ } catch (error) {
257
+ status = {
258
+ kind: 'failed',
259
+ message: error instanceof Error ? error.message : 'The report was not sent.',
260
+ }
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Starts a new report: an empty form, and the context taken again.
266
+ *
267
+ * @example startOver()
268
+ */
269
+ function startOver(): void {
270
+ title = ''
271
+ description = ''
272
+ kind = 'bug'
273
+ priority = 'none'
274
+ attachments = []
275
+ removed = new Set()
276
+ droppedLabels = new Set()
277
+ titleError = undefined
278
+ fileError = null
279
+ status = { kind: 'editing' }
280
+ refresh()
281
+ }
282
+
283
+ /**
284
+ * Sends on Ctrl or Cmd with Enter in the description, where Enter alone starts a new line.
285
+ *
286
+ * @param {KeyboardEvent} event - The key press.
287
+ * @example onKeydown(event)
288
+ */
289
+ function onKeydown(event: KeyboardEvent): void {
290
+ if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
291
+ event.preventDefault()
292
+ void send()
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Attaches the files dropped on the attachments area.
298
+ *
299
+ * @param {DragEvent} event - The drop.
300
+ * @example onDrop(event)
301
+ */
302
+ function onDrop(event: DragEvent): void {
303
+ event.preventDefault()
304
+ const files = event.dataTransfer?.files
305
+ if (files) void attachFiles(files)
306
+ }
307
+
308
+ /**
309
+ * A file size, in kB or MB.
310
+ *
311
+ * @param {number} bytes - The size.
312
+ * @returns {string} The size as the reviewer reads it.
313
+ * @example sizeText(1_572_864) // '1.5 MB'
314
+ */
315
+ function sizeText(bytes: number): string {
316
+ if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} kB`
317
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
318
+ }
319
+ </script>
320
+
321
+ {#if issues === null}
322
+ <Stack direction="vertical" gap="medium" padding="medium" justify="center">
323
+ <EmptyState
324
+ message="Reports are not set up on this site yet"
325
+ hint="A developer connects the devtools to the team's issue tracker. Each report then goes straight to it, with where you are in the experience."
326
+ />
327
+ <Button variant="secondary" size="small" href={docsUrl} target="_blank">
328
+ How to set up reports
329
+ </Button>
330
+ </Stack>
331
+ {:else if status.kind === 'sent'}
332
+ <div class="sent" data-testid="devtools-issue-sent">
333
+ <Banner status="success" title="Report sent">
334
+ {#if status.receipt.reference}
335
+ Its reference is <strong>{status.receipt.reference}</strong>.
336
+ {:else}
337
+ The team has it.
338
+ {/if}
339
+ {#if status.receipt.url}
340
+ <a class="issue-link" href={status.receipt.url} target="_blank" rel="noopener noreferrer"
341
+ >Open the issue</a
342
+ >
343
+ {/if}
344
+ </Banner>
345
+ <div>
346
+ <Button variant="secondary" size="small" onclick={startOver}>Report another issue</Button>
347
+ </div>
348
+ </div>
349
+ {:else}
350
+ <form
351
+ class="report"
352
+ novalidate
353
+ data-testid="devtools-issue-form"
354
+ onsubmit={(event) => {
355
+ event.preventDefault()
356
+ void send()
357
+ }}
358
+ >
359
+ {#if status.kind === 'failed'}
360
+ <Banner status="error" title="The report was not sent" testId="devtools-issue-failed">
361
+ {status.message} Your report is still here; try again.
362
+ </Banner>
363
+ {/if}
364
+
365
+ <TextField
366
+ label="Title"
367
+ name="issue-title"
368
+ required
369
+ placeholder="What is wrong, in a few words"
370
+ value={title}
371
+ error={titleError}
372
+ oninput={(value) => {
373
+ title = value
374
+ titleError = undefined
375
+ }}
376
+ />
377
+
378
+ <div class="field">
379
+ <label class="label" for="field-issue-description">Description</label>
380
+ <textarea
381
+ id="field-issue-description"
382
+ class="textarea"
383
+ name="issue-description"
384
+ rows="5"
385
+ placeholder="What you did, what you saw, and what you expected to see"
386
+ value={description}
387
+ onkeydown={onKeydown}
388
+ oninput={(event) => {
389
+ if (event.target instanceof HTMLTextAreaElement) description = event.target.value
390
+ }}
391
+ ></textarea>
392
+ </div>
393
+
394
+ <div class="pair">
395
+ <Select
396
+ label="Kind"
397
+ name="issue-kind"
398
+ options={ISSUE_KINDS.map((option) => ({ value: option.value, label: option.label }))}
399
+ value={kind}
400
+ onchange={(value) => {
401
+ kind = ISSUE_KINDS.find((option) => option.value === value)?.value ?? 'bug'
402
+ }}
403
+ />
404
+ <Select
405
+ label="Priority"
406
+ name="issue-priority"
407
+ options={ISSUE_PRIORITIES.map((option) => ({ value: option.value, label: option.label }))}
408
+ value={priority}
409
+ onchange={(value) => {
410
+ priority = ISSUE_PRIORITIES.find((option) => option.value === value)?.value ?? 'none'
411
+ }}
412
+ />
413
+ </div>
414
+
415
+ {#if suggestedLabels.length > 0}
416
+ <div class="field">
417
+ <span class="label">Labels</span>
418
+ <div class="chips" role="group" aria-label="Labels">
419
+ {#each suggestedLabels as label (label)}
420
+ <button
421
+ type="button"
422
+ class="chip"
423
+ class:dropped={droppedLabels.has(label)}
424
+ aria-pressed={!droppedLabels.has(label)}
425
+ data-testid="devtools-issue-label"
426
+ onclick={() => toggleLabel(label)}
427
+ >
428
+ {label}
429
+ </button>
430
+ {/each}
431
+ </div>
432
+ </div>
433
+ {/if}
434
+
435
+ <section
436
+ class="attachments"
437
+ aria-label="Screenshots and files"
438
+ ondragover={(event) => event.preventDefault()}
439
+ ondrop={onDrop}
440
+ >
441
+ <div class="attachment-actions">
442
+ <Button
443
+ variant="secondary"
444
+ size="small"
445
+ loading={capturing}
446
+ testId="devtools-issue-screenshot"
447
+ onclick={() => void takeScreenshot()}
448
+ >
449
+ Take screenshot
450
+ </Button>
451
+ <Button variant="secondary" size="small" onclick={() => fileInput?.click()}>
452
+ Attach file
453
+ </Button>
454
+ <span class="hint">or drop files here</span>
455
+ <input
456
+ bind:this={fileInput}
457
+ class="file-input"
458
+ type="file"
459
+ multiple
460
+ accept={ATTACHMENT_TYPES.join(',')}
461
+ tabindex="-1"
462
+ aria-hidden="true"
463
+ onchange={(event) => {
464
+ const input = event.currentTarget
465
+ if (!input.files) return
466
+ void attachFiles([...input.files]).then(() => {
467
+ input.value = ''
468
+ })
469
+ }}
470
+ />
471
+ </div>
472
+ {#if fileError}
473
+ <p class="file-error" role="alert">{fileError}</p>
474
+ {/if}
475
+ {#if attachments.length > 0}
476
+ <ul class="files" data-testid="devtools-issue-files">
477
+ {#each attachments as attachment (attachment)}
478
+ {@const preview = previewUrl(attachment)}
479
+ <li class="file">
480
+ {#if preview}
481
+ <img class="thumb" src={preview} alt="" />
482
+ {:else}
483
+ <span class="thumb doc" aria-hidden="true"
484
+ >{attachment.filename.split('.').pop()}</span
485
+ >
486
+ {/if}
487
+ <span class="file-name">{attachment.filename}</span>
488
+ <span class="file-size">{sizeText(attachment.size)}</span>
489
+ <button
490
+ type="button"
491
+ class="text-button"
492
+ aria-label="Remove {attachment.filename}"
493
+ onclick={() => detach(attachment)}>Remove</button
494
+ >
495
+ </li>
496
+ {/each}
497
+ </ul>
498
+ {/if}
499
+ </section>
500
+
501
+ <Card>
502
+ {#snippet header()}
503
+ <div class="context-head">
504
+ <span class="context-title">Sent with the report</span>
505
+ <span class="context-actions">
506
+ <button type="button" class="text-button" onclick={refresh}>Refresh</button>
507
+ <CopyButton
508
+ text={JSON.stringify(withoutRows(context, removed), null, 2)}
509
+ label="Copy context"
510
+ />
511
+ </span>
512
+ </div>
513
+ {/snippet}
514
+ {#if rows.length === 0}
515
+ <p class="empty">Nothing about the session goes with this report.</p>
516
+ {:else}
517
+ <dl class="context" data-testid="devtools-issue-context">
518
+ {#each rows as row (row.id)}
519
+ <div class="context-row" class:removed={removed.has(row.id)}>
520
+ <dt>{row.label}</dt>
521
+ <dd>{removed.has(row.id) ? 'Left out' : row.value}</dd>
522
+ <button
523
+ type="button"
524
+ class="text-button"
525
+ aria-label="{removed.has(row.id) ? 'Put back' : 'Leave out'} {row.label}"
526
+ onclick={() => toggleRow(row.id)}
527
+ >{removed.has(row.id) ? 'Put back' : 'Leave out'}</button
528
+ >
529
+ </div>
530
+ {/each}
531
+ </dl>
532
+ {/if}
533
+ {#if context.hidden.length > 0 && !removed.has('responses')}
534
+ <p class="privacy">
535
+ Email addresses, phone numbers, dates and personal details in your responses are hidden.
536
+ </p>
537
+ {/if}
538
+ </Card>
539
+
540
+ <div class="send">
541
+ <Button variant="ghost" size="small" onclick={startOver} disabled={sending}>Clear</Button>
542
+ <Button
543
+ variant="primary"
544
+ size="small"
545
+ type="submit"
546
+ loading={sending}
547
+ testId="devtools-issue-send"
548
+ >
549
+ Send report
550
+ </Button>
551
+ </div>
552
+ </form>
553
+ {/if}
554
+
555
+ <style>
556
+ .report,
557
+ .sent {
558
+ display: flex;
559
+ flex-direction: column;
560
+ gap: var(--space-4);
561
+ }
562
+
563
+ .field {
564
+ display: flex;
565
+ flex-direction: column;
566
+ gap: var(--space-1);
567
+ }
568
+
569
+ .label {
570
+ font-size: var(--text-sm);
571
+ font-weight: var(--font-medium);
572
+ color: var(--color-gray-900);
573
+ }
574
+
575
+ /* Specific enough to win over the panel's monospace rule for its JSON editors. */
576
+ .field .textarea {
577
+ box-sizing: border-box;
578
+ width: 100%;
579
+ min-height: 6rem;
580
+ padding: var(--space-2) var(--space-3);
581
+ border: 1px solid var(--color-gray-300);
582
+ border-radius: var(--radius-md);
583
+ background-color: var(--color-white);
584
+ color: var(--color-gray-900);
585
+ font-family: inherit;
586
+ font-size: var(--text-sm);
587
+ line-height: 1.5;
588
+ resize: vertical;
589
+ }
590
+
591
+ .field .textarea:focus {
592
+ outline: none;
593
+ box-shadow: 0 0 0 var(--ring-width) var(--ring-color);
594
+ }
595
+
596
+ .pair {
597
+ display: grid;
598
+ grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
599
+ gap: var(--space-3);
600
+ }
601
+
602
+ .chips {
603
+ display: flex;
604
+ flex-wrap: wrap;
605
+ gap: var(--space-1);
606
+ }
607
+
608
+ .chip {
609
+ padding: var(--space-1) var(--space-2-5);
610
+ border: 1px solid var(--color-blue-600);
611
+ border-radius: var(--radius-full);
612
+ background: var(--color-blue-600);
613
+ color: var(--color-white);
614
+ font-family: inherit;
615
+ font-size: var(--text-xs);
616
+ cursor: pointer;
617
+ }
618
+
619
+ .chip.dropped {
620
+ border-color: var(--color-gray-300);
621
+ background: var(--color-white);
622
+ color: var(--color-gray-500);
623
+ text-decoration: line-through;
624
+ }
625
+
626
+ .chip:focus-visible,
627
+ .text-button:focus-visible {
628
+ outline: var(--ring-width) solid var(--ring-color);
629
+ outline-offset: var(--ring-offset);
630
+ }
631
+
632
+ .attachments {
633
+ display: flex;
634
+ flex-direction: column;
635
+ gap: var(--space-2);
636
+ padding: var(--space-3);
637
+ border: 1px dashed var(--color-gray-300);
638
+ border-radius: var(--radius-md);
639
+ }
640
+
641
+ .attachment-actions {
642
+ display: flex;
643
+ flex-wrap: wrap;
644
+ align-items: center;
645
+ gap: var(--space-2);
646
+ }
647
+
648
+ .hint {
649
+ color: var(--color-gray-500);
650
+ font-size: var(--text-xs);
651
+ }
652
+
653
+ .file-input {
654
+ display: none;
655
+ }
656
+
657
+ .file-error {
658
+ margin: 0;
659
+ color: var(--color-red-600);
660
+ font-size: var(--text-sm);
661
+ }
662
+
663
+ .files {
664
+ display: flex;
665
+ flex-direction: column;
666
+ gap: var(--space-2);
667
+ margin: 0;
668
+ padding: 0;
669
+ list-style: none;
670
+ }
671
+
672
+ .file {
673
+ display: grid;
674
+ grid-template-columns: 3rem minmax(0, 1fr) auto auto;
675
+ align-items: center;
676
+ gap: var(--space-2);
677
+ font-size: var(--text-sm);
678
+ }
679
+
680
+ .thumb {
681
+ width: 3rem;
682
+ height: 2.25rem;
683
+ border: 1px solid var(--color-gray-200);
684
+ border-radius: var(--radius-sm);
685
+ object-fit: cover;
686
+ }
687
+
688
+ .thumb.doc {
689
+ display: flex;
690
+ align-items: center;
691
+ justify-content: center;
692
+ background: var(--color-gray-50);
693
+ color: var(--color-gray-500);
694
+ font-size: var(--text-xs);
695
+ text-transform: uppercase;
696
+ }
697
+
698
+ .file-name {
699
+ overflow: hidden;
700
+ color: var(--color-gray-900);
701
+ text-overflow: ellipsis;
702
+ white-space: nowrap;
703
+ }
704
+
705
+ .file-size {
706
+ color: var(--color-gray-500);
707
+ font-size: var(--text-xs);
708
+ }
709
+
710
+ .text-button {
711
+ padding: 0;
712
+ border: 0;
713
+ background: none;
714
+ color: var(--color-blue-600);
715
+ font: inherit;
716
+ font-size: var(--text-xs);
717
+ cursor: pointer;
718
+ }
719
+
720
+ .text-button:hover {
721
+ text-decoration: underline;
722
+ }
723
+
724
+ .context-head {
725
+ display: flex;
726
+ flex: 1;
727
+ align-items: center;
728
+ justify-content: space-between;
729
+ gap: var(--space-2);
730
+ }
731
+
732
+ .context-title {
733
+ color: var(--color-gray-900);
734
+ font-size: var(--text-sm);
735
+ font-weight: var(--font-semibold);
736
+ }
737
+
738
+ .context-actions {
739
+ display: flex;
740
+ align-items: center;
741
+ gap: var(--space-2);
742
+ }
743
+
744
+ .context {
745
+ display: flex;
746
+ flex-direction: column;
747
+ margin: 0;
748
+ padding: var(--space-2) var(--space-4);
749
+ }
750
+
751
+ .context-row {
752
+ display: grid;
753
+ grid-template-columns: 6.5rem minmax(0, 1fr) auto;
754
+ align-items: baseline;
755
+ gap: var(--space-3);
756
+ padding: var(--space-1-5) 0;
757
+ border-bottom: 1px solid var(--color-gray-100);
758
+ font-size: var(--text-sm);
759
+ }
760
+
761
+ .context-row:last-child {
762
+ border-bottom: 0;
763
+ }
764
+
765
+ .context-row dt {
766
+ color: var(--color-gray-500);
767
+ }
768
+
769
+ .context-row dd {
770
+ margin: 0;
771
+ overflow-wrap: anywhere;
772
+ color: var(--color-gray-900);
773
+ }
774
+
775
+ .context-row.removed dd {
776
+ color: var(--color-gray-400);
777
+ font-style: italic;
778
+ }
779
+
780
+ .empty {
781
+ padding: var(--space-3) var(--space-4);
782
+ }
783
+
784
+ .empty,
785
+ .privacy {
786
+ margin: 0;
787
+ color: var(--color-gray-500);
788
+ font-size: var(--text-xs);
789
+ }
790
+
791
+ .privacy {
792
+ padding: 0 var(--space-4) var(--space-3);
793
+ }
794
+
795
+ .issue-link {
796
+ color: inherit;
797
+ font-weight: var(--font-medium);
798
+ }
799
+
800
+ .send {
801
+ display: flex;
802
+ justify-content: flex-end;
803
+ gap: var(--space-2);
804
+ }
805
+ </style>